feat(chroma): 新增 Chroma 向量库连接支持

- 后端新增 Chroma REST 连接、元数据浏览、JSON/SELECT 查询与 upsert/delete 写入

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

- 测试覆盖 v1/v2 兼容、真实服务 smoke 和前端配置

Refs #560
This commit is contained in:
Syngnat
2026-06-13 16:47:25 +08:00
parent d3836da9cb
commit 56126e22f2
21 changed files with 1660 additions and 24 deletions

View File

@@ -33,13 +33,25 @@ 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") ? "" : "root";',
'type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma") ? "" : "root";',
);
expect(source).toContain(
'placeholder={dbType === "elasticsearch" ? "未开启认证可留空" : undefined}',
'placeholder={(dbType === "elasticsearch" || dbType === "chroma") ? "未开启认证可留空" : undefined}',
);
expect(source).toContain('label="显示数据库 (留空显示全部)"');
});
it('exposes Chroma in the create-connection picker with vector defaults', () => {
expect(source).toContain("case 'chroma':");
expect(source).toContain('return 8000;');
expect(source).toContain('chroma: ["http", "https", "chroma"]');
expect(source).toContain("key: 'chroma'");
expect(source).toContain("name: 'Chroma'");
expect(source).toContain('type === "chroma"');
expect(source).toContain("return 'Collection 浏览、向量检索和元数据过滤';");
expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";');
expect(source).toContain('return "tenant=default_tenant&apiKey=...";');
});
});
describe('ConnectionModal Redis Sentinel configuration', () => {

View File

@@ -1789,6 +1789,22 @@ const ConnectionModal: React.FC<{
parsedValues.useSSL = false;
parsedValues.sslMode = "disable";
}
} else if (type === "chroma") {
const tls = String(
parsed.params.get("tls") ||
parsed.params.get("ssl") ||
parsed.params.get("useSSL") ||
parsed.params.get("use_ssl") ||
"",
)
.trim()
.toLowerCase();
const skipVerify = normalizeBool(
parsed.params.get("skip_verify") || parsed.params.get("skipVerify"),
);
const enabled = tls ? normalizeBool(tls) : trimmedUri.toLowerCase().startsWith("https://");
parsedValues.useSSL = enabled;
parsedValues.sslMode = enabled ? (skipVerify ? "skip-verify" : "required") : "disable";
}
}
return parsedValues;
@@ -1851,6 +1867,9 @@ const ConnectionModal: React.FC<{
if (dbType === "clickhouse") {
return "clickhouse://default:pass@127.0.0.1:9000/default";
}
if (dbType === "chroma") {
return "http://127.0.0.1:8000/default_database?tenant=default_tenant";
}
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";
}
@@ -1892,6 +1911,8 @@ const ConnectionModal: React.FC<{
return "max_execution_time=60&compress=lz4";
case "mongodb":
return "retryWrites=true&readPreference=secondaryPreferred";
case "chroma":
return "tenant=default_tenant&apiKey=...";
case "dameng":
return "schema=SYSDBA";
case "tdengine":
@@ -2033,6 +2054,10 @@ const ConnectionModal: React.FC<{
const scheme =
type === "postgres"
? "postgresql"
: type === "chroma"
? values.useSSL
? "https"
: "http"
: type === "clickhouse" && clickHouseProtocol === "http"
? values.useSSL
? "https"
@@ -2083,6 +2108,11 @@ const ConnectionModal: React.FC<{
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
} else if (type === "chroma") {
if (mode === "skip-verify" || mode === "preferred") {
params.set("skip_verify", "true");
}
appendSSLPathParamsForUri(params, type, values);
}
} else if (supportsSSLForType(type)) {
if (isPostgresCompatibleSSLType(type)) {
@@ -3675,7 +3705,7 @@ const ConnectionModal: React.FC<{
});
} else if (type !== "custom") {
const defaultUser =
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch") ? "" : "root";
type === "clickhouse" ? "default" : (type === "redis" || type === "elasticsearch" || type === "chroma") ? "" : "root";
const sslCapableType = supportsSSLForType(type);
setUseSSL(false);
setUseHttpTunnel(false);
@@ -4971,13 +5001,13 @@ const ConnectionModal: React.FC<{
name="user"
label="用户名"
rules={
(dbType === "mongodb" || dbType === "elasticsearch")
(dbType === "mongodb" || dbType === "elasticsearch" || dbType === "chroma")
? []
: [createUriAwareRequiredRule("请输入用户名")]
}
style={{ marginBottom: 0 }}
>
<Input {...noAutoCapInputProps} placeholder={dbType === "elasticsearch" ? "未开启认证可留空" : undefined} />
<Input {...noAutoCapInputProps} placeholder={(dbType === "elasticsearch" || dbType === "chroma") ? "未开启认证可留空" : undefined} />
</Form.Item>
<Form.Item
name="password"

View File

@@ -18,6 +18,13 @@ describe('DatabaseIcons', () => {
expect(markup).toContain('alt="elasticsearch"');
});
it('includes Chroma in the selectable database icons', () => {
expect(DB_ICON_TYPES).toContain('chroma');
expect(getDbIconLabel('chroma')).toBe('Chroma');
const markup = renderToStaticMarkup(<>{getDbIcon('chroma', undefined, 22)}</>);
expect(markup).toContain('>Ch</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

@@ -49,6 +49,7 @@ const DB_DEFAULT_COLORS: Record<string, string> = {
highgo: '#00A86B',
iris: '#1F6FEB',
tdengine: '#2962FF',
chroma: '#7C3AED',
diros: '#0050B3',
starrocks: '#00A6A6',
sphinx: '#2F5D62',
@@ -178,6 +179,9 @@ const IrisIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
const TDengineIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.tdengine} label="TD" />
);
const ChromaIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.chroma} label="Ch" />
);
const JVMIcon: React.FC<DbIconProps> = ({ size = 16, color }) => (
<ColorBadge size={size} color={color || DB_DEFAULT_COLORS.jvm} label="JVM" />
);
@@ -231,6 +235,7 @@ const DB_ICON_MAP: Record<string, React.FC<DbIconProps>> = {
highgo: HighGoIcon,
iris: IrisIcon,
tdengine: TDengineIcon,
chroma: ChromaIcon,
elasticsearch: ElasticsearchIcon,
custom: CustomIcon,
};
@@ -239,7 +244,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', 'elasticsearch', 'custom',
'kingbase', 'dameng', 'vastbase', 'opengauss', 'highgo', 'iris', 'tdengine', 'chroma', 'elasticsearch', 'custom',
];
/** 该类型是否有品牌 SVG 文件 */
@@ -262,6 +267,7 @@ export const getDbIconLabel = (type: string): string => {
starrocks: 'StarRocks',
duckdb: 'DuckDB', kingbase: '金仓', dameng: '达梦',
vastbase: 'VastBase', opengauss: 'OpenGauss', highgo: '瀚高', iris: 'InterSystems IRIS', tdengine: 'TDengine',
chroma: 'Chroma',
elasticsearch: 'Elasticsearch',
custom: '自定义',
};

View File

@@ -12,6 +12,8 @@ describe('connectionDriverType', () => {
expect(normalizeDriverType('postgresql')).toBe('postgres');
expect(normalizeDriverType('pgx')).toBe('postgres');
expect(normalizeDriverType('elastic')).toBe('elasticsearch');
expect(normalizeDriverType('chromadb')).toBe('chroma');
expect(normalizeDriverType('chroma-db')).toBe('chroma');
expect(normalizeDriverType('doris')).toBe('diros');
expect(normalizeDriverType('open-gauss')).toBe('opengauss');
expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris');

View File

@@ -15,6 +15,7 @@ export const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
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 === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||

View File

@@ -87,6 +87,7 @@ describe('connectionModalPresentation', () => {
'iris',
'mongodb',
'elasticsearch',
'chroma',
'redis',
'tdengine',
'custom',
@@ -156,11 +157,20 @@ describe('connectionModalPresentation', () => {
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('chroma').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
});
it('uses localized labels for layout kinds shown in the modal', () => {
expect(getConnectionConfigLayoutKindLabel('mysql-compatible')).toBe('MySQL 兼容');
expect(getConnectionConfigLayoutKindLabel('file')).toBe('文件型数据库');
expect(getConnectionConfigLayoutKindLabel('search')).toBe('搜索引擎');
expect(getConnectionConfigLayoutKindLabel('vector')).toBe('向量数据库');
});
});

View File

@@ -40,6 +40,7 @@ export type ConnectionConfigLayoutKind =
| 'oracle'
| 'file'
| 'search'
| 'vector'
| 'custom'
| 'jvm'
| 'generic-sql';
@@ -160,6 +161,8 @@ export const getConnectionConfigLayoutKindLabel = (
return '文件型数据库';
case 'search':
return '搜索引擎';
case 'vector':
return '向量数据库';
case 'custom':
return '自定义连接';
case 'jvm':
@@ -249,6 +252,19 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'chroma') {
return {
kind: 'vector',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (postgresCompatibleTypes.has(type)) {
return {
kind: 'postgres-compatible',

View File

@@ -17,6 +17,7 @@ describe('connectionTypeCapabilities', () => {
expect(singleHostUriSchemesByType.opengauss).toContain('jdbc:opengauss');
expect(singleHostUriSchemesByType.dameng).toEqual(['dameng', 'dm']);
expect(singleHostUriSchemesByType.elasticsearch).toEqual(['http', 'https']);
expect(singleHostUriSchemesByType.chroma).toEqual(['http', 'https', 'chroma']);
expect(singleHostUriSchemesByType.redis).toEqual(['redis']);
});
@@ -24,6 +25,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsSSLForType('redis')).toBe(true);
expect(supportsSSLForType('MongoDB')).toBe(true);
expect(supportsSSLForType('elasticsearch')).toBe(true);
expect(supportsSSLForType('chroma')).toBe(true);
expect(supportsSSLForType('tdengine')).toBe(true);
expect(supportsSSLForType('dameng')).toBe(true);
expect(supportsSSLForType('sqlite')).toBe(false);
@@ -36,6 +38,8 @@ describe('connectionTypeCapabilities', () => {
expect(supportsSSLClientCertificateForType('sqlserver')).toBe(false);
expect(supportsSSLCAPathForType('redis')).toBe(true);
expect(supportsSSLClientCertificateForType('redis')).toBe(true);
expect(supportsSSLCAPathForType('chroma')).toBe(true);
expect(supportsSSLClientCertificateForType('chroma')).toBe(false);
});
it('detects postgres-compatible SSL parameter dialects', () => {
@@ -63,6 +67,7 @@ describe('connectionTypeCapabilities', () => {
expect(supportsConnectionParamsForType('dameng')).toBe(true);
expect(supportsConnectionParamsForType('tdengine')).toBe(true);
expect(supportsConnectionParamsForType('elasticsearch')).toBe(true);
expect(supportsConnectionParamsForType('chroma')).toBe(true);
expect(supportsConnectionParamsForType('redis')).toBe(false);
expect(supportsConnectionParamsForType('sqlite')).toBe(false);
expect(supportsConnectionParamsForType('jvm')).toBe(false);

View File

@@ -12,6 +12,7 @@ export const singleHostUriSchemesByType: Record<string, string[]> = {
highgo: ["highgo"],
vastbase: ["vastbase"],
elasticsearch: ["http", "https"],
chroma: ["http", "https", "chroma"],
};
const normalizeConnectionType = (type: string) =>
@@ -40,6 +41,7 @@ const sslSupportedTypes = new Set([
"redis",
"tdengine",
"elasticsearch",
"chroma",
]);
export const supportsSSLForType = (type: string) =>
@@ -62,6 +64,7 @@ const sslCAPathSupportedTypes = new Set([
"mongodb",
"redis",
"elasticsearch",
"chroma",
]);
const sslClientCertificateSupportedTypes = new Set([
@@ -123,4 +126,5 @@ export const supportsConnectionParamsForType = (type: string) =>
type === "mongodb" ||
type === "dameng" ||
type === "tdengine" ||
type === "elasticsearch";
type === "elasticsearch" ||
type === "chroma";

View File

@@ -13,6 +13,7 @@ describe('connectionTypeCatalog', () => {
'关系型数据库',
'国产数据库',
'NoSQL',
'向量数据库',
'时序数据库',
'其他',
]);
@@ -23,6 +24,7 @@ describe('connectionTypeCatalog', () => {
expect(keys).toContain('mongodb');
expect(keys).toContain('redis');
expect(keys).toContain('elasticsearch');
expect(keys).toContain('chroma');
expect(keys).toContain('jvm');
expect(keys).toContain('custom');
expect(new Set(keys).size).toBe(keys.length);
@@ -37,6 +39,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeDefaultPort('oracle')).toBe(1521);
expect(getConnectionTypeDefaultPort('mongodb')).toBe(27017);
expect(getConnectionTypeDefaultPort('elasticsearch')).toBe(9200);
expect(getConnectionTypeDefaultPort('chroma')).toBe(8000);
expect(getConnectionTypeDefaultPort('sqlite')).toBe(0);
expect(getConnectionTypeDefaultPort('duckdb')).toBe(0);
expect(getConnectionTypeDefaultPort('unknown')).toBe(3306);
@@ -46,6 +49,7 @@ describe('connectionTypeCatalog', () => {
expect(getConnectionTypeHint('redis')).toBe('单机 / 哨兵 / 集群');
expect(getConnectionTypeHint('mongodb')).toBe('单机 / 副本集');
expect(getConnectionTypeHint('elasticsearch')).toContain('Mapping');
expect(getConnectionTypeHint('chroma')).toContain('向量');
expect(getConnectionTypeHint('oceanbase')).toBe('MySQL / Oracle 租户');
expect(getConnectionTypeHint('duckdb')).toBe('本地文件连接');
expect(getConnectionTypeHint('mysql')).toBe('标准连接配置');

View File

@@ -45,6 +45,12 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
{ key: 'elasticsearch', name: 'Elasticsearch' },
],
},
{
label: '向量数据库',
items: [
{ key: 'chroma', name: 'Chroma' },
],
},
{
label: '时序数据库',
items: [
@@ -97,6 +103,8 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
return 27017;
case 'elasticsearch':
return 9200;
case 'chroma':
return 8000;
case 'highgo':
return 5866;
case 'mariadb':
@@ -123,6 +131,8 @@ export const getConnectionTypeHint = (type: string): string => {
return '单机 / 副本集';
case 'elasticsearch':
return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';
case 'chroma':
return 'Collection 浏览、向量检索和元数据过滤';
case 'oceanbase':
return 'MySQL / Oracle 租户';
case 'sqlite':

View File

@@ -72,6 +72,24 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats Chroma as a queryable vector datasource without SQL export actions', () => {
expect(getDataSourceCapabilities({ type: 'chroma' })).toMatchObject({
type: 'chroma',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'chromadb' })).toMatchObject({
type: 'chroma',
supportsQueryEditor: true,
supportsCopyInsert: false,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',

View File

@@ -21,6 +21,9 @@ const normalizeDataSourceToken = (raw: string): string => {
case 'elastic':
case 'elasticsearch':
return 'elasticsearch';
case 'chromadb':
case 'chroma-db':
return 'chroma';
case 'intersystems':
case 'intersystemsiris':
case 'inter-systems':

View File

@@ -30,6 +30,8 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'open_gauss')).toBe('opengauss');
expect(resolveSqlDialect('Elasticsearch')).toBe('elasticsearch');
expect(resolveSqlDialect('custom', 'elastic')).toBe('elasticsearch');
expect(resolveSqlDialect('ChromaDB')).toBe('chroma');
expect(resolveSqlDialect('custom', 'chroma-db')).toBe('chroma');
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(resolveSqlDialect('custom', 'oceanbase', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);

View File

@@ -30,6 +30,7 @@ export type SqlDialect =
| 'mongodb'
| 'redis'
| 'elasticsearch'
| 'chroma'
| 'unknown'
| string;
@@ -115,6 +116,10 @@ export const resolveSqlDialect = (
return source;
case 'elastic':
return 'elasticsearch';
case 'chromadb':
case 'chroma-db':
case 'chroma':
return 'chroma';
default:
break;
}
@@ -140,6 +145,7 @@ export const resolveSqlDialect = (
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
if (source.includes('iris') || source.includes('intersystems')) return 'iris';
if (source.includes('elastic')) return 'elasticsearch';
if (source.includes('chroma')) return 'chroma';
return source;
};

View File

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

1196
internal/db/chroma_impl.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,292 @@
package db
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
func newMockChromaServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
t.Helper()
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return server
}
func newTestChromaDB(t *testing.T, serverURL string) *ChromaDB {
t.Helper()
parsed, err := url.Parse(serverURL)
if err != nil {
t.Fatalf("parse server URL: %v", err)
}
host, port, ok := parseHostPortWithDefault(parsed.Host, defaultChromaPort)
if !ok {
t.Fatalf("parse host port failed: %s", parsed.Host)
}
db := &ChromaDB{}
if err := db.Connect(connection.ConnectionConfig{
Type: "chroma",
Host: host,
Port: port,
}); err != nil {
t.Fatalf("connect chroma: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
return db
}
func writeChromaJSON(w http.ResponseWriter, value interface{}) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(value)
}
func TestChromaConnectDetectsV2(t *testing.T) {
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && r.URL.Path == "/api/v2/heartbeat" {
writeChromaJSON(w, map[string]interface{}{"nanosecond heartbeat": 1})
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestChromaDB(t, server.URL)
if db.apiVersion != 2 {
t.Fatalf("apiVersion = %d, want 2", db.apiVersion)
}
}
func TestChromaConnectFallsBackToV1(t *testing.T) {
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/v2/heartbeat" {
w.WriteHeader(http.StatusNotFound)
return
}
if r.Method == http.MethodGet && r.URL.Path == "/api/v1/heartbeat" {
writeChromaJSON(w, map[string]interface{}{"nanosecond heartbeat": 1})
return
}
w.WriteHeader(http.StatusNotFound)
})
db := newTestChromaDB(t, server.URL)
if db.apiVersion != 1 {
t.Fatalf("apiVersion = %d, want 1", db.apiVersion)
}
}
func TestChromaGetDatabasesAndTablesV2(t *testing.T) {
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v2/heartbeat":
writeChromaJSON(w, map[string]interface{}{"ok": true})
case "/api/v2/tenants/default_tenant/databases":
writeChromaJSON(w, []map[string]interface{}{
{"name": "analytics"},
{"name": "default_database"},
})
case "/api/v2/tenants/default_tenant/databases/default_database/collections":
writeChromaJSON(w, []chromaCollection{
{ID: "col-products", Name: "products", Database: "default_database", Tenant: "default_tenant"},
{ID: "col-logs", Name: "logs", Database: "default_database", Tenant: "default_tenant"},
})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestChromaDB(t, server.URL)
dbs, err := db.GetDatabases()
if err != nil {
t.Fatalf("GetDatabases failed: %v", err)
}
if strings.Join(dbs, ",") != "analytics,default_database" {
t.Fatalf("databases = %v", dbs)
}
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 TestChromaSelectConvertsToGetRows(t *testing.T) {
var capturedPath string
var capturedBody map[string]interface{}
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v2/heartbeat":
writeChromaJSON(w, map[string]interface{}{"ok": true})
case r.URL.Path == "/api/v2/tenants/default_tenant/databases/default_database/collections":
writeChromaJSON(w, []chromaCollection{{ID: "col-products", Name: "products", Database: "default_database"}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/collections/col-products/get"):
capturedPath = r.URL.Path
_ = json.NewDecoder(r.Body).Decode(&capturedBody)
writeChromaJSON(w, chromaGetResponse{
IDs: []string{"p1"},
Documents: []interface{}{"first product"},
Metadatas: []map[string]interface{}{{"category": "book", "price": json.Number("19.5")}},
})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestChromaDB(t, server.URL)
rows, columns, err := db.Query(`SELECT * FROM "products" LIMIT 10 OFFSET 5`)
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if capturedPath == "" {
t.Fatal("expected get endpoint to be called")
}
if intFromAny(capturedBody["limit"], 0) != 10 || intFromAny(capturedBody["offset"], -1) != 5 {
t.Fatalf("captured body = %#v", capturedBody)
}
if len(rows) != 1 || rows[0]["id"] != "p1" || rows[0]["metadata.category"] != "book" {
t.Fatalf("rows = %#v", rows)
}
if !containsString(columns, "metadata.category") {
t.Fatalf("columns missing metadata.category: %v", columns)
}
}
func TestChromaJSONQueryFlattensResults(t *testing.T) {
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v2/heartbeat":
writeChromaJSON(w, map[string]interface{}{"ok": true})
case r.URL.Path == "/api/v2/tenants/default_tenant/databases/default_database/collections":
writeChromaJSON(w, []chromaCollection{{ID: "col-products", Name: "products", Database: "default_database"}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/collections/col-products/query"):
writeChromaJSON(w, map[string]interface{}{
"ids": [][]string{{"p1", "p2"}},
"documents": [][]string{{"first", "second"}},
"distances": [][]float64{{0.1, 0.2}},
"metadatas": [][]map[string]interface{}{{{"category": "book"}, {"category": "tool"}}},
})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestChromaDB(t, server.URL)
rows, columns, err := db.Query(`{"query":"products","query_embeddings":[[0.1,0.2]],"n_results":2}`)
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(rows) != 2 || rows[1]["id"] != "p2" || rows[1]["distance"] == nil {
t.Fatalf("rows = %#v", rows)
}
if !containsString(columns, "distance") || !containsString(columns, "metadata.category") {
t.Fatalf("columns = %v", columns)
}
}
func TestChromaApplyChangesUpsertAndDelete(t *testing.T) {
var upsertBody map[string]interface{}
var deleteBody map[string]interface{}
server := newMockChromaServer(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v2/heartbeat":
writeChromaJSON(w, map[string]interface{}{"ok": true})
case r.URL.Path == "/api/v2/tenants/default_tenant/databases/default_database/collections":
writeChromaJSON(w, []chromaCollection{{ID: "col-products", Name: "products", Database: "default_database"}})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/collections/col-products/upsert"):
_ = json.NewDecoder(r.Body).Decode(&upsertBody)
writeChromaJSON(w, map[string]interface{}{"ok": true})
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/collections/col-products/delete"):
_ = json.NewDecoder(r.Body).Decode(&deleteBody)
writeChromaJSON(w, map[string]interface{}{"ok": true})
default:
w.WriteHeader(http.StatusNotFound)
}
})
db := newTestChromaDB(t, server.URL)
err := db.ApplyChanges("products", connection.ChangeSet{
Deletes: []map[string]interface{}{{"id": "old"}},
Inserts: []map[string]interface{}{
{"id": "new", "document": "hello", "metadata.kind": "demo", "score": 9},
},
})
if err != nil {
t.Fatalf("ApplyChanges failed: %v", err)
}
if ids := anySlice(deleteBody["ids"]); len(ids) != 1 || ids[0] != "old" {
t.Fatalf("delete body = %#v", deleteBody)
}
if ids := anySlice(upsertBody["ids"]); len(ids) != 1 || ids[0] != "new" {
t.Fatalf("upsert body = %#v", upsertBody)
}
metas := anySlice(upsertBody["metadatas"])
if len(metas) != 1 {
t.Fatalf("metadatas = %#v", upsertBody["metadatas"])
}
meta, _ := metas[0].(map[string]interface{})
if meta["kind"] != "demo" || meta["score"] == nil {
t.Fatalf("metadata = %#v", meta)
}
}
func TestChromaLiveSmoke(t *testing.T) {
serverURL := strings.TrimSpace(os.Getenv("GONAVI_CHROMA_TEST_URL"))
if serverURL == "" {
t.Skip("set GONAVI_CHROMA_TEST_URL to run live Chroma smoke test")
}
db := newTestChromaDB(t, serverURL)
collection := "gonavi_smoke_live"
_, _ = db.Exec(fmt.Sprintf(`{"delete_collection":%q}`, collection))
if _, err := db.Exec(fmt.Sprintf(`{"create_collection":%q,"get_or_create":true}`, 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": "doc-1",
"document": "GoNavi Chroma live smoke",
"metadata.kind": "smoke",
"embedding": []float64{0.1, 0.2, 0.3},
}},
}); err != nil {
t.Fatalf("upsert live row: %v", err)
}
rows, columns, err := db.Query(fmt.Sprintf(`SELECT * FROM "%s" LIMIT 5`, collection))
if err != nil {
t.Fatalf("select live rows: %v", err)
}
if len(rows) == 0 || rows[0]["id"] != "doc-1" || rows[0]["metadata.kind"] != "smoke" {
t.Fatalf("live rows = %#v", rows)
}
if !containsString(columns, "metadata.kind") {
t.Fatalf("live columns missing metadata.kind: %v", columns)
}
queryRows, queryColumns, err := db.Query(fmt.Sprintf(`{"query":%q,"query_embeddings":[[0.1,0.2,0.3]],"n_results":1}`, collection))
if err != nil {
t.Fatalf("query live rows: %v", err)
}
if len(queryRows) == 0 || queryRows[0]["id"] != "doc-1" || !containsString(queryColumns, "distance") {
t.Fatalf("live query rows = %#v columns = %v", queryRows, queryColumns)
}
}
func containsString(items []string, target string) bool {
for _, item := range items {
if item == target {
return true
}
}
return false
}

View File

@@ -480,6 +480,9 @@ var databaseFactories = map[string]databaseFactory{
"custom": func() Database {
return &CustomDB{}
},
"chroma": func() Database {
return &ChromaDB{}
},
}
func init() {
@@ -512,6 +515,8 @@ func normalizeDatabaseType(dbType string) string {
return "opengauss"
case "intersystems", "intersystemsiris", "inter-systems-iris", "inter-systems":
return "iris"
case "chromadb", "chroma-db":
return "chroma"
default:
return normalized
}

View File

@@ -16,28 +16,29 @@ var coreBuiltinDrivers = map[string]struct{}{
"redis": {},
"oracle": {},
"postgres": {},
"chroma": {},
}
// optionalGoDrivers 表示需要用户“安装启用”后才能使用的纯 Go 驱动。
// 注意这是一种运行时门控installed.json 标记),并不减少主二进制体积。
var optionalGoDrivers = map[string]struct{}{
"mariadb": {},
"oceanbase": {},
"diros": {},
"starrocks": {},
"sphinx": {},
"sqlserver": {},
"sqlite": {},
"duckdb": {},
"dameng": {},
"kingbase": {},
"highgo": {},
"vastbase": {},
"opengauss": {},
"iris": {},
"mongodb": {},
"tdengine": {},
"clickhouse": {},
"mariadb": {},
"oceanbase": {},
"diros": {},
"starrocks": {},
"sphinx": {},
"sqlserver": {},
"sqlite": {},
"duckdb": {},
"dameng": {},
"kingbase": {},
"highgo": {},
"vastbase": {},
"opengauss": {},
"iris": {},
"mongodb": {},
"tdengine": {},
"clickhouse": {},
"elasticsearch": {},
}
@@ -66,6 +67,8 @@ func normalizeRuntimeDriverType(driverType string) string {
return "iris"
case "elastic":
return "elasticsearch"
case "chromadb", "chroma-db":
return "chroma"
default:
return normalized
}
@@ -117,6 +120,8 @@ func driverDisplayName(driverType string) string {
return "ClickHouse"
case "elasticsearch":
return "Elasticsearch"
case "chroma":
return "Chroma"
default:
return strings.ToUpper(strings.TrimSpace(driverType))
}