mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-16 19:49:51 +08:00
✨ feat(qdrant): 新增 Qdrant 向量库连接支持
- 后端新增 Qdrant REST 连接、collection 元数据、scroll/search 查询与 upsert/delete/payload 更新 - 前端新增 Qdrant 类型、连接配置、图标、方言和能力矩阵 - 测试覆盖 mock REST、真实服务 smoke 和前端配置 Refs #555
This commit is contained in:
@@ -33,10 +33,10 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain('type === "elasticsearch"');
|
||||
expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';");
|
||||
expect(source).toContain(
|
||||
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma") ? "" : "root";',
|
||||
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant") ? "" : "root";',
|
||||
);
|
||||
expect(source).toContain(
|
||||
'placeholder={(dbType === "elasticsearch" || dbType === "chroma") ? "未开启认证可留空" : undefined}',
|
||||
'placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant") ? "未开启认证可留空" : undefined}',
|
||||
);
|
||||
expect(source).toContain('label="显示数据库 (留空显示全部)"');
|
||||
});
|
||||
@@ -52,6 +52,18 @@ describe('ConnectionModal data source registry', () => {
|
||||
expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";');
|
||||
expect(source).toContain('return "tenant=default_tenant&apiKey=...";');
|
||||
});
|
||||
|
||||
it('exposes Qdrant in the create-connection picker with vector defaults', () => {
|
||||
expect(source).toContain("case 'qdrant':");
|
||||
expect(source).toContain('return 6333;');
|
||||
expect(source).toContain('qdrant: ["http", "https", "qdrant"]');
|
||||
expect(source).toContain("key: 'qdrant'");
|
||||
expect(source).toContain("name: 'Qdrant'");
|
||||
expect(source).toContain('type === "qdrant"');
|
||||
expect(source).toContain("return 'Collection 浏览、向量搜索和 Payload 过滤';");
|
||||
expect(source).toContain('return "http://127.0.0.1:6333";');
|
||||
expect(source).toContain('return "apiKey=...";');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConnectionModal Redis Sentinel configuration', () => {
|
||||
|
||||
@@ -1789,7 +1789,7 @@ const ConnectionModal: React.FC<{
|
||||
parsedValues.useSSL = false;
|
||||
parsedValues.sslMode = "disable";
|
||||
}
|
||||
} else if (type === "chroma") {
|
||||
} else if (type === "chroma" || type === "qdrant") {
|
||||
const tls = String(
|
||||
parsed.params.get("tls") ||
|
||||
parsed.params.get("ssl") ||
|
||||
@@ -1870,6 +1870,9 @@ const ConnectionModal: React.FC<{
|
||||
if (dbType === "chroma") {
|
||||
return "http://127.0.0.1:8000/default_database?tenant=default_tenant";
|
||||
}
|
||||
if (dbType === "qdrant") {
|
||||
return "http://127.0.0.1:6333";
|
||||
}
|
||||
if (dbType === "redis") {
|
||||
return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster";
|
||||
}
|
||||
@@ -1913,6 +1916,8 @@ const ConnectionModal: React.FC<{
|
||||
return "retryWrites=true&readPreference=secondaryPreferred";
|
||||
case "chroma":
|
||||
return "tenant=default_tenant&apiKey=...";
|
||||
case "qdrant":
|
||||
return "apiKey=...";
|
||||
case "dameng":
|
||||
return "schema=SYSDBA";
|
||||
case "tdengine":
|
||||
@@ -2054,7 +2059,7 @@ const ConnectionModal: React.FC<{
|
||||
const scheme =
|
||||
type === "postgres"
|
||||
? "postgresql"
|
||||
: type === "chroma"
|
||||
: type === "chroma" || type === "qdrant"
|
||||
? values.useSSL
|
||||
? "https"
|
||||
: "http"
|
||||
@@ -2108,7 +2113,7 @@ const ConnectionModal: React.FC<{
|
||||
if (mode === "skip-verify" || mode === "preferred") {
|
||||
params.set("skip_verify", "true");
|
||||
}
|
||||
} else if (type === "chroma") {
|
||||
} else if (type === "chroma" || type === "qdrant") {
|
||||
if (mode === "skip-verify" || mode === "preferred") {
|
||||
params.set("skip_verify", "true");
|
||||
}
|
||||
@@ -3705,7 +3710,7 @@ const ConnectionModal: React.FC<{
|
||||
});
|
||||
} else if (type !== "custom") {
|
||||
const defaultUser =
|
||||
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma") ? "" : "root";
|
||||
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma" || type === "qdrant") ? "" : "root";
|
||||
const sslCapableType = supportsSSLForType(type);
|
||||
setUseSSL(false);
|
||||
setUseHttpTunnel(false);
|
||||
@@ -5001,13 +5006,13 @@ const ConnectionModal: React.FC<{
|
||||
name="user"
|
||||
label="用户名"
|
||||
rules={
|
||||
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma")
|
||||
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant")
|
||||
? []
|
||||
: [createUriAwareRequiredRule("请输入用户名")]
|
||||
}
|
||||
style={{ marginBottom: 0 }}
|
||||
>
|
||||
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma") ? "未开启认证可留空" : undefined} />
|
||||
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma" || dbType === "qdrant") ? "未开启认证可留空" : undefined} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="password"
|
||||
|
||||
@@ -25,6 +25,13 @@ describe('DatabaseIcons', () => {
|
||||
expect(markup).toContain('>Ch</text>');
|
||||
});
|
||||
|
||||
it('includes Qdrant in the selectable database icons', () => {
|
||||
expect(DB_ICON_TYPES).toContain('qdrant');
|
||||
expect(getDbIconLabel('qdrant')).toBe('Qdrant');
|
||||
const markup = renderToStaticMarkup(<>{getDbIcon('qdrant', undefined, 22)}</>);
|
||||
expect(markup).toContain('>Qd</text>');
|
||||
});
|
||||
|
||||
it('wraps database icons in a consistent frame for sidebar sizing', () => {
|
||||
const mysqlMarkup = renderToStaticMarkup(<>{getDbIcon('mysql', undefined, 22)}</>);
|
||||
const jvmMarkup = renderToStaticMarkup(<>{getDbIcon('jvm', undefined, 22)}</>);
|
||||
|
||||
@@ -50,6 +50,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
|
||||
iris: '#1F6FEB',
|
||||
tdengine: '#2962FF',
|
||||
chroma: '#7C3AED',
|
||||
qdrant: '#DC244C',
|
||||
diros: '#0050B3',
|
||||
starrocks: '#00A6A6',
|
||||
sphinx: '#2F5D62',
|
||||
@@ -182,6 +183,9 @@ const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
const ChromaIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.chroma} label="Ch" />
|
||||
);
|
||||
const QdrantIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.qdrant} label="Qd" />
|
||||
);
|
||||
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
|
||||
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
|
||||
);
|
||||
@@ -236,6 +240,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
iris: IrisIcon,
|
||||
tdengine: TDengineIcon,
|
||||
chroma: ChromaIcon,
|
||||
qdrant: QdrantIcon,
|
||||
elasticsearch: ElasticsearchIcon,
|
||||
custom: CustomIcon,
|
||||
};
|
||||
@@ -244,7 +249,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
|
||||
export const DB_ICON_TYPES: string[] = [
|
||||
'mysql', 'mariadb', 'oceanbase', 'postgres', 'redis', 'mongodb', 'jvm',
|
||||
'oracle', 'sqlserver', 'sqlite', 'duckdb', 'clickhouse', 'starrocks',
|
||||
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'chroma', 'elasticsearch', 'custom',
|
||||
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'chroma', 'qdrant', 'elasticsearch', 'custom',
|
||||
];
|
||||
|
||||
/** 该类型是否有品牌 SVG 文件 */
|
||||
@@ -268,6 +273,7 @@ export const getDbIconLabel = (type: string): string => {
|
||||
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
|
||||
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine',
|
||||
chroma: 'Chroma',
|
||||
qdrant: 'Qdrant',
|
||||
elasticsearch: 'Elasticsearch',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ describe('connectionDriverType', () => {
|
||||
expect(normalizeDriverType('elastic')).toBe('elasticsearch');
|
||||
expect(normalizeDriverType('chromadb')).toBe('chroma');
|
||||
expect(normalizeDriverType('chroma-db')).toBe('chroma');
|
||||
expect(normalizeDriverType('qdrantdb')).toBe('qdrant');
|
||||
expect(normalizeDriverType('qdrant-db')).toBe('qdrant');
|
||||
expect(normalizeDriverType('doris')).toBe('diros');
|
||||
expect(normalizeDriverType('open-gauss')).toBe('opengauss');
|
||||
expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris');
|
||||
|
||||
@@ -16,6 +16,7 @@ export const normalizeDriverType = (value: string): string => {
|
||||
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
|
||||
if (normalized === 'elastic') return 'elasticsearch';
|
||||
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
|
||||
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
|
||||
if (normalized === 'doris') return 'diros';
|
||||
if (
|
||||
normalized === 'open_gauss' ||
|
||||
|
||||
@@ -88,6 +88,7 @@ describe('connectionModalPresentation', () => {
|
||||
'mongodb',
|
||||
'elasticsearch',
|
||||
'chroma',
|
||||
'qdrant',
|
||||
'redis',
|
||||
'tdengine',
|
||||
'custom',
|
||||
@@ -165,6 +166,14 @@ describe('connectionModalPresentation', () => {
|
||||
'credentials',
|
||||
'databaseScope',
|
||||
]);
|
||||
expect(resolveConnectionConfigLayout('qdrant').sections).toEqual([
|
||||
'identity',
|
||||
'uri',
|
||||
'target',
|
||||
'service',
|
||||
'credentials',
|
||||
'databaseScope',
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses localized labels for layout kinds shown in the modal', () => {
|
||||
|
||||
@@ -252,7 +252,7 @@ export const resolveConnectionConfigLayout = (
|
||||
],
|
||||
};
|
||||
}
|
||||
if (type === 'chroma') {
|
||||
if (type === 'chroma' || type === 'qdrant') {
|
||||
return {
|
||||
kind: 'vector',
|
||||
sections: [
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('connectionTypeCapabilities', () => {
|
||||
expect(singleHostUriSchemesByType.dameng).toEqual(['dameng', 'dm']);
|
||||
expect(singleHostUriSchemesByType.elasticsearch).toEqual(['http', 'https']);
|
||||
expect(singleHostUriSchemesByType.chroma).toEqual(['http', 'https', 'chroma']);
|
||||
expect(singleHostUriSchemesByType.qdrant).toEqual(['http', 'https', 'qdrant']);
|
||||
expect(singleHostUriSchemesByType.redis).toEqual(['redis']);
|
||||
});
|
||||
|
||||
@@ -26,6 +27,7 @@ describe('connectionTypeCapabilities', () => {
|
||||
expect(supportsSSLForType('MongoDB')).toBe(true);
|
||||
expect(supportsSSLForType('elasticsearch')).toBe(true);
|
||||
expect(supportsSSLForType('chroma')).toBe(true);
|
||||
expect(supportsSSLForType('qdrant')).toBe(true);
|
||||
expect(supportsSSLForType('tdengine')).toBe(true);
|
||||
expect(supportsSSLForType('dameng')).toBe(true);
|
||||
expect(supportsSSLForType('sqlite')).toBe(false);
|
||||
@@ -40,6 +42,8 @@ describe('connectionTypeCapabilities', () => {
|
||||
expect(supportsSSLClientCertificateForType('redis')).toBe(true);
|
||||
expect(supportsSSLCAPathForType('chroma')).toBe(true);
|
||||
expect(supportsSSLClientCertificateForType('chroma')).toBe(false);
|
||||
expect(supportsSSLCAPathForType('qdrant')).toBe(true);
|
||||
expect(supportsSSLClientCertificateForType('qdrant')).toBe(false);
|
||||
});
|
||||
|
||||
it('detects postgres-compatible SSL parameter dialects', () => {
|
||||
@@ -68,6 +72,7 @@ describe('connectionTypeCapabilities', () => {
|
||||
expect(supportsConnectionParamsForType('tdengine')).toBe(true);
|
||||
expect(supportsConnectionParamsForType('elasticsearch')).toBe(true);
|
||||
expect(supportsConnectionParamsForType('chroma')).toBe(true);
|
||||
expect(supportsConnectionParamsForType('qdrant')).toBe(true);
|
||||
expect(supportsConnectionParamsForType('redis')).toBe(false);
|
||||
expect(supportsConnectionParamsForType('sqlite')).toBe(false);
|
||||
expect(supportsConnectionParamsForType('jvm')).toBe(false);
|
||||
|
||||
@@ -13,6 +13,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
|
||||
vastbase: ["vastbase"],
|
||||
elasticsearch: ["http", "https"],
|
||||
chroma: ["http", "https", "chroma"],
|
||||
qdrant: ["http", "https", "qdrant"],
|
||||
};
|
||||
|
||||
const normalizeConnectionType = (type: string) =>
|
||||
@@ -42,6 +43,7 @@ const sslSupportedTypes = new Set([
|
||||
"tdengine",
|
||||
"elasticsearch",
|
||||
"chroma",
|
||||
"qdrant",
|
||||
]);
|
||||
|
||||
export const supportsSSLForType = (type: string) =>
|
||||
@@ -65,6 +67,7 @@ const sslCAPathSupportedTypes = new Set([
|
||||
"redis",
|
||||
"elasticsearch",
|
||||
"chroma",
|
||||
"qdrant",
|
||||
]);
|
||||
|
||||
const sslClientCertificateSupportedTypes = new Set([
|
||||
@@ -127,4 +130,5 @@ export const supportsConnectionParamsForType = (type: string) =>
|
||||
type === "dameng" ||
|
||||
type === "tdengine" ||
|
||||
type === "elasticsearch" ||
|
||||
type === "chroma";
|
||||
type === "chroma" ||
|
||||
type === "qdrant";
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('connectionTypeCatalog', () => {
|
||||
expect(keys).toContain('redis');
|
||||
expect(keys).toContain('elasticsearch');
|
||||
expect(keys).toContain('chroma');
|
||||
expect(keys).toContain('qdrant');
|
||||
expect(keys).toContain('jvm');
|
||||
expect(keys).toContain('custom');
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
@@ -40,6 +41,7 @@ describe('connectionTypeCatalog', () => {
|
||||
expect(getConnectionTypeDefaultPort('mongodb')).toBe(27017);
|
||||
expect(getConnectionTypeDefaultPort('elasticsearch')).toBe(9200);
|
||||
expect(getConnectionTypeDefaultPort('chroma')).toBe(8000);
|
||||
expect(getConnectionTypeDefaultPort('qdrant')).toBe(6333);
|
||||
expect(getConnectionTypeDefaultPort('sqlite')).toBe(0);
|
||||
expect(getConnectionTypeDefaultPort('duckdb')).toBe(0);
|
||||
expect(getConnectionTypeDefaultPort('unknown')).toBe(3306);
|
||||
@@ -50,6 +52,7 @@ describe('connectionTypeCatalog', () => {
|
||||
expect(getConnectionTypeHint('mongodb')).toBe('单机 / 副本集');
|
||||
expect(getConnectionTypeHint('elasticsearch')).toContain('Mapping');
|
||||
expect(getConnectionTypeHint('chroma')).toContain('向量');
|
||||
expect(getConnectionTypeHint('qdrant')).toContain('Payload');
|
||||
expect(getConnectionTypeHint('oceanbase')).toBe('MySQL / Oracle 租户');
|
||||
expect(getConnectionTypeHint('duckdb')).toBe('本地文件连接');
|
||||
expect(getConnectionTypeHint('mysql')).toBe('标准连接配置');
|
||||
|
||||
@@ -49,6 +49,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
|
||||
label: '向量数据库',
|
||||
items: [
|
||||
{ key: 'chroma', name: 'Chroma' },
|
||||
{ key: 'qdrant', name: 'Qdrant' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -105,6 +106,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
|
||||
return 9200;
|
||||
case 'chroma':
|
||||
return 8000;
|
||||
case 'qdrant':
|
||||
return 6333;
|
||||
case 'highgo':
|
||||
return 5866;
|
||||
case 'mariadb':
|
||||
@@ -133,6 +136,8 @@ export const getConnectionTypeHint = (type: string): string => {
|
||||
return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';
|
||||
case 'chroma':
|
||||
return 'Collection 浏览、向量检索和元数据过滤';
|
||||
case 'qdrant':
|
||||
return 'Collection 浏览、向量搜索和 Payload 过滤';
|
||||
case 'oceanbase':
|
||||
return 'MySQL / Oracle 租户';
|
||||
case 'sqlite':
|
||||
|
||||
@@ -90,6 +90,24 @@ describe('dataSourceCapabilities', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('treats Qdrant as a queryable vector datasource without SQL export actions', () => {
|
||||
expect(getDataSourceCapabilities({ type: 'qdrant' })).toMatchObject({
|
||||
type: 'qdrant',
|
||||
supportsQueryEditor: true,
|
||||
supportsSqlQueryExport: false,
|
||||
supportsCopyInsert: false,
|
||||
supportsCreateDatabase: false,
|
||||
supportsRenameDatabase: false,
|
||||
supportsDropDatabase: false,
|
||||
forceReadOnlyQueryResult: false,
|
||||
});
|
||||
expect(getDataSourceCapabilities({ type: 'custom', driver: 'qdrantdb' })).toMatchObject({
|
||||
type: 'qdrant',
|
||||
supportsQueryEditor: true,
|
||||
supportsCopyInsert: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
|
||||
expect(getDataSourceCapabilities({
|
||||
type: 'oceanbase',
|
||||
|
||||
@@ -24,6 +24,9 @@ const normalizeDataSourceToken = (raw: string): string => {
|
||||
case 'chromadb':
|
||||
case 'chroma-db':
|
||||
return 'chroma';
|
||||
case 'qdrantdb':
|
||||
case 'qdrant-db':
|
||||
return 'qdrant';
|
||||
case 'intersystems':
|
||||
case 'intersystemsiris':
|
||||
case 'inter-systems':
|
||||
|
||||
@@ -32,6 +32,8 @@ describe('sqlDialect', () => {
|
||||
expect(resolveSqlDialect('custom', 'elastic')).toBe('elasticsearch');
|
||||
expect(resolveSqlDialect('ChromaDB')).toBe('chroma');
|
||||
expect(resolveSqlDialect('custom', 'chroma-db')).toBe('chroma');
|
||||
expect(resolveSqlDialect('QdrantDB')).toBe('qdrant');
|
||||
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
|
||||
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
|
||||
expect(resolveSqlDialect('custom', 'oceanbase', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
|
||||
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
|
||||
|
||||
@@ -31,6 +31,7 @@ export type SqlDialect =
|
||||
| 'redis'
|
||||
| 'elasticsearch'
|
||||
| 'chroma'
|
||||
| 'qdrant'
|
||||
| 'unknown'
|
||||
| string;
|
||||
|
||||
@@ -120,6 +121,10 @@ export const resolveSqlDialect = (
|
||||
case 'chroma-db':
|
||||
case 'chroma':
|
||||
return 'chroma';
|
||||
case 'qdrantdb':
|
||||
case 'qdrant-db':
|
||||
case 'qdrant':
|
||||
return 'qdrant';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -146,6 +151,7 @@ export const resolveSqlDialect = (
|
||||
if (source.includes('iris') || source.includes('intersystems')) return 'iris';
|
||||
if (source.includes('elastic')) return 'elasticsearch';
|
||||
if (source.includes('chroma')) return 'chroma';
|
||||
if (source.includes('qdrant')) return 'qdrant';
|
||||
|
||||
return source;
|
||||
};
|
||||
|
||||
@@ -235,6 +235,8 @@ func defaultPortByType(driverType string) int {
|
||||
return 1972
|
||||
case "chroma":
|
||||
return 8000
|
||||
case "qdrant":
|
||||
return 6333
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -483,6 +483,9 @@ var databaseFactories = map[string]databaseFactory{
|
||||
"chroma": func() Database {
|
||||
return &ChromaDB{}
|
||||
},
|
||||
"qdrant": func() Database {
|
||||
return &QdrantDB{}
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
@@ -517,6 +520,8 @@ func normalizeDatabaseType(dbType string) string {
|
||||
return "iris"
|
||||
case "chromadb", "chroma-db":
|
||||
return "chroma"
|
||||
case "qdrantdb", "qdrant-db":
|
||||
return "qdrant"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ var coreBuiltinDrivers = map[string]struct{}{
|
||||
"oracle": {},
|
||||
"postgres": {},
|
||||
"chroma": {},
|
||||
"qdrant": {},
|
||||
}
|
||||
|
||||
// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。
|
||||
@@ -69,6 +70,8 @@ func normalizeRuntimeDriverType(driverType string) string {
|
||||
return "elasticsearch"
|
||||
case "chromadb", "chroma-db":
|
||||
return "chroma"
|
||||
case "qdrantdb", "qdrant-db":
|
||||
return "qdrant"
|
||||
default:
|
||||
return normalized
|
||||
}
|
||||
@@ -122,6 +125,8 @@ func driverDisplayName(driverType string) string {
|
||||
return "Elasticsearch"
|
||||
case "chroma":
|
||||
return "Chroma"
|
||||
case "qdrant":
|
||||
return "Qdrant"
|
||||
default:
|
||||
return strings.ToUpper(strings.TrimSpace(driverType))
|
||||
}
|
||||
|
||||
1049
internal/db/qdrant_impl.go
Normal file
1049
internal/db/qdrant_impl.go
Normal file
File diff suppressed because it is too large
Load Diff
274
internal/db/qdrant_impl_test.go
Normal file
274
internal/db/qdrant_impl_test.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"GoNavi-Wails/internal/connection"
|
||||
)
|
||||
|
||||
func newMockQdrantServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func newTestQdrantDB(t *testing.T, serverURL string) *QdrantDB {
|
||||
t.Helper()
|
||||
parsed, err := url.Parse(serverURL)
|
||||
if err != nil {
|
||||
t.Fatalf("parse server URL: %v", err)
|
||||
}
|
||||
host, port, ok := parseHostPortWithDefault(parsed.Host, defaultQdrantPort)
|
||||
if !ok {
|
||||
t.Fatalf("parse host port failed: %s", parsed.Host)
|
||||
}
|
||||
db := &QdrantDB{}
|
||||
if err := db.Connect(connection.ConnectionConfig{
|
||||
Type: "qdrant",
|
||||
Host: host,
|
||||
Port: port,
|
||||
UseSSL: strings.EqualFold(parsed.Scheme, "https"),
|
||||
}); err != nil {
|
||||
t.Fatalf("connect qdrant: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func writeQdrantJSON(w http.ResponseWriter, value interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func TestQdrantGetTables(t *testing.T) {
|
||||
server := newMockQdrantServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet && r.URL.Path == "/collections" {
|
||||
writeQdrantJSON(w, map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"collections": []map[string]interface{}{
|
||||
{"name": "products"},
|
||||
{"name": "logs"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
})
|
||||
|
||||
db := newTestQdrantDB(t, server.URL)
|
||||
tables, err := db.GetTables("")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTables failed: %v", err)
|
||||
}
|
||||
if strings.Join(tables, ",") != "logs,products" {
|
||||
t.Fatalf("tables = %v", tables)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQdrantCreateCollectionBuildsVectorsBody(t *testing.T) {
|
||||
var capturedBody map[string]interface{}
|
||||
server := newMockQdrantServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/collections":
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"collections": []interface{}{}}})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/collections/products":
|
||||
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": true})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestQdrantDB(t, server.URL)
|
||||
if _, err := db.Exec(`{"create_collection":"products","size":3,"distance":"Cosine","on_disk_payload":true}`); err != nil {
|
||||
t.Fatalf("create collection failed: %v", err)
|
||||
}
|
||||
vectors, _ := capturedBody["vectors"].(map[string]interface{})
|
||||
if intFromAny(vectors["size"], 0) != 3 || vectors["distance"] != "Cosine" || capturedBody["on_disk_payload"] != true {
|
||||
t.Fatalf("captured body = %#v", capturedBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQdrantSelectConvertsToScroll(t *testing.T) {
|
||||
var capturedBody map[string]interface{}
|
||||
server := newMockQdrantServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/collections":
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"collections": []interface{}{}}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/collections/products/points/scroll":
|
||||
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
|
||||
writeQdrantJSON(w, map[string]interface{}{
|
||||
"result": map[string]interface{}{
|
||||
"points": []map[string]interface{}{
|
||||
{
|
||||
"id": 1,
|
||||
"payload": map[string]interface{}{"category": "book", "price": 19.5},
|
||||
"vector": []float64{0.1, 0.2, 0.3},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestQdrantDB(t, server.URL)
|
||||
rows, columns, err := db.Query(`SELECT id, vector FROM "products" LIMIT 10 OFFSET 5`)
|
||||
if err != nil {
|
||||
t.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
if intFromAny(capturedBody["limit"], 0) != 10 || capturedBody["offset"] != float64(5) && capturedBody["offset"] != int64(5) {
|
||||
t.Fatalf("captured body = %#v", capturedBody)
|
||||
}
|
||||
if len(rows) != 1 || rows[0]["id"] == nil || rows[0]["payload.category"] != "book" {
|
||||
t.Fatalf("rows = %#v", rows)
|
||||
}
|
||||
if !containsString(columns, "payload.category") || !containsString(columns, "vector") {
|
||||
t.Fatalf("columns = %v", columns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQdrantJSONSearchFlattensResults(t *testing.T) {
|
||||
server := newMockQdrantServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/collections":
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"collections": []interface{}{}}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/collections/products/points/search":
|
||||
writeQdrantJSON(w, map[string]interface{}{
|
||||
"result": []map[string]interface{}{
|
||||
{
|
||||
"id": 1,
|
||||
"score": 0.98,
|
||||
"payload": map[string]interface{}{"category": "book"},
|
||||
"vector": []float64{0.1, 0.2, 0.3},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestQdrantDB(t, server.URL)
|
||||
rows, columns, err := db.Query(`{"search":"products","vector":[0.1,0.2,0.3],"limit":1}`)
|
||||
if err != nil {
|
||||
t.Fatalf("Query failed: %v", err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0]["score"] == nil || rows[0]["payload.category"] != "book" {
|
||||
t.Fatalf("rows = %#v", rows)
|
||||
}
|
||||
if !containsString(columns, "score") || !containsString(columns, "payload.category") {
|
||||
t.Fatalf("columns = %v", columns)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQdrantApplyChangesUpsertPayloadAndDelete(t *testing.T) {
|
||||
var upsertBody map[string]interface{}
|
||||
var payloadBody map[string]interface{}
|
||||
var deleteBody map[string]interface{}
|
||||
server := newMockQdrantServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/collections":
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"collections": []interface{}{}}})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/collections/products/points":
|
||||
_ = json.NewDecoder(r.Body).Decode(&upsertBody)
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"operation_id": 1}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/collections/products/points/payload":
|
||||
_ = json.NewDecoder(r.Body).Decode(&payloadBody)
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"operation_id": 2}})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/collections/products/points/delete":
|
||||
_ = json.NewDecoder(r.Body).Decode(&deleteBody)
|
||||
writeQdrantJSON(w, map[string]interface{}{"result": map[string]interface{}{"operation_id": 3}})
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
})
|
||||
|
||||
db := newTestQdrantDB(t, server.URL)
|
||||
err := db.ApplyChanges("products", connection.ChangeSet{
|
||||
Deletes: []map[string]interface{}{{"id": 9}},
|
||||
Updates: []connection.UpdateRow{{
|
||||
Keys: map[string]interface{}{"id": 1},
|
||||
Values: map[string]interface{}{"payload.category": "updated"},
|
||||
}},
|
||||
Inserts: []map[string]interface{}{
|
||||
{"id": 2, "vector": []float64{0.1, 0.2, 0.3}, "payload.kind": "new"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyChanges failed: %v", err)
|
||||
}
|
||||
if points := anySlice(deleteBody["points"]); len(points) != 1 || intFromAny(points[0], 0) != 9 {
|
||||
t.Fatalf("delete body = %#v", deleteBody)
|
||||
}
|
||||
if points := anySlice(payloadBody["points"]); len(points) != 1 || intFromAny(points[0], 0) != 1 {
|
||||
t.Fatalf("payload body = %#v", payloadBody)
|
||||
}
|
||||
payload, _ := payloadBody["payload"].(map[string]interface{})
|
||||
if payload["category"] != "updated" {
|
||||
t.Fatalf("payload body = %#v", payloadBody)
|
||||
}
|
||||
points := anySlice(upsertBody["points"])
|
||||
if len(points) != 1 {
|
||||
t.Fatalf("upsert body = %#v", upsertBody)
|
||||
}
|
||||
point, _ := points[0].(map[string]interface{})
|
||||
pointPayload, _ := point["payload"].(map[string]interface{})
|
||||
if intFromAny(point["id"], 0) != 2 || pointPayload["kind"] != "new" {
|
||||
t.Fatalf("upsert body = %#v", upsertBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQdrantLiveSmoke(t *testing.T) {
|
||||
serverURL := strings.TrimSpace(os.Getenv("GONAVI_QDRANT_TEST_URL"))
|
||||
if serverURL == "" {
|
||||
t.Skip("set GONAVI_QDRANT_TEST_URL to run live Qdrant smoke test")
|
||||
}
|
||||
|
||||
db := newTestQdrantDB(t, serverURL)
|
||||
collection := "gonavi_smoke_live"
|
||||
_, _ = db.Exec(fmt.Sprintf(`{"delete_collection":%q}`, collection))
|
||||
if _, err := db.Exec(fmt.Sprintf(`{"create_collection":%q,"size":3,"distance":"Cosine"}`, collection)); err != nil {
|
||||
t.Fatalf("create live collection: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = db.Exec(fmt.Sprintf(`{"delete_collection":%q}`, collection)) })
|
||||
|
||||
if err := db.ApplyChanges(collection, connection.ChangeSet{
|
||||
Inserts: []map[string]interface{}{{
|
||||
"id": 1,
|
||||
"vector": []float64{0.1, 0.2, 0.3},
|
||||
"payload.kind": "smoke",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("upsert live row: %v", err)
|
||||
}
|
||||
|
||||
rows, columns, err := db.Query(fmt.Sprintf(`SELECT id, vector FROM "%s" LIMIT 5`, collection))
|
||||
if err != nil {
|
||||
t.Fatalf("select live rows: %v", err)
|
||||
}
|
||||
if len(rows) == 0 || intFromAny(rows[0]["id"], 0) != 1 || rows[0]["payload.kind"] != "smoke" {
|
||||
t.Fatalf("live rows = %#v", rows)
|
||||
}
|
||||
if !containsString(columns, "payload.kind") {
|
||||
t.Fatalf("live columns missing payload.kind: %v", columns)
|
||||
}
|
||||
|
||||
queryRows, queryColumns, err := db.Query(fmt.Sprintf(`{"search":%q,"vector":[0.1,0.2,0.3],"limit":1}`, collection))
|
||||
if err != nil {
|
||||
t.Fatalf("search live rows: %v", err)
|
||||
}
|
||||
if len(queryRows) == 0 || intFromAny(queryRows[0]["id"], 0) != 1 || !containsString(queryColumns, "score") {
|
||||
t.Fatalf("live query rows = %#v columns = %v", queryRows, queryColumns)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user