feat(qdrant): 新增 Qdrant 向量库连接支持

- 后端新增 Qdrant REST 连接、collection 元数据、scroll/search 查询与 upsert/delete/payload 更新

- 前端新增 Qdrant 类型、连接配置、图标、方言和能力矩阵

- 测试覆盖 mock REST、真实服务 smoke 和前端配置

Refs #555
This commit is contained in:
Syngnat
2026-06-13 17:03:20 +08:00
parent 56126e22f2
commit c805b16fcd
21 changed files with 1434 additions and 11 deletions

View File

@@ -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', () => {

View File

@@ -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"

View File

@@ -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)}</>);

View File

@@ -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: '自定义',
};

View File

@@ -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');

View File

@@ -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' ||

View File

@@ -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', () => {

View File

@@ -252,7 +252,7 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'chroma') {
if (type === 'chroma' || type === 'qdrant') {
return {
kind: 'vector',
sections: [

View File

@@ -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);

View File

@@ -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";

View File

@@ -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('标准连接配置');

View File

@@ -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':

View File

@@ -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',

View File

@@ -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':

View File

@@ -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);

View File

@@ -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;
};

View File

@@ -235,6 +235,8 @@ func defaultPortByType(driverType string) int {
return 1972
case "chroma":
return 8000
case "qdrant":
return 6333
default:
return 0
}

View File

@@ -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
}

View File

@@ -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

File diff suppressed because it is too large Load Diff

View 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)
}
}