uql-orm 0.70.0 → 0.72.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 (78) hide show
  1. package/README.md +2 -0
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/cockroachdb/cockroachDialect.d.ts +6 -0
  5. package/dist/cockroachdb/cockroachDialect.js +8 -0
  6. package/dist/d1/d1SqliteDialect.d.ts +3 -0
  7. package/dist/d1/d1SqliteDialect.js +3 -1
  8. package/dist/dialect/abstractSqlDialect.d.ts +14 -2
  9. package/dist/dialect/abstractSqlDialect.js +48 -16
  10. package/dist/dialect/aliases.d.ts +2 -0
  11. package/dist/dialect/aliases.js +2 -0
  12. package/dist/dialect/hydrateColumn.d.ts +3 -2
  13. package/dist/dialect/hydrateColumn.js +10 -1
  14. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -0
  15. package/dist/dialect/mysqlLikeSqlDialect.js +6 -1
  16. package/dist/dialect/pgLikeSqlDialect.d.ts +21 -5
  17. package/dist/dialect/pgLikeSqlDialect.js +48 -15
  18. package/dist/dialect/queryJoins.d.ts +14 -4
  19. package/dist/dialect/queryJoins.js +31 -11
  20. package/dist/dialect/vectorCast.d.ts +2 -0
  21. package/dist/dialect/vectorCast.js +7 -0
  22. package/dist/dialect/vectorSqlDialect.d.ts +7 -3
  23. package/dist/dialect/vectorSqlDialect.js +15 -10
  24. package/dist/libsql/libsqlDialect.d.ts +1 -1
  25. package/dist/libsql/libsqlDialect.js +3 -3
  26. package/dist/maria/mariaDialect.d.ts +3 -9
  27. package/dist/maria/mariaDialect.js +5 -15
  28. package/dist/maria/mariadbQuerier.js +9 -3
  29. package/dist/migrate/ddl/index.d.ts +1 -0
  30. package/dist/migrate/ddl/index.js +4 -2
  31. package/dist/migrate/ddl/indexDdl.d.ts +2 -0
  32. package/dist/migrate/ddl/indexDdl.js +10 -2
  33. package/dist/migrate/ddl/pgIndexDdl.d.ts +3 -4
  34. package/dist/migrate/ddl/pgIndexDdl.js +11 -11
  35. package/dist/migrate/ddl/sqliteIndexDdl.d.ts +11 -0
  36. package/dist/migrate/ddl/sqliteIndexDdl.js +38 -0
  37. package/dist/migrate/generator/mongoCommand.d.ts +28 -0
  38. package/dist/migrate/generator/mongoCommand.js +8 -0
  39. package/dist/migrate/generator/mongoSchemaGenerator.d.ts +2 -0
  40. package/dist/migrate/generator/mongoSchemaGenerator.js +50 -6
  41. package/dist/migrate/introspection/mongoIntrospector.js +33 -7
  42. package/dist/migrate/introspection/postgresIntrospector.js +19 -0
  43. package/dist/migrate/introspection/sqliteIntrospector.d.ts +6 -0
  44. package/dist/migrate/introspection/sqliteIntrospector.js +29 -3
  45. package/dist/mongo/mongoDialect.d.ts +5 -3
  46. package/dist/mongo/mongoDialect.js +40 -17
  47. package/dist/mongo/mongodbQuerier.js +4 -5
  48. package/dist/mssql/mssqlDialect.js +1 -0
  49. package/dist/schema/canonicalType.js +10 -4
  50. package/dist/schema/indexDifferences.d.ts +5 -2
  51. package/dist/schema/indexDifferences.js +5 -0
  52. package/dist/schema/schemaASTBuilder.js +3 -2
  53. package/dist/sqlite/localSqliteQuerierPool.d.ts +10 -11
  54. package/dist/sqlite/localSqliteQuerierPool.js +8 -11
  55. package/dist/sqlite/nodeSqliteQuerierPool.d.ts +4 -2
  56. package/dist/sqlite/nodeSqliteQuerierPool.js +7 -4
  57. package/dist/sqlite/sqliteDialect.d.ts +11 -1
  58. package/dist/sqlite/sqliteDialect.js +46 -3
  59. package/dist/sqlite/sqliteQuerierPool.d.ts +6 -5
  60. package/dist/sqlite/sqliteQuerierPool.js +10 -7
  61. package/dist/turso/tursoLocalDialect.d.ts +1 -1
  62. package/dist/turso/tursoLocalDialect.js +1 -1
  63. package/dist/turso/tursoLocalQuerierPool.d.ts +4 -5
  64. package/dist/turso/tursoLocalQuerierPool.js +3 -10
  65. package/dist/type/dialect.d.ts +5 -0
  66. package/dist/type/entity.d.ts +27 -13
  67. package/dist/type/migration.d.ts +7 -9
  68. package/dist/type/query.d.ts +11 -4
  69. package/dist/type/queryAggregate.d.ts +5 -16
  70. package/dist/type/utility.d.ts +13 -0
  71. package/dist/type/vector.d.ts +17 -0
  72. package/dist/type/vector.js +6 -0
  73. package/dist/util/ddlExpression.util.d.ts +2 -0
  74. package/dist/util/ddlExpression.util.js +5 -0
  75. package/dist/util/dialect.util.d.ts +21 -2
  76. package/dist/util/dialect.util.js +42 -2
  77. package/package.json +4 -3
  78. package/skills/uql-orm/SKILL.md +146 -0
@@ -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,26 @@ 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
+ /** `TS_RANK` of the document the predicate matches, against the same search. */
68
+ protected appendTextRank<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
69
+ /** The document a search reads and the search itself, open for its value. */
70
+ private textSearchParts;
71
+ /**
72
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
73
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
74
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
75
+ */
76
+ textSearchTarget(columns: readonly string[], config?: string): string;
77
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
78
+ private textConfigArg;
79
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
80
+ protected readonly textConfigCast: string;
81
+ protected readonly textQueryFn: string;
66
82
  protected jsonContains(ctx: QueryContext, slot: JsonSlot, values: readonly unknown[]): string;
67
83
  protected jsonLength(slot: JsonSlot): string;
68
84
  /** 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,50 @@ 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 { document, query } = this.textSearchParts(meta, search);
133
+ ctx.append(`${document} @@ ${query}`);
135
134
  ctx.addValue(search.$value);
136
135
  ctx.append(')');
137
136
  }
137
+ /** `TS_RANK` of the document the predicate matches, against the same search. */
138
+ appendTextRank(ctx, meta, search) {
139
+ const { document, query } = this.textSearchParts(meta, search);
140
+ ctx.append(`TS_RANK(${document}, ${query}`);
141
+ ctx.addValue(search.$value);
142
+ ctx.append('))');
143
+ }
144
+ /** The document a search reads and the search itself, open for its value. */
145
+ textSearchParts(meta, search) {
146
+ const keys = textSearchFields(meta, search);
147
+ const index = fulltextIndexOver(meta, keys);
148
+ const config = search.$config ?? (index && fulltextConfig(index));
149
+ const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
150
+ return {
151
+ document: this.textSearchTarget(columns, config),
152
+ query: `${this.textQueryFn}(${this.textConfigArg(config)}`,
153
+ };
154
+ }
155
+ /**
156
+ * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
157
+ * `NULL` column read as empty rather than emptying it all. The config is a literal: an index is built
158
+ * over one, and a bound one reaches it only where the driver leaves the parameter untyped.
159
+ */
160
+ textSearchTarget(columns, config) {
161
+ const document = columns.map((column) => `COALESCE(${column}, '')`).join(` || ' ' || `);
162
+ return `TO_TSVECTOR(${this.textConfigArg(config)}${document})`;
163
+ }
164
+ /** A config as the text-search functions' first argument, or nothing, for the server's default. */
165
+ textConfigArg(config) {
166
+ return config === undefined ? '' : `${this.escape(config)}${this.textConfigCast}, `;
167
+ }
168
+ /** How a config literal is typed, and the function reading a search's text: CockroachDB lacks both of these. */
169
+ textConfigCast = '::regconfig';
170
+ textQueryFn = 'WEBSEARCH_TO_TSQUERY';
138
171
  jsonContains(ctx, slot, values) {
139
172
  return `${this.jsonValue(slot)} @> ${this.jsonVal(ctx, values)}`;
140
173
  }
@@ -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
@@ -32,15 +32,25 @@ export type QuerySortOptions = {
32
32
  /** Alias the queried entity's own columns are qualified by, when the statement qualifies them. */
33
33
  readonly prefix?: string;
34
34
  readonly joins?: QueryJoins;
35
- readonly distinct?: boolean;
36
35
  };
37
36
  /**
38
37
  * What the statement joins, from `$populate` and from a `$sort` by a to-one relation's field, so the
39
38
  * columns, the `ORDER BY` and the lock agree. `claimAlias` names each join's table, parents first.
40
39
  */
41
40
  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;
41
+ /**
42
+ * What an aggregate joins, and the `$where` left to it. Each to-one relation a `$group` path passes through
43
+ * is an `INNER` join, since a group of a path names a related row; a filter on one of them, keyed at the top
44
+ * of the `$where` where an `AND` joins it, moves into that join rather than reading its table again. Under a
45
+ * `$not` it could not: the join would drop the rows the negation keeps.
46
+ */
47
+ export declare function resolveGroupJoins<E>(meta: EntityMeta<E>, q: {
48
+ readonly $group?: QueryGroupMap<E>;
49
+ readonly $where?: QueryWhere<E>;
50
+ }, claimAlias?: (path: string) => string): {
51
+ readonly joins: QueryJoins;
52
+ readonly where: QueryWhere<E> | undefined;
53
+ };
44
54
  /** The field a grouped `path` reads, and the join it reads it through: none for the entity's own. */
45
55
  export declare function groupPathField(joins: QueryJoins, path: readonly string[]): {
46
56
  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,
@@ -8,12 +8,14 @@ export class MariaDialect extends MysqlLikeSqlDialect {
8
8
  // MariaDB 10.5+ has `INSERT ... RETURNING`, so ids come back exact per row - the upsert's too.
9
9
  insertIdSource = 'returning';
10
10
  /**
11
- * Unlike MySQL: `VECTOR(n)` takes its dimension, every column of a vector index has to be NOT NULL,
12
- * `CREATE INDEX` takes `IF NOT EXISTS`, and a lock cannot be narrowed to one table of a join.
11
+ * Unlike MySQL: `VECTOR(n)` takes its dimension and binds as packed float32 bytes, every column of a
12
+ * vector index has to be NOT NULL, `CREATE INDEX` takes `IF NOT EXISTS`, and a lock cannot be narrowed
13
+ * to one table of a join.
13
14
  */
14
15
  features = {
15
16
  ...MYSQL_FEATURES,
16
17
  vectorSupportsLength: true,
18
+ vectorBytes: true,
17
19
  vectorIndexRequiresNotNull: true,
18
20
  indexIfNotExists: true,
19
21
  rowLockOf: false,
@@ -25,8 +27,7 @@ export class MariaDialect extends MysqlLikeSqlDialect {
25
27
  appendRelationArray(ctx, { entity, query, alias, joins }) {
26
28
  const meta = getMeta(entity);
27
29
  const terms = this.projection(ctx, entity, query, { prefix: alias, json: true }, joins);
28
- const sortOpts = { prefix: alias, joins, distinct: query.$distinct };
29
- const order = this.buildFragment(ctx, (fragmentCtx) => this.sort(fragmentCtx, entity, query.$sort, sortOpts));
30
+ const order = this.buildFragment(ctx, (fragmentCtx) => this.sort(fragmentCtx, entity, query, { prefix: alias, joins }));
30
31
  const page = this.buildFragment(ctx, (fragmentCtx) => this.pager(fragmentCtx, query));
31
32
  const from = this.buildFragment(ctx, (fragmentCtx) => {
32
33
  this.selectRelationJoins(fragmentCtx, meta, alias, joins);
@@ -69,17 +70,6 @@ export class MariaDialect extends MysqlLikeSqlDialect {
69
70
  ['cosine', { fn: 'VEC_DISTANCE_COSINE', index: 'cosine' }],
70
71
  ['l2', { fn: 'VEC_DISTANCE_EUCLIDEAN', index: 'euclidean' }],
71
72
  ]);
72
- /**
73
- * A `VECTOR` column holds a packed little-endian float32 blob, and MariaDB refuses text where one
74
- * belongs: inserting `'[1,2,3]'` fails with `Incorrect vector value`, and passing it to
75
- * `VEC_DISTANCE_COSINE` with `Illegal parameter data type varchar`. `VEC_FromText` is the
76
- * conversion, needed on both paths.
77
- */
78
- appendVectorValue(ctx, value) {
79
- ctx.append('VEC_FromText(');
80
- super.appendVectorValue(ctx, value);
81
- ctx.append(')');
82
- }
83
73
  /**
84
74
  * `mhnsw_ef_search` too, where a vector search is tuned. A setting scoped to one statement needs
85
75
  * neither a transaction nor a restore, and cannot leak to the next query on this pooled connection,
@@ -1,19 +1,25 @@
1
1
  import { AbstractPoolQuerier } from '../querier/abstractPoolQuerier.js';
2
2
  import { decodeBigInts } from '../util/wideNumber.js';
3
+ /** This driver binds only a `Buffer` as bytes: any other `Uint8Array` goes as the JSON of its indices. */
4
+ function toBindValues(values) {
5
+ return values?.map((value) => value instanceof Uint8Array && !Buffer.isBuffer(value)
6
+ ? Buffer.from(value.buffer, value.byteOffset, value.byteLength)
7
+ : value);
8
+ }
3
9
  export class MariadbQuerier extends AbstractPoolQuerier {
4
10
  async internalAll(query, values) {
5
- const rows = await this.getConn().query(query, values);
11
+ const rows = await this.getConn().query(query, toBindValues(values));
6
12
  return Array.from(rows, decodeBigInts);
7
13
  }
8
14
  async internalRun(query, values) {
9
- const res = await this.getConn().query(query, values);
15
+ const res = await this.getConn().query(query, toBindValues(values));
10
16
  // An OK packet reports `affectedRows`; a `RETURNING` statement answers rows instead, and counts by them.
11
17
  const changes = res.affectedRows ?? res.length;
12
18
  const rows = res.length ? Array.from(res, decodeBigInts) : [];
13
19
  return this.buildUpdateResult({ rows, changes, upsertStatus: res.affectedRows });
14
20
  }
15
21
  async *internalStream(query, values) {
16
- const stream = this.getConn().queryStream(query, values);
22
+ const stream = this.getConn().queryStream(query, toBindValues(values));
17
23
  try {
18
24
  for await (const row of stream) {
19
25
  yield decodeBigInts(row);
@@ -6,6 +6,7 @@ export { MsSqlIndexDdl } from './mssqlIndexDdl.js';
6
6
  export { MsSqlTableDdl } from './mssqlTableDdl.js';
7
7
  export { MariaIndexDdl, MySqlIndexDdl, MysqlLikeIndexDdl } from './mysqlIndexDdl.js';
8
8
  export { CockroachIndexDdl, PgIndexDdl } from './pgIndexDdl.js';
9
+ export { SqliteIndexDdl } from './sqliteIndexDdl.js';
9
10
  export { TableDdl } from './tableDdl.js';
10
11
  export declare function indexDdlFor(dialect: AbstractSqlDialect): IndexDdl;
11
12
  /**
@@ -3,16 +3,18 @@ import { MsSqlIndexDdl } from './mssqlIndexDdl.js';
3
3
  import { MsSqlTableDdl } from './mssqlTableDdl.js';
4
4
  import { MariaIndexDdl, MySqlIndexDdl } from './mysqlIndexDdl.js';
5
5
  import { CockroachIndexDdl, PgIndexDdl } from './pgIndexDdl.js';
6
+ import { SqliteIndexDdl } from './sqliteIndexDdl.js';
6
7
  import { TableDdl } from './tableDdl.js';
7
8
  export { IndexDdl } from './indexDdl.js';
8
9
  export { MsSqlIndexDdl } from './mssqlIndexDdl.js';
9
10
  export { MsSqlTableDdl } from './mssqlTableDdl.js';
10
11
  export { MariaIndexDdl, MySqlIndexDdl, MysqlLikeIndexDdl } from './mysqlIndexDdl.js';
11
12
  export { CockroachIndexDdl, PgIndexDdl } from './pgIndexDdl.js';
13
+ export { SqliteIndexDdl } from './sqliteIndexDdl.js';
12
14
  export { TableDdl } from './tableDdl.js';
13
15
  /**
14
16
  * Each engine's index DDL, by the `dialectName` a subclass inherits: by name, so this entry carries no
15
- * dialect, and exhaustive, so a new engine has to name its own. SQLite's is the portable form.
17
+ * dialect, and exhaustive, so a new engine has to name its own.
16
18
  */
17
19
  const INDEX_DDL = {
18
20
  postgres: PgIndexDdl,
@@ -20,7 +22,7 @@ const INDEX_DDL = {
20
22
  mysql: MySqlIndexDdl,
21
23
  mariadb: MariaIndexDdl,
22
24
  mssql: MsSqlIndexDdl,
23
- sqlite: IndexDdl,
25
+ sqlite: SqliteIndexDdl,
24
26
  };
25
27
  export function indexDdlFor(dialect) {
26
28
  return new INDEX_DDL[dialect.dialectName](dialect);
@@ -37,6 +37,8 @@ export declare class IndexDdl<D extends AbstractSqlDialect = AbstractSqlDialect>
37
37
  protected readonly indexTypeKeywords: ReadonlyMap<IndexType, string>;
38
38
  /** The keyword an index type replaces `INDEX` with, or `INDEX` for the types that do not. */
39
39
  protected indexKeyword(index: IndexSchema): string;
40
+ /** What the index is over, between the parentheses: a fulltext one's columns as a search matches them, else its entries. */
41
+ protected indexTarget(index: IndexSchema): string;
40
42
  /** One index entry: what is indexed, its operator class if any, then its stored order. */
41
43
  protected indexColumn(entry: IndexColumnSchema, index: IndexSchema): string;
42
44
  /**
@@ -1,7 +1,7 @@
1
1
  import { jsonTypeMode } from '../../dialect/jsonSql.js';
2
2
  import { INDEX_TYPES } from '../../schema/types.js';
3
3
  import { INDEX_FEATURE_LABELS, } from '../../type/index.js';
4
- import { getKeys } from '../../util/index.js';
4
+ import { fulltextConfig, getKeys } from '../../util/index.js';
5
5
  /**
6
6
  * What in an index asks for each feature. A `Record` over the feature union rather than a list, so a
7
7
  * feature added to {@link INDEX_FEATURE_LABELS} cannot reach a dialect without the test that decides
@@ -45,7 +45,7 @@ export class IndexDdl {
45
45
  assertIndexFeatures(index, this.indexFeatures, this.dialect.dialectName);
46
46
  const unique = index.unique ? 'UNIQUE ' : '';
47
47
  const ifNotExists = (opts.ifNotExists ?? this.dialect.features.indexIfNotExists) ? 'IF NOT EXISTS ' : '';
48
- const columns = index.entries.map((entry) => this.indexColumn(entry, index)).join(', ');
48
+ const columns = this.indexTarget(index);
49
49
  return (`CREATE ${unique}${this.indexKeyword(index)} ${ifNotExists}${this.dialect.escapeId(index.name)} ` +
50
50
  `ON ${this.dialect.escapeId(tableName)}${this.indexAccessMethod(index)} (${columns})` +
51
51
  `${this.indexInclude(index)}${this.indexTuning(index)}${this.indexPredicate(index)};`);
@@ -78,6 +78,14 @@ export class IndexDdl {
78
78
  indexKeyword(index) {
79
79
  return (index.type && this.indexTypeKeywords.get(index.type)) || 'INDEX';
80
80
  }
81
+ /** What the index is over, between the parentheses: a fulltext one's columns as a search matches them, else its entries. */
82
+ indexTarget(index) {
83
+ if (index.type === 'fulltext' && !index.entries.some((entry) => entry.expression)) {
84
+ const columns = index.entries.map((entry) => this.dialect.escapeId(entry.column));
85
+ return this.dialect.textSearchTarget(columns, fulltextConfig(index));
86
+ }
87
+ return index.entries.map((entry) => this.indexColumn(entry, index)).join(', ');
88
+ }
81
89
  /** One index entry: what is indexed, its operator class if any, then its stored order. */
82
90
  indexColumn(entry, index) {
83
91
  return `${this.indexColumnTarget(entry)}${this.indexColumnOpsClass(entry, index)}${this.indexColumnOrder(entry)}`;
@@ -2,9 +2,8 @@ import type { IndexColumnSchema, IndexSchema } from '../../type/index.js';
2
2
  import { IndexDdl } from './indexDdl.js';
3
3
  /** `CREATE INDEX ... USING hnsw ("embedding" vector_cosine_ops) WITH (m = ...)`, pgvector's form. */
4
4
  export declare class PgIndexDdl extends IndexDdl {
5
- /** Postgres 18's `pg_am`, with pgvector's two. */
5
+ /** Postgres 18's `pg_am`, with pgvector's two, and `fulltext`, which builds a `gin` one. */
6
6
  protected readonly indexTypes: Set<"brin" | "btree" | "fulltext" | "gin" | "gist" | "hash" | "hnsw" | "ivfflat" | "vector" | "vectorSearch">;
7
- protected readonly indexTypeHints: ReadonlyMap<"brin" | "btree" | "fulltext" | "gin" | "gist" | "hash" | "hnsw" | "ivfflat" | "vector" | "vectorSearch", string>;
8
7
  protected readonly indexFeatures: Set<"expression" | "include" | "jsonArray" | "jsonPath" | "nullsOrder" | "opsClass" | "partial" | "prefixLength">;
9
8
  /** pgvector's own index types; CockroachDB's native one widens this. */
10
9
  protected isVectorIndex(index: IndexSchema): boolean;
@@ -27,6 +26,6 @@ export declare class CockroachIndexDdl extends PgIndexDdl {
27
26
  protected isVectorIndex(index: IndexSchema): boolean;
28
27
  protected indexKeyword(index: IndexSchema): string;
29
28
  protected indexAccessMethod(index: IndexSchema): string;
30
- /** None of pgvector's knobs: `WITH (m = 16)` answers "invalid storage parameter", `hnsw` included. */
31
- protected indexTuning(): string;
29
+ /** Its build-time candidate list alone: pgvector's `WITH (m = 16)` answers "invalid storage parameter", `hnsw` included. */
30
+ protected indexTuning(index: IndexSchema): string;
32
31
  }