uql-orm 0.70.0 → 0.71.0

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 (71) hide show
  1. package/README.md +2 -0
  2. package/dist/cockroachdb/cockroachDialect.d.ts +6 -0
  3. package/dist/cockroachdb/cockroachDialect.js +8 -0
  4. package/dist/d1/d1SqliteDialect.d.ts +3 -0
  5. package/dist/d1/d1SqliteDialect.js +3 -1
  6. package/dist/dialect/abstractSqlDialect.d.ts +5 -0
  7. package/dist/dialect/abstractSqlDialect.js +12 -5
  8. package/dist/dialect/hydrateColumn.d.ts +3 -2
  9. package/dist/dialect/hydrateColumn.js +10 -1
  10. package/dist/dialect/mysqlLikeSqlDialect.js +2 -1
  11. package/dist/dialect/pgLikeSqlDialect.d.ts +17 -5
  12. package/dist/dialect/pgLikeSqlDialect.js +33 -15
  13. package/dist/dialect/queryJoins.d.ts +14 -3
  14. package/dist/dialect/queryJoins.js +31 -11
  15. package/dist/dialect/vectorCast.d.ts +2 -0
  16. package/dist/dialect/vectorCast.js +7 -0
  17. package/dist/dialect/vectorSqlDialect.d.ts +7 -3
  18. package/dist/dialect/vectorSqlDialect.js +15 -10
  19. package/dist/libsql/libsqlDialect.d.ts +1 -1
  20. package/dist/libsql/libsqlDialect.js +3 -3
  21. package/dist/maria/mariaDialect.d.ts +3 -9
  22. package/dist/maria/mariaDialect.js +4 -13
  23. package/dist/maria/mariadbQuerier.js +9 -3
  24. package/dist/migrate/ddl/index.d.ts +1 -0
  25. package/dist/migrate/ddl/index.js +4 -2
  26. package/dist/migrate/ddl/indexDdl.d.ts +2 -0
  27. package/dist/migrate/ddl/indexDdl.js +10 -2
  28. package/dist/migrate/ddl/pgIndexDdl.d.ts +3 -4
  29. package/dist/migrate/ddl/pgIndexDdl.js +11 -11
  30. package/dist/migrate/ddl/sqliteIndexDdl.d.ts +11 -0
  31. package/dist/migrate/ddl/sqliteIndexDdl.js +38 -0
  32. package/dist/migrate/generator/mongoCommand.d.ts +28 -0
  33. package/dist/migrate/generator/mongoCommand.js +8 -0
  34. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +2 -0
  35. package/dist/migrate/generator/mongoSchemaGenerator.js +50 -6
  36. package/dist/migrate/introspection/mongoIntrospector.js +33 -7
  37. package/dist/migrate/introspection/postgresIntrospector.js +19 -0
  38. package/dist/migrate/introspection/sqliteIntrospector.d.ts +6 -0
  39. package/dist/migrate/introspection/sqliteIntrospector.js +29 -3
  40. package/dist/mongo/mongoDialect.d.ts +2 -0
  41. package/dist/mongo/mongoDialect.js +8 -4
  42. package/dist/mongo/mongodbQuerier.js +2 -2
  43. package/dist/mssql/mssqlDialect.js +1 -0
  44. package/dist/schema/canonicalType.js +10 -4
  45. package/dist/schema/indexDifferences.d.ts +5 -2
  46. package/dist/schema/indexDifferences.js +5 -0
  47. package/dist/schema/schemaASTBuilder.js +3 -2
  48. package/dist/sqlite/localSqliteQuerierPool.d.ts +10 -11
  49. package/dist/sqlite/localSqliteQuerierPool.js +8 -11
  50. package/dist/sqlite/nodeSqliteQuerierPool.d.ts +4 -2
  51. package/dist/sqlite/nodeSqliteQuerierPool.js +7 -4
  52. package/dist/sqlite/sqliteDialect.d.ts +9 -1
  53. package/dist/sqlite/sqliteDialect.js +42 -3
  54. package/dist/sqlite/sqliteQuerierPool.d.ts +6 -5
  55. package/dist/sqlite/sqliteQuerierPool.js +10 -7
  56. package/dist/turso/tursoLocalDialect.d.ts +1 -1
  57. package/dist/turso/tursoLocalDialect.js +1 -1
  58. package/dist/turso/tursoLocalQuerierPool.d.ts +4 -5
  59. package/dist/turso/tursoLocalQuerierPool.js +3 -10
  60. package/dist/type/dialect.d.ts +5 -0
  61. package/dist/type/entity.d.ts +14 -3
  62. package/dist/type/migration.d.ts +7 -9
  63. package/dist/type/queryAggregate.d.ts +4 -8
  64. package/dist/type/vector.d.ts +17 -0
  65. package/dist/type/vector.js +6 -0
  66. package/dist/util/ddlExpression.util.d.ts +2 -0
  67. package/dist/util/ddlExpression.util.js +5 -0
  68. package/dist/util/dialect.util.d.ts +11 -1
  69. package/dist/util/dialect.util.js +21 -1
  70. package/package.json +5 -3
  71. package/skills/uql-orm/SKILL.md +142 -0
package/README.md CHANGED
@@ -65,6 +65,8 @@ The compiler catches each of those, with no codegen: the entity classes are the
65
65
  - [Entities](https://uql-orm.dev/entities/basic) - decorators, relations, hooks, or the decorator-free [imperative API](https://uql-orm.dev/entities/imperative)
66
66
  - [Switching to UQL](https://uql-orm.dev/switching-to-uql) - coming from Prisma, Drizzle, TypeORM, or MikroORM
67
67
 
68
+ Using a coding agent? The package ships a [skill](skills/uql-orm/SKILL.md) for it (`npx @tanstack/intent install`, or `npx skills add rogerpadilla/uql`), and every docs page is Markdown, indexed at [uql-orm.dev/llms.txt](https://uql-orm.dev/llms.txt).
69
+
68
70
  Release notes live in [CHANGELOG.md](https://github.com/rogerpadilla/uql/blob/main/CHANGELOG.md).
69
71
 
70
72
  ---
@@ -1,4 +1,5 @@
1
1
  import { PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
2
+ import type { IndexType } from '../schema/types.js';
2
3
  import type { QueryContext, SqlDialectFeatures, Type } from '../type/index.js';
3
4
  /** CockroachDB: the Postgres wire and SQL, without `xmax` (so no upsert `created`) and with native vectors. */
4
5
  export declare class CockroachDialect extends PgLikeSqlDialect {
@@ -9,6 +10,11 @@ export declare class CockroachDialect extends PgLikeSqlDialect {
9
10
  * Re-check that issue before adding it.
10
11
  */
11
12
  readonly vectorMetrics: Map<import("../type/vector.js").VectorDistance, import("../type/vector.js").VectorMetric>;
13
+ /** Neither `regconfig` nor `WEBSEARCH_TO_TSQUERY` exist here (v26.3): the config goes as text, the search as plain words. */
14
+ protected readonly textConfigCast = "";
15
+ protected readonly textQueryFn = "PLAINTO_TSQUERY";
16
+ /** Its own beam for either type that builds its vector index; it refuses pgvector's `hnsw.ef_search`. */
17
+ protected readonly annSettings: ReadonlyMap<IndexType, string>;
12
18
  /** An upsert batch mixing an update and an insert returns the update first (verified on v26.2). */
13
19
  readonly features: SqlDialectFeatures;
14
20
  /**
@@ -10,6 +10,14 @@ export class CockroachDialect extends PgLikeSqlDialect {
10
10
  * Re-check that issue before adding it.
11
11
  */
12
12
  vectorMetrics = new Map([...PG_VECTOR_METRICS].filter(([metric]) => metric !== 'l1'));
13
+ /** Neither `regconfig` nor `WEBSEARCH_TO_TSQUERY` exist here (v26.3): the config goes as text, the search as plain words. */
14
+ textConfigCast = '';
15
+ textQueryFn = 'PLAINTO_TSQUERY';
16
+ /** Its own beam for either type that builds its vector index; it refuses pgvector's `hnsw.ef_search`. */
17
+ annSettings = new Map([
18
+ ['hnsw', 'vector_search_beam_size'],
19
+ ['vector', 'vector_search_beam_size'],
20
+ ]);
13
21
  /** An upsert batch mixing an update and an insert returns the update first (verified on v26.2). */
14
22
  features = { ...PG_FEATURES, orderedUpsertReturning: false };
15
23
  /**
@@ -1,10 +1,13 @@
1
1
  import { SqliteDialect } from '../sqlite/sqliteDialect.js';
2
+ import type { SqlDialectFeatures } from '../type/index.js';
2
3
  /**
3
4
  * SQLite Dialect specialization for Cloudflare D1.
4
5
  *
5
6
  * @remarks Distinct type for `D1QuerierPool` and a hook for D1-specific SQL differences.
6
7
  */
7
8
  export declare class D1SqliteDialect extends SqliteDialect {
9
+ /** A vector stays text: D1 answers a BLOB as an array of its byte values, which reads like a vector. */
10
+ readonly features: SqlDialectFeatures;
8
11
  readonly maxBindValues: number;
9
12
  readonly maxFunctionArgs: number;
10
13
  /**
@@ -1,10 +1,12 @@
1
- import { SqliteDialect } from '../sqlite/sqliteDialect.js';
1
+ import { SQLITE_FEATURES, SqliteDialect } from '../sqlite/sqliteDialect.js';
2
2
  /**
3
3
  * SQLite Dialect specialization for Cloudflare D1.
4
4
  *
5
5
  * @remarks Distinct type for `D1QuerierPool` and a hook for D1-specific SQL differences.
6
6
  */
7
7
  export class D1SqliteDialect extends SqliteDialect {
8
+ /** A vector stays text: D1 answers a BLOB as an array of its byte values, which reads like a vector. */
9
+ features = { ...SQLITE_FEATURES, vectorBytes: false };
8
10
  // Cloudflare D1 caps bound parameters at 100 per query.
9
11
  maxBindValues = 100;
10
12
  // And a function call at 32 arguments.
@@ -446,6 +446,11 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
446
446
  */
447
447
  private readOptions;
448
448
  insert<E>(ctx: QueryContext, entity: Type<E>, payload: E | E[], opts?: QueryOptions): void;
449
+ /**
450
+ * What a text search matches and a fulltext index covers, which have to agree for the index to serve the
451
+ * search: the columns themselves, where the engine indexes them as they are (MySQL's `MATCH (a, b)`).
452
+ */
453
+ textSearchTarget(columns: readonly string[], _config?: string): string;
449
454
  /** Where an insert's id clause goes: `RETURNING` at the end, or SQL Server's `OUTPUT` before `VALUES`. */
450
455
  readonly returningPosition: 'suffix' | 'after-target';
451
456
  /**
@@ -150,7 +150,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
150
150
  if (opts.prefix !== prefix) {
151
151
  opts = { ...opts, prefix };
152
152
  }
153
- this.where(ctx, entity, q.$where, opts);
153
+ this.where(ctx, entity, this.rankedWhere(meta, q, prefix), opts);
154
154
  const sorted = order
155
155
  ? this.orderCarried(ctx, q, order)
156
156
  : this.sort(ctx, entity, q.$sort, { prefix, joins, distinct: q.$distinct });
@@ -963,7 +963,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
963
963
  throw new TypeError('aggregate requires at least one $group column or $select function');
964
964
  }
965
965
  const table = this.tableRef(meta, this.readOptions(ctx, meta).alias);
966
- const joins = resolveGroupJoins(meta, q.$group, (path) => ctx.claimAlias(path));
966
+ const { joins, where } = resolveGroupJoins(meta, q, (path) => ctx.claimAlias(path));
967
967
  const prefix = joins.size ? table.alias : undefined;
968
968
  const reads = entries.map((entry) => ({ entry, value: this.aggregateValue(ctx, entity, entry, joins, prefix) }));
969
969
  // Only bare columns are read inline: SQL Server refuses a subquery inside an aggregate or a
@@ -988,7 +988,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
988
988
  const columns = reads.flatMap(({ entry, value }) => (value.sql === '*' ? [] : [named(value.sql, entry.alias)]));
989
989
  ctx.append(`SELECT ${selectParts.join(', ')} FROM ${derived ? `(SELECT ${columns.join(', ')} FROM ` : ''}${table.ref}`);
990
990
  this.selectRelationJoins(ctx, meta, table.alias, joins);
991
- this.where(ctx, entity, q.$where, { ...opts, prefix });
991
+ this.where(ctx, entity, where, { ...opts, prefix });
992
992
  if (derived) {
993
993
  ctx.append(`) ${this.escapeId(ROWS_ALIAS, true)}`);
994
994
  }
@@ -1127,6 +1127,13 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1127
1127
  ctx.append(` ${returning}`);
1128
1128
  }
1129
1129
  }
1130
+ /**
1131
+ * What a text search matches and a fulltext index covers, which have to agree for the index to serve the
1132
+ * search: the columns themselves, where the engine indexes them as they are (MySQL's `MATCH (a, b)`).
1133
+ */
1134
+ textSearchTarget(columns, _config) {
1135
+ return columns.join(', ');
1136
+ }
1130
1137
  /** Where an insert's id clause goes: `RETURNING` at the end, or SQL Server's `OUTPUT` before `VALUES`. */
1131
1138
  returningPosition = 'suffix';
1132
1139
  /**
@@ -1340,7 +1347,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1340
1347
  /** The same for an aggregate's row, per query: each alias decodes by {@link aggregateKind}. */
1341
1348
  hydratableAggregates(entity, q) {
1342
1349
  const meta = getMeta(entity);
1343
- const joins = resolveGroupJoins(meta, q.$group);
1350
+ const { joins } = resolveGroupJoins(meta, q);
1344
1351
  const decoded = [];
1345
1352
  for (const entry of parseGroupMap(q.$group, q.$select)) {
1346
1353
  const kind = entry.kind === 'fn'
@@ -1387,7 +1394,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
1387
1394
  case 'json':
1388
1395
  return 'json';
1389
1396
  case 'vector':
1390
- return this.supportedVectorType(resolveVectorCast(field));
1397
+ return this.features.vectorBytes ? 'float32' : this.supportedVectorType(resolveVectorCast(field));
1391
1398
  case 'boolean':
1392
1399
  return 'boolean';
1393
1400
  case 'numeric':
@@ -3,9 +3,10 @@ import { type VectorCast } from './vectorCast.js';
3
3
  * How a stored column is decoded on read: the inverse of `AbstractSqlDialect.persistKind`. `json`
4
4
  * parses; a {@link VectorCast} says which literal; `boolean` undoes an engine with no boolean type,
5
5
  * `number` and `bigint` a driver that hands a wide integer or a decimal back as text, and `date` and
6
- * `bytes` a row that crossed JSON inside its parent's statement, which spells both as text.
6
+ * `bytes` a row that crossed JSON inside its parent's statement, which spells both as text. `float32` is
7
+ * a vector bound as bytes (`DialectFeatures.vectorBytes`).
7
8
  */
8
- export type HydrateKind = 'json' | 'boolean' | 'number' | 'bigint' | 'date' | 'bytes' | VectorCast;
9
+ export type HydrateKind = 'json' | 'boolean' | 'number' | 'bigint' | 'date' | 'bytes' | 'float32' | VectorCast;
9
10
  /**
10
11
  * Decodes one non-null cell. A no-op where the driver already decoded it, since that varies per driver,
11
12
  * and untouched where it does not match its column's format.
@@ -20,6 +20,14 @@ function vectorDecoder(cast) {
20
20
  ? decodeFloat32s(hexBytes(text.slice(BYTES_PREFIX.length)))
21
21
  : (parseVectorLiteral(text, cast) ?? value));
22
22
  }
23
+ const denseVector = vectorDecoder('vector');
24
+ /** A vector bound as bytes: packed float32s as a driver returns them, else hex or text, as JSON or an older row carries it. */
25
+ const float32Decoder = (value) => {
26
+ if (value instanceof ArrayBuffer) {
27
+ return decodeFloat32s(new Uint8Array(value));
28
+ }
29
+ return value instanceof Uint8Array ? decodeFloat32s(value) : denseVector(value);
30
+ };
23
31
  const DECODERS = {
24
32
  // 0/1 from SQLite's INTEGER or MySQL's TINYINT(1). Already a boolean on Postgres.
25
33
  boolean: (value) => (typeof value === 'boolean' ? value : Boolean(value)),
@@ -48,7 +56,8 @@ const DECODERS = {
48
56
  return value;
49
57
  }
50
58
  }),
51
- vector: vectorDecoder('vector'),
59
+ float32: float32Decoder,
60
+ vector: denseVector,
52
61
  halfvec: vectorDecoder('halfvec'),
53
62
  sparsevec: vectorDecoder('sparsevec'),
54
63
  };
@@ -23,6 +23,7 @@ export const MYSQL_FEATURES = {
23
23
  commentSyntax: 'inline',
24
24
  vectorIndexRequiresNotNull: false,
25
25
  vectorSupportsLength: false,
26
+ vectorBytes: false,
26
27
  supportsTimestamptz: false,
27
28
  stringSizing: 'varchar',
28
29
  supportsUnsigned: true,
@@ -178,7 +179,7 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
178
179
  */
179
180
  appendTextSearch(ctx, _entity, meta, search) {
180
181
  const columns = textSearchFields(meta, search).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
181
- ctx.append(`MATCH(${columns.join(', ')}) AGAINST(`);
182
+ ctx.append(`MATCH(${this.textSearchTarget(columns)}) AGAINST(`);
182
183
  ctx.addValue(search.$value);
183
184
  ctx.append(')');
184
185
  }
@@ -1,3 +1,4 @@
1
+ import type { IndexType } from '../schema/types.js';
1
2
  import { type DriverCapabilities, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QueryTextSearchOptions, type SqlDialectFeatures, type Type, type VectorDistance, type VectorMetric } from '../type/index.js';
2
3
  import type { DialectOptions } from './abstractDialect.js';
3
4
  import { AbstractSqlDialect, type RelationRows } from './abstractSqlDialect.js';
@@ -45,11 +46,11 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
45
46
  readonly maxBindValues: number;
46
47
  readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorMetric>;
47
48
  /**
48
- * The GUC each pgvector index type reads for "how much of the index to explore". They are not the
49
+ * The setting each vector index type reads for "how much of the index to explore". They are not the
49
50
  * same quantity - `ef_search` is a candidate-list size, `probes` a count of lists - which is why
50
51
  * `$candidates` is documented in the index's own units rather than as a portable number.
51
52
  */
52
- private static readonly ANN_SETTINGS;
53
+ protected readonly annSettings: ReadonlyMap<IndexType, string>;
53
54
  /**
54
55
  * `SET LOCAL hnsw.ef_search = N`, plus `hnsw.iterative_scan = strict_order` where the query also filters
55
56
  * by distance, which would otherwise drop rows the candidate list missed.
@@ -58,11 +59,22 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
58
59
  normalizeValue(value: unknown): unknown;
59
60
  placeholder(index: number): string;
60
61
  /**
61
- * `TO_TSVECTOR(...) @@ WEBSEARCH_TO_TSQUERY(...)`. `WEBSEARCH_TO_TSQUERY` takes free-form user input
62
- * (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`, which
63
- * rejects anything unparseable - including a plain two-word search.
62
+ * `<document> @@ WEBSEARCH_TO_TSQUERY(...)`, under the `$config` asked for, else that of the fulltext
63
+ * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
64
+ * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
64
65
  */
65
66
  protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
67
+ /**
68
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
69
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
70
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
71
+ */
72
+ textSearchTarget(columns: readonly string[], config?: string): string;
73
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
74
+ private textConfigArg;
75
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
76
+ protected readonly textConfigCast: string;
77
+ protected readonly textQueryFn: string;
66
78
  protected jsonContains(ctx: QueryContext, slot: JsonSlot, values: readonly unknown[]): string;
67
79
  protected jsonLength(slot: JsonSlot): string;
68
80
  /** Each element stays `jsonb`, so it is read as any path is: `->` for the value, `->>` for its text. */
@@ -1,5 +1,5 @@
1
1
  import { QueryRaw, } from '../type/index.js';
2
- import { hasVectorNear, textSearchFields } from '../util/dialect.util.js';
2
+ import { fulltextConfig, fulltextIndexOver, hasVectorNear, textSearchFields } from '../util/dialect.util.js';
3
3
  import { escapeSingleQuotes } from '../util/sqlLiteral.js';
4
4
  import { AbstractSqlDialect } from './abstractSqlDialect.js';
5
5
  import { JSON_PULL_ALIAS, RELATION_ROW_ALIAS } from './aliases.js';
@@ -13,6 +13,8 @@ export const PG_VECTOR_METRICS = new Map([
13
13
  ['inner', { op: '<#>', index: 'ip' }],
14
14
  ['l1', { op: '<+>', index: 'l1' }],
15
15
  ]);
16
+ /** pgvector's HNSW candidate list, the one setting an iterative scan goes with. */
17
+ const HNSW_EF_SEARCH = 'hnsw.ef_search';
16
18
  /** What the Postgres-wire engines have. */
17
19
  export const PG_FEATURES = {
18
20
  ifNotExists: true,
@@ -25,6 +27,7 @@ export const PG_FEATURES = {
25
27
  commentSyntax: 'statement',
26
28
  vectorIndexRequiresNotNull: false,
27
29
  vectorSupportsLength: true,
30
+ vectorBytes: false,
28
31
  supportsTimestamptz: true,
29
32
  stringSizing: 'bounded-text',
30
33
  supportsUnsigned: false,
@@ -87,12 +90,12 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
87
90
  maxBindValues = 65535;
88
91
  vectorMetrics = PG_VECTOR_METRICS;
89
92
  /**
90
- * The GUC each pgvector index type reads for "how much of the index to explore". They are not the
93
+ * The setting each vector index type reads for "how much of the index to explore". They are not the
91
94
  * same quantity - `ef_search` is a candidate-list size, `probes` a count of lists - which is why
92
95
  * `$candidates` is documented in the index's own units rather than as a portable number.
93
96
  */
94
- static ANN_SETTINGS = new Map([
95
- ['hnsw', 'hnsw.ef_search'],
97
+ annSettings = new Map([
98
+ ['hnsw', HNSW_EF_SEARCH],
96
99
  ['ivfflat', 'ivfflat.probes'],
97
100
  ]);
98
101
  /**
@@ -101,12 +104,12 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
101
104
  */
102
105
  vectorTuningStatements(meta, q) {
103
106
  const indexType = this.tunedVectorIndex(meta, q)?.type;
104
- const setting = indexType ? PgLikeSqlDialect.ANN_SETTINGS.get(indexType) : undefined;
107
+ const setting = indexType ? this.annSettings.get(indexType) : undefined;
105
108
  if (!setting) {
106
109
  return [];
107
110
  }
108
111
  const statements = [`SET LOCAL ${setting} = ${q.$candidates}`];
109
- if (indexType === 'hnsw' && hasVectorNear(q.$where)) {
112
+ if (setting === HNSW_EF_SEARCH && hasVectorNear(q.$where)) {
110
113
  statements.push('SET LOCAL hnsw.iterative_scan = strict_order');
111
114
  }
112
115
  return statements;
@@ -121,20 +124,35 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
121
124
  return `$${index}`;
122
125
  }
123
126
  /**
124
- * `TO_TSVECTOR(...) @@ WEBSEARCH_TO_TSQUERY(...)`. `WEBSEARCH_TO_TSQUERY` takes free-form user input
125
- * (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`, which
126
- * rejects anything unparseable - including a plain two-word search.
127
+ * `<document> @@ WEBSEARCH_TO_TSQUERY(...)`, under the `$config` asked for, else that of the fulltext
128
+ * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
129
+ * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
127
130
  */
128
131
  appendTextSearch(ctx, _entity, meta, search) {
129
- const fields = textSearchFields(meta, search)
130
- .map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])))
131
- .join(` || ' ' || `);
132
- // The config is bound once and its numbered placeholder reused by both calls.
133
- const config = search.$config ? `${this.addValue(ctx, search.$config)}::regconfig, ` : '';
134
- ctx.append(`TO_TSVECTOR(${config}${fields}) @@ WEBSEARCH_TO_TSQUERY(${config}`);
132
+ const keys = textSearchFields(meta, search);
133
+ const index = fulltextIndexOver(meta, keys);
134
+ const config = search.$config ?? (index && fulltextConfig(index));
135
+ const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
136
+ ctx.append(`${this.textSearchTarget(columns, config)} @@ ${this.textQueryFn}(${this.textConfigArg(config)}`);
135
137
  ctx.addValue(search.$value);
136
138
  ctx.append(')');
137
139
  }
140
+ /**
141
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
142
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
143
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
144
+ */
145
+ textSearchTarget(columns, config) {
146
+ const document = columns.map((column) => `COALESCE(${column}, '')`).join(` || ' ' || `);
147
+ return `TO_TSVECTOR(${this.textConfigArg(config)}${document})`;
148
+ }
149
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
150
+ textConfigArg(config) {
151
+ return config === undefined ? '' : `${this.escape(config)}${this.textConfigCast}, `;
152
+ }
153
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
154
+ textConfigCast = '::regconfig';
155
+ textQueryFn = 'WEBSEARCH_TO_TSQUERY';
138
156
  jsonContains(ctx, slot, values) {
139
157
  return `${this.jsonValue(slot)} @> ${this.jsonVal(ctx, values)}`;
140
158
  }
@@ -1,4 +1,4 @@
1
- import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, RelationMeta, RelationQuery, Type } from '../type/index.js';
1
+ import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
2
2
  /**
3
3
  * One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
4
4
  * `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
@@ -39,8 +39,19 @@ export type QuerySortOptions = {
39
39
  * columns, the `ORDER BY` and the lock agree. `claimAlias` names each join's table, parents first.
40
40
  */
41
41
  export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>, claimAlias?: (path: string) => string): QueryJoins;
42
- /** What an aggregate joins: each to-one relation a `$group` path passes through. */
43
- export declare function resolveGroupJoins<E>(meta: EntityMeta<E>, group: QueryGroupMap<E> | undefined, claimAlias?: (path: string) => string): QueryJoins;
42
+ /**
43
+ * What an aggregate joins, and the `$where` left to it. Each to-one relation a `$group` path passes through
44
+ * is an `INNER` join, since a group of a path names a related row; a filter on one of them, keyed at the top
45
+ * of the `$where` where an `AND` joins it, moves into that join rather than reading its table again. Under a
46
+ * `$not` it could not: the join would drop the rows the negation keeps.
47
+ */
48
+ export declare function resolveGroupJoins<E>(meta: EntityMeta<E>, q: {
49
+ readonly $group?: QueryGroupMap<E>;
50
+ readonly $where?: QueryWhere<E>;
51
+ }, claimAlias?: (path: string) => string): {
52
+ readonly joins: QueryJoins;
53
+ readonly where: QueryWhere<E> | undefined;
54
+ };
44
55
  /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
45
56
  export declare function groupPathField(joins: QueryJoins, path: readonly string[]): {
46
57
  readonly key: string;
@@ -1,5 +1,5 @@
1
1
  import { getMeta, relationOf } from '../entity/index.js';
2
- import { getKeys, getRelationRequestSummary, isRecord, isToManyRelation, parseRelationAtKey } from '../util/index.js';
2
+ import { getKeys, getRelationRequestSummary, isRecord, isToManyRelation, parseRelationAtKey, parseRelationSize, } from '../util/index.js';
3
3
  export const NO_JOINS = new Map();
4
4
  /**
5
5
  * What the statement joins, from `$populate` and from a `$sort` by a to-one relation's field, so the
@@ -11,18 +11,35 @@ export function resolveQueryJoins(meta, q, claimAlias = (path) => path) {
11
11
  }
12
12
  const joins = new Map();
13
13
  addPopulateJoins(joins, claimAlias, meta, q.$populate);
14
- addPathJoins(joins, claimAlias, meta, q.$sort);
14
+ addPathJoins(joins, claimAlias, meta, q.$sort, false);
15
15
  return joins;
16
16
  }
17
- /** What an aggregate joins: each to-one relation a `$group` path passes through. */
18
- export function resolveGroupJoins(meta, group, claimAlias = (path) => path) {
17
+ /**
18
+ * What an aggregate joins, and the `$where` left to it. Each to-one relation a `$group` path passes through
19
+ * is an `INNER` join, since a group of a path names a related row; a filter on one of them, keyed at the top
20
+ * of the `$where` where an `AND` joins it, moves into that join rather than reading its table again. Under a
21
+ * `$not` it could not: the join would drop the rows the negation keeps.
22
+ */
23
+ export function resolveGroupJoins(meta, q, claimAlias = (path) => path) {
19
24
  const joins = new Map();
20
- for (const ref of Object.values(group ?? {})) {
25
+ for (const ref of Object.values(q.$group ?? {})) {
21
26
  if (isRecord(ref)) {
22
- addPathJoins(joins, claimAlias, meta, ref);
27
+ addPathJoins(joins, claimAlias, meta, ref, true);
23
28
  }
24
29
  }
25
- return joins;
30
+ if (!q.$where) {
31
+ return { joins, where: q.$where };
32
+ }
33
+ const where = { ...q.$where };
34
+ for (const key of getKeys(q.$where)) {
35
+ const join = joins.get(key);
36
+ const filter = q.$where[key];
37
+ if (join && isRecord(filter) && parseRelationSize(filter) === undefined) {
38
+ joins.set(key, { ...join, query: { $where: filter } });
39
+ delete where[key];
40
+ }
41
+ }
42
+ return { joins, where };
26
43
  }
27
44
  /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
28
45
  export function groupPathField(joins, path) {
@@ -93,8 +110,11 @@ function addPopulateJoins(joins, claimAlias, meta, populate, parent) {
93
110
  addPopulateJoins(joins, claimAlias, join.meta, query.$populate, join);
94
111
  }
95
112
  }
96
- /** The to-one relations a nested map of fields passes through, a `$sort` or a `$group` path, as joins adding no columns. */
97
- function addPathJoins(joins, claimAlias, meta, map, parent) {
113
+ /**
114
+ * The to-one relations a nested map of fields passes through, a `$sort` or a `$group` path, as joins adding
115
+ * no columns: `required` where the path names a related row, as a group's does, and a sort's does not.
116
+ */
117
+ function addPathJoins(joins, claimAlias, meta, map, required, parent) {
98
118
  if (!map) {
99
119
  return;
100
120
  }
@@ -106,9 +126,9 @@ function addPathJoins(joins, claimAlias, meta, map, parent) {
106
126
  if (!relation || isToManyRelation(relation) || !isSortMap(value)) {
107
127
  continue;
108
128
  }
109
- const join = addJoin(joins, claimAlias, parent, key, relation, {}, false, false);
129
+ const join = addJoin(joins, claimAlias, parent, key, relation, {}, required, false);
110
130
  // `E` stated: inferred from the nested map, it lands on the nested relation's target.
111
- addPathJoins(joins, claimAlias, join.meta, value, join);
131
+ addPathJoins(joins, claimAlias, join.meta, value, required, join);
112
132
  }
113
133
  }
114
134
  /** The join a sort may address at `path` with the relation's own sort map, or why it may not; `unjoinable` is the dialect's remedy. */
@@ -20,5 +20,7 @@ export declare function toSparsevecLiteral(values: readonly unknown[]): string;
20
20
  * as it was written, `{1:1,3:2}/3` or `[1,2,3]`; `undefined` where the text matches neither.
21
21
  */
22
22
  export declare function parseVectorLiteral(raw: string, cast: VectorCast): number[] | undefined;
23
+ /** The packed little-endian float32s a blob vector column holds, and {@link decodeFloat32s} reads back. */
24
+ export declare function encodeFloat32s(values: readonly unknown[]): Uint8Array;
23
25
  /** Packed little-endian float32s, each read as {@link shortestFloat32}. */
24
26
  export declare function decodeFloat32s(bytes: Uint8Array): number[];
@@ -62,6 +62,13 @@ function parseDense(text) {
62
62
  return undefined;
63
63
  }
64
64
  }
65
+ /** The packed little-endian float32s a blob vector column holds, and {@link decodeFloat32s} reads back. */
66
+ export function encodeFloat32s(values) {
67
+ const bytes = new Uint8Array(values.length * 4);
68
+ const view = new DataView(bytes.buffer);
69
+ values.forEach((value, at) => view.setFloat32(at * 4, Number(value), true));
70
+ return bytes;
71
+ }
65
72
  /** Packed little-endian float32s, each read as {@link shortestFloat32}. */
66
73
  export function decodeFloat32s(bytes) {
67
74
  const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
@@ -1,6 +1,6 @@
1
- import type { EntityIndexMeta, EntityMeta, FieldOptions, Query, QueryContext, QueryVectorSearch, SqlDialectFeatures, VectorDistance, VectorMetric } from '../type/index.js';
1
+ import type { EntityIndexMeta, EntityMeta, FieldOptions, Query, QueryContext, QueryVectorSearch, QueryWhere, SqlDialectFeatures, VectorDistance, VectorMetric } from '../type/index.js';
2
2
  import { AbstractDialect } from './abstractDialect.js';
3
- import type { VectorCast } from './vectorCast.js';
3
+ import { type VectorCast } from './vectorCast.js';
4
4
  /**
5
5
  * Vector search for the SQL dialects: the distance a `$sort` ranks by and projects, and the ANN tuning.
6
6
  * Each dialect lists its metrics in {@link vectorMetrics}, an operator or a function; empty means no search.
@@ -20,12 +20,16 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
20
20
  * spelled into a `SET` rather than bound, and `/http` input is untyped.
21
21
  */
22
22
  protected tunedVectorIndex<E>(meta: EntityMeta<E>, q: Query<E>): EntityIndexMeta | undefined;
23
+ /** The `$where` a read runs: the query's own, which an engine reading its vector index as a table narrows. */
24
+ protected rankedWhere<E>(_meta: EntityMeta<E>, q: Query<E>, _prefix: string | undefined): QueryWhere<E> | undefined;
23
25
  /**
24
26
  * Every distance metric this dialect has, and how a search and an index spell each. Empty means no
25
27
  * vector search at all, which is what MySQL and D1 are. The key set is the single answer to "is this
26
28
  * metric supported here", for a query and an index alike.
27
29
  */
28
30
  readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorMetric>;
31
+ /** Whether this engine has a vector index: the one a metric's `index` names. */
32
+ hasVectorIndex(): boolean;
29
33
  /** Quotes an identifier; supplied by the SQL dialect built on top of this layer. */
30
34
  abstract escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
31
35
  /**
@@ -39,7 +43,7 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
39
43
  };
40
44
  /**
41
45
  * Binds a vector, both as a persisted value and as the query vector of a distance expression, so a
42
- * dialect needing a conversion around it (`$1::vector`, `VEC_FromText(?)`) declares it once.
46
+ * dialect needing a conversion around it (`$1::vector`, `CAST(? AS VECTOR(n))`) declares it once.
43
47
  */
44
48
  protected appendVectorValue(ctx: QueryContext, value: readonly unknown[], _field?: FieldOptions): void;
45
49
  /**
@@ -1,7 +1,8 @@
1
- import { unsupportedVectorMetric } from '../type/vector.js';
2
- import { findVectorIndex, findVectorSort } from '../util/dialect.util.js';
1
+ import { DEFAULT_VECTOR_DISTANCE, unsupportedVectorMetric } from '../type/vector.js';
2
+ import { findVectorIndex, findVectorSort, vectorCandidates } from '../util/dialect.util.js';
3
3
  import { entityName } from '../util/object.util.js';
4
4
  import { AbstractDialect } from './abstractDialect.js';
5
+ import { encodeFloat32s } from './vectorCast.js';
5
6
  /**
6
7
  * Vector search for the SQL dialects: the distance a `$sort` ranks by and projects, and the ANN tuning.
7
8
  * Each dialect lists its metrics in {@link vectorMetrics}, an operator or a function; empty means no search.
@@ -24,22 +25,26 @@ export class VectorSqlDialect extends AbstractDialect {
24
25
  * spelled into a `SET` rather than bound, and `/http` input is untyped.
25
26
  */
26
27
  tunedVectorIndex(meta, q) {
27
- const candidates = q.$candidates;
28
- if (candidates === undefined) {
28
+ if (vectorCandidates(q) === undefined) {
29
29
  return undefined;
30
30
  }
31
- if (!Number.isInteger(candidates) || candidates < 1) {
32
- throw new TypeError(`$candidates must be a positive integer, got ${JSON.stringify(candidates)}`);
33
- }
34
31
  const key = this.vectorSortKey(q);
35
32
  return key ? findVectorIndex(meta, key) : undefined;
36
33
  }
34
+ /** The `$where` a read runs: the query's own, which an engine reading its vector index as a table narrows. */
35
+ rankedWhere(_meta, q, _prefix) {
36
+ return q.$where;
37
+ }
37
38
  /**
38
39
  * Every distance metric this dialect has, and how a search and an index spell each. Empty means no
39
40
  * vector search at all, which is what MySQL and D1 are. The key set is the single answer to "is this
40
41
  * metric supported here", for a query and an index alike.
41
42
  */
42
43
  vectorMetrics = new Map();
44
+ /** Whether this engine has a vector index: the one a metric's `index` names. */
45
+ hasVectorIndex() {
46
+ return [...this.vectorMetrics.values()].some((metric) => metric.index);
47
+ }
43
48
  /**
44
49
  * What a distance expression reads, for a `$sort` and a `$near` alike. The metric falls back to the
45
50
  * field's, then its index's, which serves no other, then cosine.
@@ -47,15 +52,15 @@ export class VectorSqlDialect extends AbstractDialect {
47
52
  resolveVectorDistance(meta, key, search) {
48
53
  const field = meta.fields[key];
49
54
  const colName = this.resolveColumnName(key, field);
50
- const distance = search.$distance ?? field?.distance ?? findVectorIndex(meta, key)?.distance ?? 'cosine';
55
+ const distance = search.$distance ?? field?.distance ?? findVectorIndex(meta, key)?.distance ?? DEFAULT_VECTOR_DISTANCE;
51
56
  return { colName, distance, field };
52
57
  }
53
58
  /**
54
59
  * Binds a vector, both as a persisted value and as the query vector of a distance expression, so a
55
- * dialect needing a conversion around it (`$1::vector`, `VEC_FromText(?)`) declares it once.
60
+ * dialect needing a conversion around it (`$1::vector`, `CAST(? AS VECTOR(n))`) declares it once.
56
61
  */
57
62
  appendVectorValue(ctx, value, _field) {
58
- ctx.addValue(`[${value.join(',')}]`);
63
+ ctx.addValue(this.features.vectorBytes ? encodeFloat32s(value) : `[${value.join(',')}]`);
59
64
  }
60
65
  /**
61
66
  * The vector type this dialect actually has for a declared one, so the cast follows the column
@@ -7,6 +7,6 @@ import type { VectorDistance, VectorMetric } from '../type/index.js';
7
7
  * functions, which `TursoDialect` inherits.
8
8
  */
9
9
  export declare class LibsqlDialect extends SqliteDialect {
10
- /** libSQL's built-in vector functions; no `inner` (only the Rust engine has it) and no `l1`. */
10
+ /** libSQL's built-in vector functions, and the metric its DiskANN index names; no `inner` (only the Rust engine has it) and no `l1`. */
11
11
  readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorMetric>;
12
12
  }
@@ -6,9 +6,9 @@ import { SqliteDialect } from '../sqlite/sqliteDialect.js';
6
6
  * functions, which `TursoDialect` inherits.
7
7
  */
8
8
  export class LibsqlDialect extends SqliteDialect {
9
- /** libSQL's built-in vector functions; no `inner` (only the Rust engine has it) and no `l1`. */
9
+ /** libSQL's built-in vector functions, and the metric its DiskANN index names; no `inner` (only the Rust engine has it) and no `l1`. */
10
10
  vectorMetrics = new Map([
11
- ['cosine', { fn: 'vector_distance_cos' }],
12
- ['l2', { fn: 'vector_distance_l2' }],
11
+ ['cosine', { fn: 'vector_distance_cos', index: 'cosine' }],
12
+ ['l2', { fn: 'vector_distance_l2', index: 'l2' }],
13
13
  ]);
14
14
  }
@@ -6,8 +6,9 @@ export declare class MariaDialect extends MysqlLikeSqlDialect {
6
6
  readonly dialectName = "mariadb";
7
7
  readonly insertIdSource = "returning";
8
8
  /**
9
- * Unlike MySQL: `VECTOR(n)` takes its dimension, every column of a vector index has to be NOT NULL,
10
- * `CREATE INDEX` takes `IF NOT EXISTS`, and a lock cannot be narrowed to one table of a join.
9
+ * Unlike MySQL: `VECTOR(n)` takes its dimension and binds as packed float32 bytes, every column of a
10
+ * vector index has to be NOT NULL, `CREATE INDEX` takes `IF NOT EXISTS`, and a lock cannot be narrowed
11
+ * to one table of a join.
11
12
  */
12
13
  readonly features: SqlDialectFeatures;
13
14
  /**
@@ -34,13 +35,6 @@ export declare class MariaDialect extends MysqlLikeSqlDialect {
34
35
  protected jsonDiffers(elem: string, operand: string): string;
35
36
  /** `VEC_DISTANCE_COSINE`/`VEC_DISTANCE_EUCLIDEAN`, 11.7+, which the index's `DISTANCE=` names alike. */
36
37
  readonly vectorMetrics: ReadonlyMap<VectorDistance, VectorMetric>;
37
- /**
38
- * A `VECTOR` column holds a packed little-endian float32 blob, and MariaDB refuses text where one
39
- * belongs: inserting `'[1,2,3]'` fails with `Incorrect vector value`, and passing it to
40
- * `VEC_DISTANCE_COSINE` with `Illegal parameter data type varchar`. `VEC_FromText` is the
41
- * conversion, needed on both paths.
42
- */
43
- protected appendVectorValue(ctx: QueryContext, value: readonly unknown[]): void;
44
38
  /**
45
39
  * `mhnsw_ef_search` too, where a vector search is tuned. A setting scoped to one statement needs
46
40
  * neither a transaction nor a restore, and cannot leak to the next query on this pooled connection,