@an5/adapters 0.2.6 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +96 -2
  2. package/dist/an5Adapter.d.ts +57 -0
  3. package/dist/an5Adapter.js +263 -4
  4. package/dist/base/sql.js +16 -15
  5. package/dist/base/types.d.ts +6 -0
  6. package/dist/googlesheets/helpers.js +13 -8
  7. package/dist/index.d.ts +2 -0
  8. package/dist/index.js +9 -1
  9. package/dist/nbase/client.d.ts +92 -0
  10. package/dist/nbase/client.js +147 -0
  11. package/dist/nbase/index.d.ts +47 -0
  12. package/dist/nbase/index.js +80 -0
  13. package/dist/sqlite/engine.js +2 -1
  14. package/dotnet/An5Adapter.cs +41 -18
  15. package/dotnet/Base/Core.cs +56 -26
  16. package/dotnet/Postgres/PostgresEngine.cs +58 -30
  17. package/dotnet/Sqlite/SqliteEngine.cs +214 -0
  18. package/golang/base/where.go +32 -49
  19. package/golang/base/where_test.go +57 -0
  20. package/package.json +26 -11
  21. package/python/an5_adapter.py +74 -9
  22. package/python/base/__init__.py +1 -1
  23. package/python/base/metadata.py +25 -1
  24. package/python/base/sql.py +50 -21
  25. package/python/sqlite/__init__.py +3 -0
  26. package/python/sqlite/provider.py +83 -0
  27. package/python/table_client.py +53 -36
  28. package/rust/Cargo.toml +31 -0
  29. package/rust/README.md +71 -0
  30. package/rust/src/adapter.rs +1314 -0
  31. package/rust/src/base/dialect.rs +201 -0
  32. package/rust/src/base/metadata.rs +138 -0
  33. package/rust/src/base/mod.rs +17 -0
  34. package/rust/src/base/where_builder.rs +386 -0
  35. package/rust/src/lib.rs +75 -0
  36. package/typescript/src/an5Adapter.ts +300 -4
  37. package/typescript/src/base/sql.ts +15 -14
  38. package/typescript/src/base/types.ts +6 -0
  39. package/typescript/src/googlesheets/helpers.ts +11 -8
  40. package/typescript/src/index.ts +17 -0
  41. package/typescript/src/nbase/client.ts +214 -0
  42. package/typescript/src/nbase/index.ts +104 -0
  43. package/typescript/src/sqlite/engine.ts +2 -1
@@ -7,6 +7,25 @@ import {
7
7
  } from './googlesheets';
8
8
  import { parseSheetsConnectionString } from './googlesheets/parseConnectionString';
9
9
  import type { An5AdapterConfig, Dialect, QueryEngine, TransactionHandle } from './base/types';
10
+
11
+ /**
12
+ * File extensions that mean SQLite when a connection string has no scheme.
13
+ *
14
+ * `.sqlite3` is here as well as in `@an5/orm`'s `detectProvider`, because the ORM
15
+ * validates field types and writes the DDL from the provider this file picks: if the
16
+ * two lists differed, a `.sqlite3` file would be pushed as SQL Server DDL.
17
+ */
18
+ const SQLITE_FILE_SUFFIXES = ['.sqlite', '.sqlite3', '.db'];
19
+ import {
20
+ NBaseError,
21
+ buildVectorId,
22
+ parseVectorId,
23
+ isNBaseConnectionString,
24
+ parseNBaseConnectionString,
25
+ createNBaseVectorClient,
26
+ type NBaseAdapterConfig,
27
+ type NBaseVectorClient,
28
+ } from './nbase';
10
29
  import { buildOrderBy, parseWhere, quote } from './base/sql';
11
30
  import {
12
31
  createAdapterProxy,
@@ -204,6 +223,34 @@ export class An5Adapter {
204
223
  private _engineType: 'postgres' | 'mysql' | 'sqlite' | 'mssql' | null = null;
205
224
  private _engineConfig: An5AdapterConfig | null = null;
206
225
  sheetsAdapter: An5SheetsAdapter | null = null;
226
+ private _nbase: NBaseVectorClient | null = null;
227
+ private _nbaseConfig: NBaseAdapterConfig | null = null;
228
+ private _nbaseOnly = false;
229
+
230
+ /**
231
+ * Returns the NBase client for this adapter, if the project configured one.
232
+ * The connection string may also point straight at NBase, which keeps
233
+ * `DATABASE_URL=nbase://host:port` working on its own.
234
+ */
235
+ nbaseClient(): { client: NBaseVectorClient; config: NBaseAdapterConfig } | null {
236
+ if (this._nbase && this._nbaseConfig) return { client: this._nbase, config: this._nbaseConfig };
237
+
238
+ if (!this._nbaseConfig) return null;
239
+ const config = this._nbaseConfig;
240
+
241
+ const client: NBaseVectorClient = createNBaseVectorClient(config);
242
+ this._nbase = client;
243
+ this._nbaseConfig = config;
244
+ return { client, config };
245
+ }
246
+
247
+ /**
248
+ * True when the adapter only knows about NBase, so there is no relational
249
+ * database to read rows from.
250
+ */
251
+ get nbaseOnly(): boolean {
252
+ return this._nbaseOnly === true;
253
+ }
207
254
 
208
255
  get dialect(): Dialect {
209
256
  if (this.sheetsAdapter) return 'googlesheets';
@@ -220,6 +267,25 @@ export class An5Adapter {
220
267
  return createAdapterProxy(this, (name) => this.table(name));
221
268
  }
222
269
 
270
+ // Keep the config even when the caller supplies its own engine: the NBase
271
+ // settings and anything else on the config are read from it later.
272
+ this._engineConfig = adapterConfig as An5AdapterConfig;
273
+
274
+ // `nbase://host:port` as the connection string configures the vector store
275
+ // on its own: there is no relational database, so the adapter is
276
+ // vector-only. The same string passed as `nbase` keeps the database from
277
+ // `connectionString` as the source of rows.
278
+ const rawConnectionString = String(this._engineConfig.connectionString ?? '').trim();
279
+ if (isNBaseConnectionString(rawConnectionString)) {
280
+ this._nbaseConfig = parseNBaseConnectionString(rawConnectionString);
281
+ this._nbaseOnly = true;
282
+ return createAdapterProxy(this, (name) => this.table(name));
283
+ }
284
+ const attached = this._engineConfig.nbase;
285
+ if (attached && isNBaseConnectionString(attached)) {
286
+ this._nbaseConfig = parseNBaseConnectionString(attached);
287
+ }
288
+
223
289
  if ((adapterConfig as any).engine) {
224
290
  this._engine = (adapterConfig as any).engine;
225
291
  this._engineType = (this._engine?.dialect as any) || 'sqlite';
@@ -233,18 +299,28 @@ export class An5Adapter {
233
299
  return createAdapterProxy(this, (name) => this.table(name));
234
300
  }
235
301
 
302
+ // Lower-cased for the dialect test only; the connection string itself keeps its
303
+ // case for the driver. A URI scheme is case-insensitive, and `@an5/orm` reads the
304
+ // provider the same way to validate field types and write DDL — if the two
305
+ // disagreed, a schema would be checked against one database and run on another.
236
306
  const cs = (adapterConfig.connectionString || '').trim();
237
- if (cs.startsWith('googlesheets://')) {
307
+ const forDialect = cs.toLowerCase();
308
+
309
+ if (forDialect.startsWith('googlesheets://')) {
238
310
  this.sheetsAdapter = new An5SheetsAdapter(parseSheetsConnectionString(cs));
239
311
  return createAdapterProxy(this, (name) => this.table(name));
240
312
  }
241
313
 
242
314
  this._engineConfig = adapterConfig;
243
- if (cs.startsWith('postgres://') || cs.startsWith('postgresql://')) {
315
+ if (forDialect.startsWith('postgres://') || forDialect.startsWith('postgresql://')) {
244
316
  this._engineType = 'postgres';
245
- } else if (cs.startsWith('mysql://') || cs.startsWith('mariadb://')) {
317
+ } else if (forDialect.startsWith('mysql://') || forDialect.startsWith('mariadb://')) {
246
318
  this._engineType = 'mysql';
247
- } else if (cs.startsWith('sqlite://') || cs.endsWith('.sqlite') || cs.endsWith('.db')) {
319
+ } else if (
320
+ forDialect === ':memory:'
321
+ || forDialect.startsWith('sqlite:')
322
+ || SQLITE_FILE_SUFFIXES.some((suffix) => forDialect.endsWith(suffix))
323
+ ) {
248
324
  this._engineType = 'sqlite';
249
325
  } else {
250
326
  this._engineType = 'mssql';
@@ -336,9 +412,35 @@ export class An5Adapter {
336
412
 
337
413
  async $connect(): Promise<void> {
338
414
  if (this.sheetsAdapter) return this.sheetsAdapter.$connect();
415
+ if (this._nbaseOnly) {
416
+ // NBase is stateless over HTTP, so there is nothing to open. Report the
417
+ // endpoint so a misconfiguration is obvious.
418
+ const config = this.nbaseClient()?.config;
419
+ if (config) await this.nbaseClient()!.client.health();
420
+ return;
421
+ }
339
422
  await (await this.requireEngine()).connect();
340
423
  }
341
424
 
425
+ /**
426
+ * Health check for the configured NBase instance, or the database when the
427
+ * adapter has none.
428
+ */
429
+ async $nbaseHealth(): Promise<{ connected: boolean; endpoint?: string; error?: string }> {
430
+ const nbase = this.nbaseClient();
431
+ if (!nbase) return { connected: false, error: 'No NBase instance configured.' };
432
+ try {
433
+ const health = await nbase.client.health();
434
+ return { connected: health.status === 'ok', endpoint: nbase.config.url };
435
+ } catch (err) {
436
+ return {
437
+ connected: false,
438
+ endpoint: nbase.config.url,
439
+ error: err instanceof Error ? err.message : String(err),
440
+ };
441
+ }
442
+ }
443
+
342
444
  async $disconnect(): Promise<void> {
343
445
  if (this.sheetsAdapter) return this.sheetsAdapter.$disconnect();
344
446
  await (await this.requireEngine()).disconnect();
@@ -1059,6 +1161,181 @@ export class AdapterTableClient<T = any> {
1059
1161
  return this.doExec(query, params);
1060
1162
  }
1061
1163
 
1164
+ /**
1165
+ * Runs similarity search in NBase and hydrates the matching rows from the
1166
+ * relational table, keeping the `{ ...row, distance }` shape the other
1167
+ * engines return.
1168
+ */
1169
+ private async searchViaNBase(
1170
+ nbase: { client: NBaseVectorClient; config: NBaseAdapterConfig },
1171
+ args: { vector: number[]; take?: number; where?: any },
1172
+ metric: 'cosine' | 'euclidean' | 'dot',
1173
+ take: number,
1174
+ ): Promise<(T & { distance: number })[]> {
1175
+ const { client, config } = nbase;
1176
+ const idField = config.idField ?? 'an5Id';
1177
+ const modelField = config.modelField ?? 'an5Model';
1178
+
1179
+ const results = await client.search(args.vector, {
1180
+ k: take,
1181
+ // NBase implements cosine and euclidean; a dot-product request falls back
1182
+ // to cosine, which still orders the results sensibly.
1183
+ distanceMetric: metric === 'euclidean' ? 'euclidean' : 'cosine',
1184
+ includeMetadata: true,
1185
+ ...(config.method ? { method: config.method } : {}),
1186
+ });
1187
+
1188
+ // Map each hit back to its row id. Ids are written as `Model:id`, and the
1189
+ // metadata key is honoured for projects that index vectors themselves.
1190
+ const ids: string[] = [];
1191
+ const distances = new Map<string, number>();
1192
+ for (const hit of results) {
1193
+ const fromMetadata = hit.metadata?.[idField];
1194
+ const rawId = fromMetadata !== undefined && fromMetadata !== null
1195
+ ? String(fromMetadata)
1196
+ : parseVectorId(hit.id).id;
1197
+ if (!rawId) continue;
1198
+ const model = hit.metadata?.[modelField] ?? parseVectorId(hit.id).model;
1199
+ ids.push(model === this.modelName ? rawId : `${model}:${rawId}`);
1200
+ distances.set(ids[ids.length - 1] as string, hit.dist);
1201
+ }
1202
+ if (ids.length === 0) return [];
1203
+
1204
+ const pk = this.primaryKeyColumn();
1205
+ const inList = ids.map((_, i) => `@nbase_id_${i}`).join(', ');
1206
+ const idParams: Record<string, any> = {};
1207
+ ids.forEach((id, i) => { idParams[`nbase_id_${i}`] = id; });
1208
+ idParams.take = take;
1209
+
1210
+ const selectColumns = this.modelFields().map((f) => quote(f, this.dialect)).join(', ');
1211
+ const columns = selectColumns ? `${selectColumns}, ${quote(pk, this.dialect)} AS __an5_nbase_key` : `${quote(pk, this.dialect)} AS __an5_nbase_key`;
1212
+ const query = `SELECT ${columns} FROM ${this.tableName} WHERE ${quote(pk, this.dialect)} IN (${inList})`;
1213
+
1214
+ const rows = await this.adapter.exec<Record<string, any>>(query, idParams);
1215
+ const byKey = new Map<string, Record<string, any>>();
1216
+ for (const row of rows ?? []) {
1217
+ byKey.set(String(row.__an5_nbase_key), row);
1218
+ }
1219
+
1220
+ const hydrated: (T & { distance: number })[] = [];
1221
+ for (const id of ids) {
1222
+ const row = byKey.get(id);
1223
+ if (!row) continue;
1224
+ const { __an5_nbase_key, ...rest } = row;
1225
+ hydrated.push({ ...(rest as T), distance: distances.get(id) ?? 0 } as T & { distance: number });
1226
+ if (hydrated.length >= take) break;
1227
+ }
1228
+ return hydrated;
1229
+ }
1230
+
1231
+ /**
1232
+ * Vector search for an adapter configured with only an NBase connection
1233
+ * string. The hit id and metadata are the result, since there is no
1234
+ * relational row to hydrate.
1235
+ */
1236
+ private async searchViaNBaseOnly(
1237
+ nbase: { client: NBaseVectorClient; config: NBaseAdapterConfig },
1238
+ args: { vector: number[]; take?: number },
1239
+ metric: 'cosine' | 'euclidean' | 'dot',
1240
+ take: number,
1241
+ ): Promise<Array<Record<string, unknown> & { distance: number }>> {
1242
+ const { client, config } = nbase;
1243
+ const idField = config.idField ?? 'an5Id';
1244
+ const results = await client.search(args.vector, {
1245
+ k: take,
1246
+ distanceMetric: metric === 'euclidean' ? 'euclidean' : 'cosine',
1247
+ includeMetadata: true,
1248
+ ...(config.method ? { method: config.method } : {}),
1249
+ });
1250
+
1251
+ return results.map((hit) => {
1252
+ const metadata = { ...(hit.metadata ?? {}) };
1253
+ if (metadata[idField] === undefined) {
1254
+ const parsed = parseVectorId(hit.id);
1255
+ if (parsed.id) metadata[idField] = parsed.id;
1256
+ if (parsed.model) metadata[config.modelField ?? 'an5Model'] = parsed.model;
1257
+ }
1258
+ return { id: hit.id, ...metadata, distance: hit.dist };
1259
+ });
1260
+ }
1261
+
1262
+ /** The primary key column registered for this model, falling back to `id`. */
1263
+ private primaryKeyColumn(): string {
1264
+ const fields = this.modelFields();
1265
+ const marked = fields.find((f) => f === 'id');
1266
+ return marked ?? fields[0] ?? 'id';
1267
+ }
1268
+
1269
+ /** Scalar column names known for this model from the generated metadata. */
1270
+ private modelFields(): string[] {
1271
+ const fields = getFieldsForModel(this.modelName);
1272
+ if (fields && typeof fields === 'object' && !Array.isArray(fields)) {
1273
+ return Object.keys(fields as Record<string, unknown>);
1274
+ }
1275
+ return [];
1276
+ }
1277
+
1278
+ /**
1279
+ * Pushes a table's embedding column into NBase so it can be searched there.
1280
+ *
1281
+ * Each vector keeps the row id in its metadata, which is what lets
1282
+ * `vectorSearch` return real rows instead of bare vector ids.
1283
+ */
1284
+ async indexVectorsInNBase(args: {
1285
+ vectorField?: string;
1286
+ where?: any;
1287
+ take?: number;
1288
+ batchSize?: number;
1289
+ } = {}): Promise<{ indexed: number }> {
1290
+ const nbase = this.adapter.nbaseClient?.() ?? null;
1291
+ if (!nbase) {
1292
+ throw new Error(
1293
+ 'No NBase instance configured. Use a nbase:// connection string, e.g. createAn5Adapter({ connectionString: "nbase://localhost:1307" }).',
1294
+ );
1295
+ }
1296
+ if (this.adapter.nbaseOnly) {
1297
+ throw new Error(
1298
+ 'indexVectorsInNBase reads the vectors from a table, but this adapter was created with an nbase:// connection string and has no database. ' +
1299
+ 'Create the adapter with your database connection string to index, or add the vectors with the NBase client directly.',
1300
+ );
1301
+ }
1302
+ const vectorField = args.vectorField || 'embedding';
1303
+ const batchSize = args.batchSize ?? 200;
1304
+ const pk = this.primaryKeyColumn();
1305
+ const rows = await this.findMany({ ...(args.where ? { where: args.where } : {}), ...(args.take ? { take: args.take } : {}) });
1306
+
1307
+ let indexed = 0;
1308
+ for (let i = 0; i < rows.length; i += batchSize) {
1309
+ const batch = rows.slice(i, i + batchSize);
1310
+ const vectors = batch
1311
+ .map((row) => {
1312
+ const raw = (row as Record<string, unknown>)[vectorField];
1313
+ if (raw === undefined || raw === null) return null;
1314
+ let vector: number[];
1315
+ try {
1316
+ vector = typeof raw === 'string' ? JSON.parse(raw) : (raw as number[]);
1317
+ } catch {
1318
+ return null;
1319
+ }
1320
+ if (!Array.isArray(vector) || vector.length === 0) return null;
1321
+ return {
1322
+ id: buildVectorId(this.modelName, String((row as Record<string, unknown>)[pk])),
1323
+ vector,
1324
+ metadata: {
1325
+ [nbase.config.idField ?? 'an5Id']: String((row as Record<string, unknown>)[pk]),
1326
+ [nbase.config.modelField ?? 'an5Model']: this.modelName,
1327
+ },
1328
+ };
1329
+ })
1330
+ .filter((v): v is NonNullable<typeof v> => v !== null);
1331
+
1332
+ if (vectors.length === 0) continue;
1333
+ const result = await nbase.client.addVectors(vectors);
1334
+ indexed += result.count;
1335
+ }
1336
+ return { indexed };
1337
+ }
1338
+
1062
1339
  async vectorSearch(args: {
1063
1340
  vector: number[];
1064
1341
  take?: number;
@@ -1078,6 +1355,25 @@ export class AdapterTableClient<T = any> {
1078
1355
  const col = quote(vectorField, this.dialect);
1079
1356
  const params: Record<string, any> = { query_vector: vectorJson };
1080
1357
 
1358
+ // 0. NBase, when the project configured one: the vectors live in the vector
1359
+ // database, so the search never loads the table into memory.
1360
+ const nbase = this.adapter.nbaseClient?.() ?? null;
1361
+ if (nbase) {
1362
+ try {
1363
+ // With an NBase-only connection string there is no table to read rows
1364
+ // from, so the hits are returned as the vector metadata itself.
1365
+ if (this.adapter.nbaseOnly) {
1366
+ return (await this.searchViaNBaseOnly(nbase, args, metric, take)) as (T & { distance: number })[];
1367
+ }
1368
+ return await this.searchViaNBase(nbase, args, metric, take);
1369
+ } catch (err) {
1370
+ if (!(err instanceof NBaseError)) throw err;
1371
+ // A NBase outage must not take vector search down with it; fall through
1372
+ // to the database instead of failing the query.
1373
+ console.warn(`[an5] NBase vector search unavailable (${err.message}); falling back to ${this.dialect}.`);
1374
+ }
1375
+ }
1376
+
1081
1377
  // 1. Try native dialect execution
1082
1378
  if (this.dialect === 'postgres') {
1083
1379
  const op = metric === 'cosine' ? '<=>' : metric === 'dot' ? '<#>' : '<->';
@@ -88,10 +88,11 @@ export function parseWhere(
88
88
  if (!where) return '';
89
89
  const conditions: string[] = [];
90
90
 
91
+ const relations = ctx?.relationMap?.[modelName] || {};
91
92
  const cleanWhere: Record<string, any> = {};
92
93
  for (const [key, value] of Object.entries(where)) {
93
94
  if (
94
- key.includes('_') &&
95
+ key.includes('_') && !relations[key] &&
95
96
  value && typeof value === 'object' &&
96
97
  !(value instanceof Date) &&
97
98
  !isOperatorValue(value)
@@ -102,23 +103,23 @@ export function parseWhere(
102
103
  }
103
104
  }
104
105
 
105
- const relations = ctx?.relationMap?.[modelName] || {};
106
106
  const colPrefix = ctx?.colPrefix || '';
107
107
 
108
108
  for (const [key, value] of Object.entries(cleanWhere)) {
109
+ if (value === undefined) continue;
109
110
  if (key === 'OR' && Array.isArray(value)) {
110
- const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx)).filter(Boolean);
111
- if (sub.length > 0) conditions.push(`(${sub.join(' OR ')})`);
111
+ const sub = value.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}or_${i}_`, ctx) || '1=1');
112
+ conditions.push(sub.length > 0 ? `(${sub.join(' OR ')})` : '1=0');
112
113
  } else if (key === 'AND') {
113
114
  const items = Array.isArray(value) ? value : [value];
114
115
  const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}and_${i}_`, ctx)).filter(Boolean);
115
116
  if (sub.length > 0) conditions.push(`(${sub.join(' AND ')})`);
116
117
  } else if (key === 'NOT') {
117
118
  const items = Array.isArray(value) ? value : [value];
118
- const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx)).filter(Boolean);
119
- if (sub.length > 0) conditions.push(`NOT (${sub.join(' AND ')})`);
119
+ const sub = items.map((v, i) => parseWhere(modelName, v, params, dialect, `${prefix}not_${i}_`, ctx) || '1=1');
120
+ if (sub.length > 0) conditions.push(`NOT (${sub.join(' OR ')})`);
120
121
  } else if (relations[key] && value && typeof value === 'object') {
121
- const clause = buildRelationClause(relations[key], value, modelName, params, dialect, prefix, ctx);
122
+ const clause = buildRelationClause(relations[key], value, modelName, params, dialect, `${prefix}${key}_`, ctx);
122
123
  if (clause) conditions.push(clause);
123
124
  } else {
124
125
  const pname = sanitizeParamName(`${prefix}${key}`);
@@ -203,28 +204,28 @@ function buildRelationClause(
203
204
 
204
205
  if (isMany) {
205
206
  if (value.some !== undefined) {
206
- const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, prefix, ctx, childAlias);
207
+ const inner = buildInnerWhere(relation.modelName, value.some === null ? {} : value.some, params, dialect, `${prefix}some_`, ctx, childAlias);
207
208
  parts.push(buildExists(inner));
208
209
  }
209
210
  if (value.none !== undefined) {
210
- const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, prefix, ctx, childAlias);
211
+ const inner = buildInnerWhere(relation.modelName, value.none === null ? {} : value.none, params, dialect, `${prefix}none_`, ctx, childAlias);
211
212
  parts.push(`NOT ${buildExists(inner)}`);
212
213
  }
213
214
  if (value.every !== undefined) {
214
215
  // "every" = no related row violates the condition.
215
- const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, prefix, ctx, childAlias);
216
+ const inner = buildInnerWhere(relation.modelName, value.every === null ? {} : value.every, params, dialect, `${prefix}every_`, ctx, childAlias);
216
217
  const negated = inner ? `NOT (${inner})` : '';
217
218
  const violated = `EXISTS (SELECT 1 FROM ${childTable} AS ${childAlias} WHERE ${correlation}${negated ? ` AND ${negated}` : ' AND 1=0'})`;
218
219
  parts.push(`NOT ${violated}`);
219
220
  }
220
221
  } else {
221
222
  if (value.is !== undefined) {
222
- const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, prefix, ctx, childAlias);
223
- parts.push(buildExists(inner));
223
+ const inner = buildInnerWhere(relation.modelName, value.is === null ? {} : value.is, params, dialect, `${prefix}is_`, ctx, childAlias);
224
+ parts.push(value.is === null ? `NOT ${buildExists(inner)}` : buildExists(inner));
224
225
  }
225
226
  if (value.isNot !== undefined) {
226
- const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, prefix, ctx, childAlias);
227
- parts.push(`NOT ${buildExists(inner)}`);
227
+ const inner = buildInnerWhere(relation.modelName, value.isNot === null ? {} : value.isNot, params, dialect, `${prefix}isNot_`, ctx, childAlias);
228
+ parts.push(value.isNot === null ? buildExists(inner) : `NOT ${buildExists(inner)}`);
228
229
  }
229
230
  }
230
231
 
@@ -2,6 +2,12 @@ export type Dialect = 'mssql' | 'postgres' | 'mysql' | 'sqlite' | 'googlesheets'
2
2
 
3
3
  export interface An5AdapterConfig {
4
4
  connectionString?: string;
5
+ /**
6
+ * NBase (Neural Vector Database) endpoint, written as a connection string,
7
+ * e.g. `nbase://localhost:1307`. Attach it here when vector search should
8
+ * run in NBase while the rows stay in `connectionString`'s database.
9
+ */
10
+ nbase?: string;
5
11
  engine?: QueryEngine;
6
12
  db?: any;
7
13
  driver?: any;
@@ -21,9 +21,7 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
21
21
  key.includes('_') &&
22
22
  value && typeof value === 'object' &&
23
23
  !(value instanceof Date) &&
24
- !(value as any).in && !(value as any).contains &&
25
- !(value as any).not && !(value as any).gte &&
26
- !(value as any).lte && !(value as any).gt && !(value as any).lt
24
+ !['equals', 'in', 'notIn', 'contains', 'startsWith', 'endsWith', 'not', 'gte', 'lte', 'gt', 'lt'].some(op => op in value)
27
25
  ) {
28
26
  Object.assign(cleanWhere, value);
29
27
  } else {
@@ -32,12 +30,14 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
32
30
  }
33
31
 
34
32
  for (const [key, value] of Object.entries(cleanWhere)) {
33
+ if (value === undefined) continue;
35
34
  if (key === 'OR' && Array.isArray(value)) {
36
35
  if (!value.some((v: any) => matchWhere(row, v))) return false;
37
36
  continue;
38
37
  }
39
- if (key === 'AND' && Array.isArray(value)) {
40
- if (!value.every((v: any) => matchWhere(row, v))) return false;
38
+ if (key === 'AND') {
39
+ const clauses = Array.isArray(value) ? value : [value];
40
+ if (!clauses.every((v: any) => matchWhere(row, v))) return false;
41
41
  continue;
42
42
  }
43
43
  if (key === 'NOT') {
@@ -57,17 +57,20 @@ export function matchWhere(row: Record<string, any>, where: any): boolean {
57
57
 
58
58
  if (v.not !== undefined) {
59
59
  if (v.not === null) { if (cellVal === null || cellVal === undefined) return false; }
60
+ else if (v.not && typeof v.not === 'object' && !(v.not instanceof Date) && !Array.isArray(v.not)) {
61
+ if (matchWhere(row, { [key]: v.not })) return false;
62
+ }
60
63
  else if (normalize(cellVal) == normalize(v.not)) return false;
61
64
  }
62
65
  if (v.equals !== undefined && normalize(cellVal) != normalize(v.equals)) return false;
63
66
  if (v.contains !== undefined) {
64
- if (!cellVal || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
67
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).includes(normalize(v.contains))) return false;
65
68
  }
66
69
  if (v.startsWith !== undefined) {
67
- if (!cellVal || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
70
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).startsWith(normalize(v.startsWith))) return false;
68
71
  }
69
72
  if (v.endsWith !== undefined) {
70
- if (!cellVal || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
73
+ if (cellVal === null || cellVal === undefined || !normalize(String(cellVal)).endsWith(normalize(v.endsWith))) return false;
71
74
  }
72
75
  if (v.gte !== undefined && !(Number(cellVal) >= Number(v.gte))) return false;
73
76
  if (v.lte !== undefined && !(Number(cellVal) <= Number(v.lte))) return false;
@@ -7,6 +7,23 @@ export {
7
7
  setAdapterMetadata,
8
8
  } from './an5Adapter';
9
9
  export { parseWhere, buildOrderBy, quote } from './base/sql';
10
+ export {
11
+ NBaseVectorClient,
12
+ NBaseError,
13
+ createNBaseVectorClient,
14
+ createNBaseClient,
15
+ parseNBaseConnectionString,
16
+ buildVectorId,
17
+ parseVectorId,
18
+ } from './nbase';
19
+ export type {
20
+ NBaseAdapterConfig,
21
+ NBaseClientConfig,
22
+ NBaseVector,
23
+ NBaseSearchOptions,
24
+ NBaseSearchResult,
25
+ NBaseStats,
26
+ } from './nbase';
10
27
  export type {
11
28
  An5AdapterConfig,
12
29
  AdapterMetadata,