uql-orm 0.72.1 → 0.73.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 (40) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +3 -3
  3. package/dist/dialect/abstractDialect.d.ts +6 -0
  4. package/dist/dialect/abstractDialect.js +22 -1
  5. package/dist/dialect/abstractSqlDialect.d.ts +7 -6
  6. package/dist/dialect/abstractSqlDialect.js +46 -38
  7. package/dist/dialect/aliases.d.ts +1 -1
  8. package/dist/dialect/aliases.js +1 -1
  9. package/dist/dialect/hydrateColumn.js +1 -1
  10. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -2
  11. package/dist/dialect/mysqlLikeSqlDialect.js +4 -5
  12. package/dist/dialect/pgLikeSqlDialect.d.ts +3 -3
  13. package/dist/dialect/pgLikeSqlDialect.js +6 -7
  14. package/dist/dialect/queryJoins.d.ts +11 -1
  15. package/dist/dialect/queryJoins.js +14 -0
  16. package/dist/dialect/vectorSqlDialect.d.ts +0 -5
  17. package/dist/dialect/vectorSqlDialect.js +0 -15
  18. package/dist/migrate/generator/mongoCommand.d.ts +2 -0
  19. package/dist/migrate/generator/mongoSchemaGenerator.js +3 -2
  20. package/dist/migrate/introspection/mongoIntrospector.js +8 -3
  21. package/dist/mongo/mongoDialect.d.ts +15 -2
  22. package/dist/mongo/mongoDialect.js +67 -18
  23. package/dist/mongo/mongodbQuerier.js +6 -7
  24. package/dist/mongo/mongodbQuerierPool.js +4 -1
  25. package/dist/schema/canonicalType.js +1 -6
  26. package/dist/schema/indexDifferences.d.ts +2 -2
  27. package/dist/schema/indexDifferences.js +9 -2
  28. package/dist/sqlite/sqliteDialect.d.ts +4 -4
  29. package/dist/sqlite/sqliteDialect.js +17 -7
  30. package/dist/type/dialect.d.ts +19 -6
  31. package/dist/type/query.d.ts +17 -9
  32. package/dist/type/queryWhere.d.ts +3 -2
  33. package/dist/type/vector.d.ts +0 -5
  34. package/dist/util/dialect.util.d.ts +13 -9
  35. package/dist/util/dialect.util.js +27 -7
  36. package/dist/util/raw.js +8 -2
  37. package/dist/util/wideNumber.d.ts +5 -3
  38. package/dist/util/wideNumber.js +8 -4
  39. package/package.json +1 -1
  40. package/skills/uql-orm/SKILL.md +3 -0
@@ -79,12 +79,12 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
79
79
  * the server answers "Can't find FULLTEXT index matching the column list". Declare it with
80
80
  * `@Index((post) => [...], { type: 'fulltext' })`.
81
81
  */
82
- protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
82
+ protected appendTextSearch<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, prefix: string | undefined): void;
83
83
  /**
84
84
  * `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. Over `keys`
85
85
  * alone, it needs a `FULLTEXT` index of exactly those: a weighted index declares one per heavier column.
86
86
  */
87
- protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[]): void;
87
+ protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[], prefix: string | undefined): void;
88
88
  /** `DOUBLE`, never a bare `DECIMAL`, which is `DECIMAL(10,0)` and rounds `1.4` to `1`. */
89
89
  protected numericCast(expr: string): string;
90
90
  protected neExpr(field: string, ph: string): string;
@@ -178,16 +178,15 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
178
178
  * the server answers "Can't find FULLTEXT index matching the column list". Declare it with
179
179
  * `@Index((post) => [...], { type: 'fulltext' })`.
180
180
  */
181
- appendTextSearch(ctx, _entity, meta, search) {
182
- this.appendTextScore(ctx, meta, search, textSearchFields(meta, search));
181
+ appendTextSearch(ctx, meta, search, prefix) {
182
+ this.appendTextScore(ctx, meta, search, textSearchFields(meta, search), prefix);
183
183
  }
184
184
  /**
185
185
  * `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. Over `keys`
186
186
  * alone, it needs a `FULLTEXT` index of exactly those: a weighted index declares one per heavier column.
187
187
  */
188
- appendTextScore(ctx, meta, search, keys) {
189
- const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
190
- ctx.append(`MATCH(${this.textSearchTarget(columns)}) AGAINST(`);
188
+ appendTextScore(ctx, meta, search, keys, prefix) {
189
+ ctx.append(`MATCH(${this.textSearchTarget(this.textColumns(meta, keys, prefix))}) AGAINST(`);
191
190
  ctx.addValue(search.$value);
192
191
  ctx.append(')');
193
192
  }
@@ -1,5 +1,5 @@
1
1
  import type { IndexType } from '../schema/types.js';
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
+ import { type DriverCapabilities, type EntityMeta, type FieldOptions, type JsonColumnType, type Query, type QueryContext, type QueryTextSearchOptions, type SqlDialectFeatures, type VectorDistance, type VectorMetric } from '../type/index.js';
3
3
  import type { DialectOptions } from './abstractDialect.js';
4
4
  import { AbstractSqlDialect, type RelationRows } from './abstractSqlDialect.js';
5
5
  import { type JsonAccessMode, type JsonSlot } from './jsonSql.js';
@@ -63,9 +63,9 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
63
63
  * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
64
64
  * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
65
65
  */
66
- protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
66
+ protected appendTextSearch<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, prefix: string | undefined): void;
67
67
  /** `TS_RANK` of the document over `keys` against the same search the match reads. */
68
- protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[]): void;
68
+ protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[], prefix: string | undefined): void;
69
69
  /**
70
70
  * The document over `keys` and the search itself, open for its value, under the `$config` asked for,
71
71
  * else that of the fulltext index over every field searched, which is what serves the match.
@@ -129,15 +129,15 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
129
129
  * index over these fields, which the planner serves it from. `WEBSEARCH_TO_TSQUERY` takes free-form
130
130
  * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
131
131
  */
132
- appendTextSearch(ctx, _entity, meta, search) {
133
- const { document, query } = this.textSearchParts(meta, search);
132
+ appendTextSearch(ctx, meta, search, prefix) {
133
+ const { document, query } = this.textSearchParts(meta, search, prefix);
134
134
  ctx.append(`${document} @@ ${query}`);
135
135
  ctx.addValue(search.$value);
136
136
  ctx.append(')');
137
137
  }
138
138
  /** `TS_RANK` of the document over `keys` against the same search the match reads. */
139
- appendTextScore(ctx, meta, search, keys) {
140
- const { document, query } = this.textSearchParts(meta, search, keys);
139
+ appendTextScore(ctx, meta, search, keys, prefix) {
140
+ const { document, query } = this.textSearchParts(meta, search, prefix, keys);
141
141
  ctx.append(`TS_RANK(${document}, ${query}`);
142
142
  ctx.addValue(search.$value);
143
143
  ctx.append('))');
@@ -146,13 +146,12 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
146
146
  * The document over `keys` and the search itself, open for its value, under the `$config` asked for,
147
147
  * else that of the fulltext index over every field searched, which is what serves the match.
148
148
  */
149
- textSearchParts(meta, search, keys) {
149
+ textSearchParts(meta, search, prefix, keys) {
150
150
  const fields = textSearchFields(meta, search);
151
151
  const index = fulltextIndexOver(meta, fields);
152
152
  const config = search.$config ?? (index && fulltextConfig(index));
153
- const columns = (keys ?? fields).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
154
153
  return {
155
- document: this.textSearchTarget(columns, config),
154
+ document: this.textSearchTarget(this.textColumns(meta, keys ?? fields, prefix), config),
156
155
  query: `${this.textQueryFn}(${this.textConfigArg(config)}`,
157
156
  };
158
157
  }
@@ -1,4 +1,5 @@
1
- import type { EntityMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
1
+ import type { EntityMeta, FieldMeta, Query, QueryGroupMap, QuerySortMap, QueryWhere, RelationMeta, RelationQuery, Type } from '../type/index.js';
2
+ import { type ParsedGroupEntry } from '../util/index.js';
2
3
  /**
3
4
  * One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
4
5
  * `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
@@ -56,6 +57,15 @@ export declare function groupPathField(joins: QueryJoins, path: readonly string[
56
57
  readonly key: string;
57
58
  readonly join: QueryJoin | undefined;
58
59
  };
60
+ /**
61
+ * The field an aggregate's column reads as, and the join it reads it through, none for the entity's own:
62
+ * a group key's, or the one a `$sum`, `$min` or `$max` aggregates. None at all for `$count` and `$avg`,
63
+ * which the engine widens to a number whatever they read.
64
+ */
65
+ export declare function aggregateColumnField<E>(meta: EntityMeta<E>, joins: QueryJoins, entry: ParsedGroupEntry<E>): {
66
+ readonly field: FieldMeta | undefined;
67
+ readonly join?: QueryJoin;
68
+ } | undefined;
59
69
  /**
60
70
  * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
61
71
  * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
@@ -53,6 +53,20 @@ export function groupPathField(joins, path) {
53
53
  }
54
54
  return { key, join };
55
55
  }
56
+ /**
57
+ * The field an aggregate's column reads as, and the join it reads it through, none for the entity's own:
58
+ * a group key's, or the one a `$sum`, `$min` or `$max` aggregates. None at all for `$count` and `$avg`,
59
+ * which the engine widens to a number whatever they read.
60
+ */
61
+ export function aggregateColumnField(meta, joins, entry) {
62
+ if (entry.kind === 'fn') {
63
+ return entry.op === '$count' || entry.op === '$avg' || entry.field === undefined
64
+ ? undefined
65
+ : { field: meta.fields[entry.field] };
66
+ }
67
+ const { key, join } = groupPathField(joins, entry.path);
68
+ return join ? { field: join.meta.fields[key], join } : { field: meta.fields[key] };
69
+ }
56
70
  /**
57
71
  * Whether a join drops parents that have no match, which is the one thing a join does to *how many*
58
72
  * rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
@@ -51,11 +51,6 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
51
51
  * rather than naming a type the engine does not define.
52
52
  */
53
53
  supportedVectorType(cast: VectorCast): VectorCast;
54
- /**
55
- * The distance a vector `$sort` projects, which the projection names after `$project`. Delegates to
56
- * `appendVectorDistance` so each dialect's distance syntax is written once.
57
- */
58
- protected appendVectorProjection<E>(ctx: QueryContext, meta: EntityMeta<E>, key: string, search: QueryVectorSearch): void;
59
54
  /**
60
55
  * The distance expression, in whichever of the two shapes this dialect spells it. One method for
61
56
  * both, so the metric lookup and its refusal exist once rather than per shape.
@@ -1,6 +1,5 @@
1
1
  import { DEFAULT_VECTOR_DISTANCE, unsupportedVectorMetric } from '../type/vector.js';
2
2
  import { findVectorIndex, findVectorSort, vectorCandidates } from '../util/dialect.util.js';
3
- import { entityName } from '../util/object.util.js';
4
3
  import { AbstractDialect } from './abstractDialect.js';
5
4
  import { encodeFloat32s } from './vectorCast.js';
6
5
  /**
@@ -69,20 +68,6 @@ export class VectorSqlDialect extends AbstractDialect {
69
68
  supportedVectorType(cast) {
70
69
  return this.features.narrowVectorTypes ? cast : 'vector';
71
70
  }
72
- /**
73
- * The distance a vector `$sort` projects, which the projection names after `$project`. Delegates to
74
- * `appendVectorDistance` so each dialect's distance syntax is written once.
75
- */
76
- appendVectorProjection(ctx, meta, key, search) {
77
- const alias = search.$project;
78
- // `$project` names a new column, so it cannot be one the entity already has: both come back
79
- // under that name and the driver keeps whichever it read last. Checked here rather than in the
80
- // type because TypeScript cannot say "any string except these".
81
- if (meta.fields[alias]) {
82
- throw new TypeError(`$project '${alias}' collides with a field of '${entityName(meta)}'`);
83
- }
84
- this.appendVectorDistance(ctx, meta, key, search);
85
- }
86
71
  /**
87
72
  * The distance expression, in whichever of the two shapes this dialect spells it. One method for
88
73
  * both, so the metric lookup and its refusal exist once rather than per shape.
@@ -7,6 +7,8 @@ export type MongoIndexOptions = {
7
7
  readonly partialFilterExpression?: Readonly<Record<string, unknown>>;
8
8
  /** A text index's weight per field, which `textScore` multiplies a match in it by. */
9
9
  readonly weights?: Readonly<Record<string, number>>;
10
+ /** The language a text index stems and drops stop words in, a search's own `$language` aside. */
11
+ readonly default_language?: string;
10
12
  };
11
13
  /** A field of an Atlas vector search index: the vector itself, or one its `filter` pre-filters on. */
12
14
  export type MongoVectorSearchField = {
@@ -1,9 +1,9 @@
1
1
  import { getMeta } from '../../entity/index.js';
2
- import { MongoDialect } from '../../mongo/mongoDialect.js';
2
+ import { MongoDialect, textLanguage } from '../../mongo/mongoDialect.js';
3
3
  import { QueryRaw, } from '../../type/index.js';
4
4
  import { indexDistance, unsupportedVectorMetric } from '../../type/vector.js';
5
5
  import { declaredIndexes, declaredIndexName, renderIndexColumn } from '../../util/ddlExpression.util.js';
6
- import { fulltextWeights } from '../../util/dialect.util.js';
6
+ import { fulltextConfig, fulltextWeights } from '../../util/dialect.util.js';
7
7
  import { assertIndexFeatures, assertIndexType } from '../ddl/indexDdl.js';
8
8
  import { assertIndexPredicate, refusedIndexPredicate } from '../indexPredicate.js';
9
9
  import { renderIndexDefinition } from './definitionToNode.js';
@@ -133,6 +133,7 @@ export class MongoSchemaGenerator extends MongoDialect {
133
133
  name: index.name,
134
134
  partialFilterExpression: index.where && JSON.parse(index.where),
135
135
  weights: weights && Object.fromEntries(index.entries.map((entry, at) => [entry.column, weights[at]])),
136
+ default_language: index.type === 'fulltext' ? textLanguage(fulltextConfig(index)) : undefined,
136
137
  },
137
138
  });
138
139
  }
@@ -1,3 +1,4 @@
1
+ import { textConfigOf } from '../../mongo/mongoDialect.js';
1
2
  import { createTableNode, SchemaAST } from '../../schema/schemaAST.js';
2
3
  import { isMongoQuerier, } from '../../type/index.js';
3
4
  /** What a server without Atlas Search answers a search index command with. */
@@ -9,7 +10,7 @@ const SEARCH_NOT_ENABLED = 31082;
9
10
  export class MongoSchemaIntrospector {
10
11
  pool;
11
12
  /** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
12
- indexFacets = new Set(['textWeights']);
13
+ indexFacets = new Set(['textIndex']);
13
14
  constructor(pool) {
14
15
  this.pool = pool;
15
16
  }
@@ -38,11 +39,15 @@ export class MongoSchemaIntrospector {
38
39
  name: tableName,
39
40
  columns: [],
40
41
  indexes: [
41
- ...indexes.map(({ name, key, unique, weights }) => ({
42
+ ...indexes.map(({ name, key, unique, weights, default_language }) => ({
42
43
  name: name ?? Object.keys(key).join('_'),
43
44
  unique: !!unique,
44
45
  ...(weights
45
- ? { entries: Object.entries(weights).map(textIndexEntry), type: 'fulltext' }
46
+ ? {
47
+ entries: Object.entries(weights).map(textIndexEntry),
48
+ type: 'fulltext',
49
+ config: default_language && textConfigOf(default_language),
50
+ }
46
51
  : { entries: Object.keys(key).map((column) => ({ column })) }),
47
52
  })),
48
53
  ...searchIndexes
@@ -7,14 +7,25 @@ type MongoReadStages = {
7
7
  /** Ordering, which runs after the lookups when it reads one of their fields. */
8
8
  readonly sort?: Sort;
9
9
  readonly pager?: MongoAggregationPipelineEntry<Document>[];
10
- /** Keys merged into the query's projection, when it has one: a vector search's score. */
11
- readonly project?: Record<string, 1>;
10
+ /** A score the read answers as a field, a vector search's or a text search's; a temporary one leaves again. */
11
+ readonly score?: {
12
+ readonly field: string;
13
+ readonly meta: 'vectorSearchScore' | 'textScore';
14
+ readonly temporary?: boolean;
15
+ };
12
16
  };
13
17
  /** Accumulator threaded through `$where` rendering: the relation lookups it needs, and their temp fields. */
14
18
  type RelationLookups = {
15
19
  readonly stages: MongoAggregationPipelineEntry<Document>[];
16
20
  readonly temps: string[];
17
21
  };
22
+ /**
23
+ * A text-search config as MongoDB names the language: the same word for each language both know, and
24
+ * `'none'` for the no-stemming parser Postgres calls `'simple'`. {@link textConfigOf} reads one back.
25
+ */
26
+ export declare function textLanguage(config: string): string;
27
+ /** A MongoDB language as the text-search config it is, the inverse of {@link textLanguage}. */
28
+ export declare function textConfigOf(language: string): string;
18
29
  /** Default {@link DialectFeatures} for MongoDB. */
19
30
  export declare const mongoDialectFeatures: DialectFeatures;
20
31
  export declare class MongoDialect extends AbstractDialect {
@@ -220,6 +231,8 @@ export declare class MongoDialect extends AbstractDialect {
220
231
  normalizeIds<E extends Document>(meta: EntityMeta<E>, docs: Document[]): E[];
221
232
  /** `doc` is the wire shape - `_id`, stored names, `ObjectId`s - and what comes back is the code's. */
222
233
  normalizeId<E extends Document>(meta: EntityMeta<E>, doc: Document | undefined): E | undefined;
234
+ /** An aggregate's rows with each 64-bit integer decoded as a document's is: a `bigint` only where the column reads a `BigInt` field. */
235
+ normalizeAggregateRows<E extends Document, const G extends QueryGroupMap<E>, const A extends QueryAggMap<E>, R extends Document>(entity: Type<E>, q: QueryAggregate<E, G, A>, rows: R[]): R[];
223
236
  /**
224
237
  * A key as MongoDB stores it: a 24-hex string as an `ObjectId`, so a write matches the filter looking for
225
238
  * it, and anything else as given. Only 24-hex, not any 12-byte string. Arrays convert element-wise.
@@ -1,13 +1,25 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
3
  import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortCountField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
- import { groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
4
+ import { aggregateColumnField, groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
5
5
  import { assertSoleId, fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
6
6
  import { COUNT_RESULT_KEY } from '../type/query.js';
7
7
  import { QueryRaw } from '../type/queryRaw.js';
8
- import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, rankedTextSearch, someKey, targetKeyColumns, } from '../util/index.js';
8
+ import { aggregateOf, asSelectMap, assertAggregateColumns, assertNonNegativeInteger, columnFamily, countedRelations, entityName, fieldUpdateOf, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, rankedTextSearch, someKey, targetKeyColumns, textSortOf, } from '../util/index.js';
9
+ import { decodeBigIntsExcept } from '../util/wideNumber.js';
9
10
  /** A scalar field's operator as the aggregation operator computing it. */
10
11
  const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
12
+ /**
13
+ * A text-search config as MongoDB names the language: the same word for each language both know, and
14
+ * `'none'` for the no-stemming parser Postgres calls `'simple'`. {@link textConfigOf} reads one back.
15
+ */
16
+ export function textLanguage(config) {
17
+ return config === 'simple' ? 'none' : config;
18
+ }
19
+ /** A MongoDB language as the text-search config it is, the inverse of {@link textLanguage}. */
20
+ export function textConfigOf(language) {
21
+ return language === 'none' ? 'simple' : language;
22
+ }
11
23
  /** Default {@link DialectFeatures} for MongoDB. */
12
24
  export const mongoDialectFeatures = {
13
25
  ifNotExists: false,
@@ -99,7 +111,8 @@ export class MongoDialect extends AbstractDialect {
99
111
  else if (key === '$text') {
100
112
  // MongoDB's text index declares which fields it covers, so `$fields` cannot narrow the search
101
113
  // the way it does elsewhere - the same shape as `$distance` being index-defined here.
102
- filter['$text'] = { $search: val.$value };
114
+ const { $value, $config } = val;
115
+ filter['$text'] = { $search: $value, ...($config && { $language: textLanguage($config) }) };
103
116
  }
104
117
  else if (meta.relations[key]) {
105
118
  this.assertNoRaw(val);
@@ -477,7 +490,8 @@ export class MongoDialect extends AbstractDialect {
477
490
  if (path) {
478
491
  throw new TypeError(`$sort by $text is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
479
492
  }
480
- out[TEXT_SCORE_ALIAS] = { $meta: 'textScore' };
493
+ const { order, project } = textSortOf(sort);
494
+ out[project ?? TEXT_SCORE_ALIAS] = sortDirection(order);
481
495
  continue;
482
496
  }
483
497
  if (!relation) {
@@ -564,10 +578,10 @@ export class MongoDialect extends AbstractDialect {
564
578
  */
565
579
  aggregateStages(meta, spec, temp, field) {
566
580
  const relOpts = relationOf(meta, spec.relation);
567
- const query = spec.query ?? {};
581
+ const page = spec.page ?? {};
568
582
  const tail = [
569
- ...(query.$sort ? [{ $sort: this.sort(relOpts.entity(), query) }] : []),
570
- ...this.pagerStages(query),
583
+ ...(page.$sort ? [{ $sort: this.sort(relOpts.entity(), page) }] : []),
584
+ ...this.pagerStages(page),
571
585
  spec.field
572
586
  ? {
573
587
  $group: {
@@ -578,7 +592,7 @@ export class MongoDialect extends AbstractDialect {
578
592
  : { $count: AGGREGATE_VALUE_ALIAS },
579
593
  ];
580
594
  return [
581
- this.relationLookup(meta, relOpts, query.$where ?? {}, temp, tail),
595
+ this.relationLookup(meta, relOpts, spec.where ?? {}, temp, tail),
582
596
  { $addFields: { [field]: this.tally(temp, spec.op) } },
583
597
  ];
584
598
  }
@@ -605,7 +619,7 @@ export class MongoDialect extends AbstractDialect {
605
619
  for (const { relKey, where } of countedRelations(meta, q.$count)) {
606
620
  const temp = `${REL_TEMP_PREFIX}count_${relKey}`;
607
621
  temps.push(temp);
608
- const spec = { relation: relKey, op: '$count', query: { $where: where } };
622
+ const spec = { relation: relKey, op: '$count', where };
609
623
  stages.push(...this.aggregateStages(meta, spec, temp, `${COUNT_RESULT_KEY}.${relKey}`));
610
624
  }
611
625
  return temps.length ? [...stages, { $unset: temps }] : stages;
@@ -679,11 +693,14 @@ export class MongoDialect extends AbstractDialect {
679
693
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
680
694
  }
681
695
  aggregationPipeline(entity, q, opts) {
696
+ // Sorted as a field, which goes either way where a `$meta` sort only descends.
697
+ const text = textSortOf(q.$sort);
682
698
  return [
683
699
  ...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
684
700
  ...this.readStages(entity, q, {
685
701
  sort: this.sort(entity, q),
686
702
  pager: this.pagerStages(q),
703
+ score: text && { field: text.project ?? TEXT_SCORE_ALIAS, meta: 'textScore', temporary: !text.project },
687
704
  }),
688
705
  ];
689
706
  }
@@ -709,10 +726,17 @@ export class MongoDialect extends AbstractDialect {
709
726
  const related = this.relationReadStages(entity, q);
710
727
  const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
711
728
  const pager = extra.pager ?? [];
712
- // Merged into the query's own projection rather than standing in for one: a query that asked
713
- // for no columns wants the whole document, not just the field this adds to it.
729
+ // The score becomes a real field before anything reads it, so the lookups, the sort and the projection
730
+ // that follow treat it like any other; merged into the query's own projection rather than standing in
731
+ // for one, since a query that asked for no columns wants the whole document as well.
732
+ const { score } = extra;
733
+ if (score && !score.temporary) {
734
+ this.assertProjectable(meta, score.field);
735
+ }
736
+ const scored = score ? [{ $addFields: { [score.field]: { $meta: score.meta } } }] : [];
737
+ const unscored = score?.temporary ? [{ $unset: [score.field] }] : [];
714
738
  const projection = this.pipelineProjection(entity, q);
715
- const projected = projection ? { ...projection, ...extra.project } : undefined;
739
+ const projected = projection && score ? { ...projection, [score.field]: 1 } : projection;
716
740
  const project = projected ? [{ $project: projected }] : [];
717
741
  // A `$lookup` the ordering asked for puts a field on the document the caller never requested,
718
742
  // which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
@@ -734,7 +758,7 @@ export class MongoDialect extends AbstractDialect {
734
758
  // ordering and the page have to run after it to address the set the caller actually receives.
735
759
  const dedup = q.$distinct ? this.distinctStages(projected) : [];
736
760
  if (dedup.length) {
737
- return [...lookups, ...related, ...project, ...dedup, ...sort, ...pager];
761
+ return [...scored, ...lookups, ...related, ...project, ...dedup, ...sort, ...pager, ...unscored];
738
762
  }
739
763
  // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
740
764
  // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
@@ -742,10 +766,12 @@ export class MongoDialect extends AbstractDialect {
742
766
  const lookupsFirst = this.sortsRelations(entity, q.$sort) ||
743
767
  lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
744
768
  return [
769
+ ...scored,
745
770
  ...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
746
771
  ...related,
747
772
  ...unset,
748
773
  ...project,
774
+ ...unscored,
749
775
  ];
750
776
  }
751
777
  /**
@@ -894,6 +920,9 @@ export class MongoDialect extends AbstractDialect {
894
920
  res[key] = this.fromWireId(res[key]);
895
921
  }
896
922
  }
923
+ // A 64-bit integer, which the pool reads as a `bigint`: kept for a `BigInt` field, and elsewhere the
924
+ // number it is where exact and its exact text past 2^53, as every SQL driver decodes one.
925
+ decodeBigIntsExcept(res, (key) => meta.fields[key]?.type === BigInt);
897
926
  const relKeys = getKeys(meta.relations).filter((key) => res[key]);
898
927
  for (const relKey of relKeys) {
899
928
  const relMeta = getMeta(relationOf(meta, relKey).entity());
@@ -903,6 +932,18 @@ export class MongoDialect extends AbstractDialect {
903
932
  }
904
933
  return res;
905
934
  }
935
+ /** An aggregate's rows with each 64-bit integer decoded as a document's is: a `bigint` only where the column reads a `BigInt` field. */
936
+ normalizeAggregateRows(entity, q, rows) {
937
+ const meta = getMeta(entity);
938
+ const { joins } = resolveGroupJoins(meta, q);
939
+ const exact = new Set(parseGroupMap(q.$group, q.$select)
940
+ .filter((entry) => aggregateColumnField(meta, joins, entry)?.field?.type === BigInt)
941
+ .map((entry) => entry.alias));
942
+ for (const row of rows) {
943
+ decodeBigIntsExcept(row, (key) => exact.has(key));
944
+ }
945
+ return rows;
946
+ }
906
947
  /**
907
948
  * A key as MongoDB stores it: a 24-hex string as an `ObjectId`, so a write matches the filter looking for
908
949
  * it, and anything else as given. Only 24-hex, not any 12-byte string. Arrays convert element-wise.
@@ -929,7 +970,7 @@ export class MongoDialect extends AbstractDialect {
929
970
  const unset = new Set();
930
971
  for (const [key, value] of Object.entries(persistable)) {
931
972
  if (isFieldUpdateOp(value)) {
932
- const [op, operand] = fieldUpdateOf(value);
973
+ const [op, operand] = fieldUpdateOf(key, value);
933
974
  arithmetic[key] = { [MONGO_ARITHMETIC[op]]: [{ $ifNull: [`$${key}`, 0] }, { $literal: operand }] };
934
975
  continue;
935
976
  }
@@ -1088,19 +1129,27 @@ export class MongoDialect extends AbstractDialect {
1088
1129
  columns[entry.alias] = `$_id.${entry.alias}`;
1089
1130
  continue;
1090
1131
  }
1091
- named.push(entry.fieldRef);
1092
- const ref = `$${this.columnOf(meta, entry.fieldRef)}`;
1132
+ const { field } = entry;
1133
+ if (field !== undefined) {
1134
+ named.push(field);
1135
+ }
1093
1136
  const test = entry.where && this.whereExpression(meta, entry.where, named);
1094
1137
  // What the accumulator reads from a row its own `$where` passes, and from one it does not.
1095
1138
  const read = (passed, failed) => (test ? { $cond: [test, passed, failed] } : passed);
1096
1139
  columns[entry.alias] = 1;
1140
+ if (field === undefined) {
1141
+ // COUNT(*): every row, as SQL counts one.
1142
+ accumulators[entry.alias] = { $sum: read(1, 0) };
1143
+ continue;
1144
+ }
1145
+ const ref = `$${this.columnOf(meta, field)}`;
1097
1146
  if (entry.distinct) {
1098
1147
  accumulators[entry.alias] = { $addToSet: read(ref, '$$REMOVE') };
1099
1148
  columns[entry.alias] = { $size: `$${entry.alias}` };
1100
1149
  }
1101
1150
  else if (entry.op === '$count') {
1102
- // COUNT(*) counts every row; COUNT(field) counts non-null values, matching SQL.
1103
- accumulators[entry.alias] = { $sum: entry.fieldRef === '*' ? read(1, 0) : read(MongoDialect.countOf(ref), 0) };
1151
+ // COUNT(field) counts the non-null values, as SQL does.
1152
+ accumulators[entry.alias] = { $sum: read(MongoDialect.countOf(ref), 0) };
1104
1153
  }
1105
1154
  else {
1106
1155
  // `$sum`, `$avg`, `$min` and `$max` are MongoDB accumulators of the same name, and skip a null.
@@ -2,7 +2,7 @@ import { AGGREGATE_VALUE_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { fieldOf, getMeta, namesKey, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
- import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, textSortOf, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
7
7
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
8
8
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -77,7 +77,8 @@ export class MongodbQuerier extends AbstractQuerier {
77
77
  populatesRelations(getMeta(entity), q.$populate) ||
78
78
  this.dialect.constrainsRelations(entity, q.$where) ||
79
79
  this.dialect.sortsRelations(entity, q.$sort) ||
80
- this.dialect.readsAggregates(entity, q));
80
+ this.dialect.readsAggregates(entity, q) ||
81
+ textSortOf(q.$sort) !== undefined);
81
82
  }
82
83
  buildScalarProjection(entity, q) {
83
84
  return this.dialect.select(entity, q.$select, q.$exclude);
@@ -114,20 +115,18 @@ export class MongodbQuerier extends AbstractQuerier {
114
115
  const scoreAlias = vectorSort.vectorSearch.$project;
115
116
  return [
116
117
  this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts, vectorCandidates(q)),
117
- // The score becomes a real field before anything reads it, so the lookups and the projection
118
- // that follow treat it like any other - and a query with no projection keeps its own columns.
119
- ...(scoreAlias ? [{ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } }] : []),
120
118
  // `$vectorSearch` has already applied `$limit`, so the pager is its own.
121
119
  ...this.dialect.readStages(entity, q, {
122
120
  sort: this.dialect.sort(entity, { ...q, $sort: vectorSort.regularSort }),
123
- project: scoreAlias ? { [scoreAlias]: 1 } : undefined,
121
+ score: scoreAlias ? { field: scoreAlias, meta: 'vectorSearchScore' } : undefined,
124
122
  }),
125
123
  ];
126
124
  }
127
125
  async internalAggregate(entity, q, opts) {
128
126
  return this.timed('internalAggregate', undefined, async () => {
129
127
  const pipeline = this.dialect.buildAggregateStages(entity, q, opts);
130
- return this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
128
+ const rows = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
129
+ return this.dialect.normalizeAggregateRows(entity, q, rows);
131
130
  });
132
131
  }
133
132
  /**
@@ -7,7 +7,10 @@ export class MongodbQuerierPool extends AbstractQuerierPool {
7
7
  client;
8
8
  constructor(uri, opts, extra) {
9
9
  super(new MongoDialect(dialectOptionsFrom(extra)), extra);
10
- this.client = new MongoClient(uri, opts);
10
+ // A 64-bit integer read as the exact `bigint` it is, where the driver would round it past 2^53 or
11
+ // hand back its own `Long`; each read then decodes it the way every SQL driver does. First, as the
12
+ // MySQL pool's `supportBigNumbers` is, so an explicit choice of the caller's wins.
13
+ this.client = new MongoClient(uri, { useBigInt64: true, ...opts });
11
14
  }
12
15
  async getQuerier() {
13
16
  const conn = await this.client.connect();
@@ -333,13 +333,8 @@ export function canonicalToTypeScript(type) {
333
333
  * the engine settles an unstated bound. Migrations and drift both compare through it.
334
334
  */
335
335
  export function engineType(dialect) {
336
- return (type) => {
337
- const stored = sqlToCanonical(canonicalToSql(type, dialect));
338
- // SQLite keeps a vector in any column, so one created as `TEXT` before vectors were blobs stays as it is.
339
- return dialect.dialectName === 'sqlite' && isVectorCategory(stored.category) ? SQLITE_TEXT : stored;
340
- };
336
+ return (type) => sqlToCanonical(canonicalToSql(type, dialect));
341
337
  }
342
- const SQLITE_TEXT = { category: 'string', size: 'small' };
343
338
  /**
344
339
  * Convert UQL FieldOptions to a canonical type.
345
340
  */
@@ -2,9 +2,9 @@ import type { IndexNode } from './types.js';
2
2
  /**
3
3
  * What an introspector reports about an index, and so all a diff may compare; apart from `IndexFeature`, what an engine emits.
4
4
  * `vector` is whether it is a vector index at all, for an engine with one vector index whatever type declared it.
5
- * `textWeights` is a text index's weights, kept by an engine that lists its fields in no declared order.
5
+ * `textIndex` is a text index's weights and language, kept by an engine that lists its fields in no declared order.
6
6
  */
7
- export type IndexFacet = 'order' | 'nulls' | 'opsClass' | 'accessMethod' | 'include' | 'vector' | 'textWeights';
7
+ export type IndexFacet = 'order' | 'nulls' | 'opsClass' | 'accessMethod' | 'include' | 'vector' | 'textIndex';
8
8
  /**
9
9
  * Whether the table has this index already, by shape rather than name, uniqueness included. An index
10
10
  * over an expression, whose text the engine reprints, falls back to its name.
@@ -1,4 +1,5 @@
1
1
  import { isVectorIndexType } from '../type/vector.js';
2
+ import { fulltextConfig } from '../util/dialect.util.js';
2
3
  /**
3
4
  * Whether the table has this index already, by shape rather than name, uniqueness included. An index
4
5
  * over an expression, whose text the engine reprints, falls back to its name.
@@ -40,6 +41,12 @@ export function describeIndexDifferences(source, target, facets) {
40
41
  differences.push(`columns: (${targetColumns}) -> (${sourceColumns})`);
41
42
  }
42
43
  }
44
+ if (facets.has('textIndex') && source.type === 'fulltext' && target.type === 'fulltext') {
45
+ const [expected, actual] = [fulltextConfig(source), fulltextConfig(target)];
46
+ if (expected !== actual) {
47
+ differences.push(`config: ${actual} -> ${expected}`);
48
+ }
49
+ }
43
50
  if (source.unique !== target.unique) {
44
51
  differences.push(`unique: ${target.unique} -> ${source.unique}`);
45
52
  }
@@ -61,7 +68,7 @@ export function describeIndexDifferences(source, target, facets) {
61
68
  }
62
69
  /** A text index's fields as a set where the engine keeps its weights: MongoDB lists them alphabetically. */
63
70
  function textFieldOrder(index, facets, entries) {
64
- return facets.has('textWeights') && index.type === 'fulltext' ? entries.toSorted() : entries;
71
+ return facets.has('textIndex') && index.type === 'fulltext' ? entries.toSorted() : entries;
65
72
  }
66
73
  function entrySignature(entry, facets) {
67
74
  const parts = [entry.column];
@@ -76,7 +83,7 @@ function entrySignature(entry, facets) {
76
83
  if (facets.has('opsClass') && entry.opsClass) {
77
84
  parts.push(entry.opsClass);
78
85
  }
79
- if (facets.has('textWeights') && (entry.weight ?? 1) !== 1) {
86
+ if (facets.has('textIndex') && (entry.weight ?? 1) !== 1) {
80
87
  parts.push(`weight ${entry.weight}`);
81
88
  }
82
89
  return parts.join(' ');