uql-orm 0.71.0 → 0.72.1

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 (44) hide show
  1. package/README.md +1 -1
  2. package/dist/browser/uql-browser.min.js +2 -2
  3. package/dist/browser/uql-browser.min.js.map +3 -3
  4. package/dist/dialect/abstractSqlDialect.d.ts +15 -2
  5. package/dist/dialect/abstractSqlDialect.js +60 -11
  6. package/dist/dialect/aliases.d.ts +2 -0
  7. package/dist/dialect/aliases.js +2 -0
  8. package/dist/dialect/mysqlLikeSqlDialect.d.ts +5 -0
  9. package/dist/dialect/mysqlLikeSqlDialect.js +9 -1
  10. package/dist/dialect/pgLikeSqlDialect.d.ts +7 -0
  11. package/dist/dialect/pgLikeSqlDialect.js +24 -5
  12. package/dist/dialect/queryJoins.d.ts +0 -1
  13. package/dist/entity/metadata/definition.js +4 -2
  14. package/dist/maria/mariaDialect.js +1 -2
  15. package/dist/migrate/cli.js +2 -1
  16. package/dist/migrate/ddl/indexDdl.d.ts +2 -0
  17. package/dist/migrate/ddl/indexDdl.js +4 -0
  18. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +5 -0
  19. package/dist/migrate/ddl/mysqlIndexDdl.js +7 -0
  20. package/dist/migrate/generator/mongoCommand.d.ts +2 -0
  21. package/dist/migrate/generator/mongoSchemaGenerator.js +3 -0
  22. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  23. package/dist/migrate/introspection/mongoIntrospector.js +12 -6
  24. package/dist/migrate/schemaGenerator.d.ts +4 -1
  25. package/dist/migrate/schemaGenerator.js +14 -7
  26. package/dist/mongo/mongoDialect.d.ts +3 -3
  27. package/dist/mongo/mongoDialect.js +32 -13
  28. package/dist/mongo/mongodbQuerier.js +2 -3
  29. package/dist/mssql/mssqlDialect.js +1 -0
  30. package/dist/schema/indexDifferences.d.ts +2 -1
  31. package/dist/schema/indexDifferences.js +8 -1
  32. package/dist/schema/schemaASTBuilder.d.ts +2 -0
  33. package/dist/schema/schemaASTBuilder.js +23 -0
  34. package/dist/sqlite/sqliteDialect.d.ts +2 -0
  35. package/dist/sqlite/sqliteDialect.js +5 -0
  36. package/dist/type/dialect.d.ts +5 -0
  37. package/dist/type/entity.d.ts +18 -10
  38. package/dist/type/query.d.ts +11 -4
  39. package/dist/type/queryAggregate.d.ts +1 -8
  40. package/dist/type/utility.d.ts +13 -0
  41. package/dist/util/dialect.util.d.ts +29 -1
  42. package/dist/util/dialect.util.js +50 -1
  43. package/package.json +1 -2
  44. package/skills/uql-orm/SKILL.md +6 -2
@@ -50,6 +50,10 @@ export class IndexDdl {
50
50
  `ON ${this.dialect.escapeId(tableName)}${this.indexAccessMethod(index)} (${columns})` +
51
51
  `${this.indexInclude(index)}${this.indexTuning(index)}${this.indexPredicate(index)};`);
52
52
  }
53
+ /** What an index added to a table that has rows needs run after it to serve queries; nothing, mostly. */
54
+ settleStatements(_tableName, _index) {
55
+ return [];
56
+ }
53
57
  /**
54
58
  * Index features this dialect can express. Everything here is supported by at least one engine and
55
59
  * refused by at least one other, so an index asking for a missing one is rejected rather than
@@ -6,6 +6,11 @@ export declare class MysqlLikeIndexDdl extends IndexDdl {
6
6
  protected readonly indexFeatures: Set<"expression" | "include" | "jsonArray" | "jsonPath" | "nullsOrder" | "opsClass" | "partial" | "prefixLength">;
7
7
  protected readonly indexTypes: ReadonlySet<IndexType>;
8
8
  protected readonly indexTypeKeywords: ReadonlyMap<IndexType, string>;
9
+ /**
10
+ * InnoDB fills a fulltext index added beside another on a loaded table only once the table is optimized:
11
+ * until then MariaDB scores it 0 and MySQL can fail a `MATCH` over it (MySQL 26.7, MariaDB 12.3).
12
+ */
13
+ settleStatements(tableName: string, index: IndexSchema): string[];
9
14
  /** ` USING btree|hash` trails the columns: between the table and them, it is a syntax error here. */
10
15
  protected indexTuning(index: IndexSchema): string;
11
16
  }
@@ -11,6 +11,13 @@ export class MysqlLikeIndexDdl extends IndexDdl {
11
11
  indexFeatures = new Set(['expression', 'prefixLength']);
12
12
  indexTypes = new Set(['btree', 'hash', 'fulltext']);
13
13
  indexTypeKeywords = MYSQL_LIKE_INDEX_KEYWORDS;
14
+ /**
15
+ * InnoDB fills a fulltext index added beside another on a loaded table only once the table is optimized:
16
+ * until then MariaDB scores it 0 and MySQL can fail a `MATCH` over it (MySQL 26.7, MariaDB 12.3).
17
+ */
18
+ settleStatements(tableName, index) {
19
+ return index.type === 'fulltext' ? [`OPTIMIZE TABLE ${this.dialect.escapeId(tableName)};`] : [];
20
+ }
14
21
  /** ` USING btree|hash` trails the columns: between the table and them, it is a syntax error here. */
15
22
  indexTuning(index) {
16
23
  return index.type && !this.indexTypeKeywords.has(index.type) ? ` USING ${index.type}` : '';
@@ -5,6 +5,8 @@ export type MongoIndexOptions = {
5
5
  readonly unique: boolean;
6
6
  readonly name: string;
7
7
  readonly partialFilterExpression?: Readonly<Record<string, unknown>>;
8
+ /** A text index's weight per field, which `textScore` multiplies a match in it by. */
9
+ readonly weights?: Readonly<Record<string, number>>;
8
10
  };
9
11
  /** A field of an Atlas vector search index: the vector itself, or one its `filter` pre-filters on. */
10
12
  export type MongoVectorSearchField = {
@@ -3,6 +3,7 @@ import { MongoDialect } 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
7
  import { assertIndexFeatures, assertIndexType } from '../ddl/indexDdl.js';
7
8
  import { assertIndexPredicate, refusedIndexPredicate } from '../indexPredicate.js';
8
9
  import { renderIndexDefinition } from './definitionToNode.js';
@@ -121,6 +122,7 @@ export class MongoSchemaGenerator extends MongoDialect {
121
122
  for (const entry of index.entries) {
122
123
  key[entry.column] = index.type === 'fulltext' ? 'text' : entry.order === 'desc' ? -1 : 1;
123
124
  }
125
+ const weights = fulltextWeights(index);
124
126
  return serializeMongoCommand({
125
127
  action: 'createIndex',
126
128
  collection: tableName,
@@ -130,6 +132,7 @@ export class MongoSchemaGenerator extends MongoDialect {
130
132
  unique: index.unique,
131
133
  name: index.name,
132
134
  partialFilterExpression: index.where && JSON.parse(index.where),
135
+ weights: weights && Object.fromEntries(index.entries.map((entry, at) => [entry.column, weights[at]])),
133
136
  },
134
137
  });
135
138
  }
@@ -7,7 +7,7 @@ import { type QuerierPool, type SchemaIntrospector, type TableSchema } from '../
7
7
  */
8
8
  export declare class MongoSchemaIntrospector implements SchemaIntrospector {
9
9
  private readonly pool;
10
- /** `listIndexes` reports keys and uniqueness; a `partialFilterExpression` is no SQL predicate. */
10
+ /** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
11
11
  readonly indexFacets: ReadonlySet<IndexFacet>;
12
12
  constructor(pool: QuerierPool);
13
13
  introspect(tables?: readonly string[]): Promise<SchemaAST>;
@@ -8,8 +8,8 @@ const SEARCH_NOT_ENABLED = 31082;
8
8
  */
9
9
  export class MongoSchemaIntrospector {
10
10
  pool;
11
- /** `listIndexes` reports keys and uniqueness; a `partialFilterExpression` is no SQL predicate. */
12
- indexFacets = new Set();
11
+ /** `listIndexes` reports keys, uniqueness and text weights; a `partialFilterExpression` is no SQL predicate. */
12
+ indexFacets = new Set(['textWeights']);
13
13
  constructor(pool) {
14
14
  this.pool = pool;
15
15
  }
@@ -38,10 +38,12 @@ export class MongoSchemaIntrospector {
38
38
  name: tableName,
39
39
  columns: [],
40
40
  indexes: [
41
- ...indexes.map((idx) => ({
42
- name: idx.name ?? Object.keys(idx.key).join('_'),
43
- entries: Object.keys(idx.key).map((column) => ({ column })),
44
- unique: !!idx.unique,
41
+ ...indexes.map(({ name, key, unique, weights }) => ({
42
+ name: name ?? Object.keys(key).join('_'),
43
+ unique: !!unique,
44
+ ...(weights
45
+ ? { entries: Object.entries(weights).map(textIndexEntry), type: 'fulltext' }
46
+ : { entries: Object.keys(key).map((column) => ({ column })) }),
45
47
  })),
46
48
  ...searchIndexes
47
49
  .filter((idx) => idx.type === 'vectorSearch')
@@ -75,6 +77,10 @@ export class MongoSchemaIntrospector {
75
77
  });
76
78
  }
77
79
  }
80
+ /** A text index field as an entity declares it: its weight stated only where it is not the default 1. */
81
+ function textIndexEntry([column, weight]) {
82
+ return weight === 1 ? { column } : { column, weight };
83
+ }
78
84
  /** A collection's Atlas search indexes, none where the server has no Atlas Search. */
79
85
  async function listSearchIndexes(collection) {
80
86
  try {
@@ -1,5 +1,6 @@
1
1
  import type { AbstractSqlDialect } from '../dialect/index.js';
2
2
  import type { SchemaAST } from '../schema/schemaAST.js';
3
+ import { type BuildSchemaASTOptions } from '../schema/schemaASTBuilder.js';
3
4
  import { type DiffOptions } from '../schema/schemaASTDiffer.js';
4
5
  import type { CanonicalType, ColumnNode, ForeignKeyAction, IndexNode, TableNode } from '../schema/types.js';
5
6
  import type { ColumnSchema, CreateSchemaOptions, DialectFeatures, DropSchemaOptions, EntityMeta, EntityWhereMeta, FieldMeta, FieldOptions, ForeignKeySchema, IndexSchema, NamingStrategy, SchemaDiff, SchemaGenerator, Type } from '../type/index.js';
@@ -65,6 +66,8 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
65
66
  generateCreateIndex(tableName: string, index: IndexSchema, options?: {
66
67
  ifNotExists?: boolean;
67
68
  }): string;
69
+ /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
70
+ private addIndexStatements;
68
71
  /**
69
72
  * `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
70
73
  * operand instead, which is already qualified.
@@ -175,4 +178,4 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
175
178
  * The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
176
179
  * also rename an explicit `@Entity({ name })` and so compare each table under another name.
177
180
  */
178
- export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[], defaultForeignKeyAction?: ForeignKeyAction): SchemaAST;
181
+ export declare function buildEntityAST(generator: Pick<SchemaGenerator, 'resolveTableAlias' | 'resolveSchema' | 'resolveColumnName' | 'compileDdl' | 'compileIndexPredicate'>, entities: readonly Type<object>[], options?: Pick<BuildSchemaASTOptions, 'defaultForeignKeyAction' | 'textScoreIndexes'>): SchemaAST;
@@ -74,7 +74,10 @@ export class SqlSchemaGenerator {
74
74
  }
75
75
  /** The entity side as an AST, carrying this generator's default referential action. */
76
76
  buildAST(entities) {
77
- return buildEntityAST(this, entities, this.defaultForeignKeyAction);
77
+ return buildEntityAST(this, entities, {
78
+ defaultForeignKeyAction: this.defaultForeignKeyAction,
79
+ textScoreIndexes: this.dialect.features.textScoreIndexes,
80
+ });
78
81
  }
79
82
  /**
80
83
  * Every `CREATE TABLE` for `entities`, then their foreign keys, since a relation graph is routinely
@@ -115,7 +118,7 @@ export class SqlSchemaGenerator {
115
118
  * resolves instead of being silently dropped.
116
119
  */
117
120
  orderedTables(entities, direction, only) {
118
- const ast = buildEntityAST(this, entities, this.defaultForeignKeyAction);
121
+ const ast = this.buildAST(entities);
119
122
  const tables = direction === 'create' ? ast.getCreateOrder() : ast.getDropOrder();
120
123
  if (!only) {
121
124
  return tables;
@@ -166,7 +169,7 @@ export class SqlSchemaGenerator {
166
169
  // Add indexes
167
170
  if (diff.indexesToAdd?.length) {
168
171
  for (const index of diff.indexesToAdd) {
169
- statements.push(this.generateCreateIndex(diff.tableName, index));
172
+ statements.push(...this.addIndexStatements(diff.tableName, index));
170
173
  }
171
174
  }
172
175
  // Drop indexes
@@ -243,6 +246,10 @@ export class SqlSchemaGenerator {
243
246
  generateCreateIndex(tableName, index, options = {}) {
244
247
  return this.indexDdl.getCreateIndexStatement(tableName, index, options);
245
248
  }
249
+ /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
250
+ addIndexStatements(tableName, index) {
251
+ return [this.generateCreateIndex(tableName, index), ...this.indexDdl.settleStatements(tableName, index)];
252
+ }
246
253
  /**
247
254
  * `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
248
255
  * operand instead, which is already qualified.
@@ -338,7 +345,7 @@ export class SqlSchemaGenerator {
338
345
  }
339
346
  // Keyed by the qualified name this generator resolves, which is the key the AST stores the table
340
347
  // under.
341
- const desired = (desiredAst ?? buildEntityAST(this, [entity], this.defaultForeignKeyAction)).getTable(tableName);
348
+ const desired = (desiredAst ?? this.buildAST([entity])).getTable(tableName);
342
349
  if (!desired) {
343
350
  return undefined;
344
351
  }
@@ -556,7 +563,7 @@ export class SqlSchemaGenerator {
556
563
  case 'alterColumn':
557
564
  return this.generateAlterColumnSql(operation.tableName, operation.columnName, operation.changes);
558
565
  case 'createIndex':
559
- return [this.generateCreateIndexFromDefinition(operation.tableName, operation.index)];
566
+ return this.addIndexStatements(operation.tableName, renderIndexDefinition(operation.index, (sql) => this.dialect.compileDdl(sql)));
560
567
  case 'dropIndex':
561
568
  return [this.generateDropIndex(operation.tableName, operation.indexName)];
562
569
  case 'addForeignKey':
@@ -693,7 +700,7 @@ function foreignKeyOf(relation) {
693
700
  * The entities as an AST, named by `generator`'s resolvers rather than a naming strategy, which would
694
701
  * also rename an explicit `@Entity({ name })` and so compare each table under another name.
695
702
  */
696
- export function buildEntityAST(generator, entities, defaultForeignKeyAction) {
703
+ export function buildEntityAST(generator, entities, options = {}) {
697
704
  return buildSchemaAST(entities, {
698
705
  // The alias, not `resolveTableName`: a node holds its schema separately, so that a name derived
699
706
  // from it stays a single identifier.
@@ -702,6 +709,6 @@ export function buildEntityAST(generator, entities, defaultForeignKeyAction) {
702
709
  resolveColumnName: (key, field) => generator.resolveColumnName(key, field),
703
710
  compileDdl: (sql, entity) => generator.compileDdl(sql, entity),
704
711
  compileIndexPredicate: (where, entity, indexName) => generator.compileIndexPredicate(where, entity, indexName),
705
- defaultForeignKeyAction,
712
+ ...options,
706
713
  });
707
714
  }
@@ -1,6 +1,6 @@
1
1
  import { type Document, type Filter, type Sort, type UpdateFilter } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import type { DialectFeatures, EntityData, EntityMeta, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPager, QueryPopulate, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
3
+ import type { DialectFeatures, EntityData, EntityMeta, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPager, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
4
  import { type CallbackKey } from '../util/index.js';
5
5
  /** What a read pipeline contributes to {@link MongoDialect.readStages} beyond the query itself. */
6
6
  type MongoReadStages = {
@@ -122,7 +122,7 @@ export declare class MongoDialect extends AbstractDialect {
122
122
  * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
123
123
  * added for the sort alone would change what the caller gets back.
124
124
  */
125
- sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
125
+ sort<E extends Document>(entity: Type<E>, { $sort: sort, $populate: populate, $where: where }: Query<E>): Sort;
126
126
  /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
127
127
  private collectSort;
128
128
  /**
@@ -237,7 +237,7 @@ export declare class MongoDialect extends AbstractDialect {
237
237
  */
238
238
  getUpdateFilter<E extends Document>(persistable: Partial<E>): UpdateFilter<E> | Document[];
239
239
  /**
240
- * A JSON update as one pipeline, since MongoDB refuses two operators on one path: each path composed as
240
+ * An update as one pipeline, since MongoDB refuses two operators on one path: each path composed as
241
241
  * `$pull`, `$set`, `$push`, then `$unset`, as SQL does, with values as `$literal` so `$x` stays data.
242
242
  */
243
243
  private getUpdatePipeline;
@@ -1,11 +1,13 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortCountField, } from '../dialect/aliases.js';
3
+ import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortCountField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
4
  import { 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, fillOnFields, filterFieldKeys, findVectorIndex, findVectorSort, getKeys, getRelationRequestSummary, hasKeys, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorObject, isRecord, isVectorSearch, normalizeScalarFieldSelection, parentJoins, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, 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, } from '../util/index.js';
9
+ /** A scalar field's operator as the aggregation operator computing it. */
10
+ const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
9
11
  /** Default {@link DialectFeatures} for MongoDB. */
10
12
  export const mongoDialectFeatures = {
11
13
  ifNotExists: false,
@@ -454,9 +456,13 @@ export class MongoDialect extends AbstractDialect {
454
456
  * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
455
457
  * added for the sort alone would change what the caller gets back.
456
458
  */
457
- sort(entity, sort, populate) {
459
+ sort(entity, { $sort: sort, $populate: populate, $where: where }) {
458
460
  const meta = getMeta(entity);
459
461
  const normalized = {};
462
+ // Refused as the SQL dialects refuse it, before MongoDB answers a missing score with its own error.
463
+ if (sort?.$text) {
464
+ rankedTextSearch(where);
465
+ }
460
466
  // The same join set the lookups are built from, so what an ordering may address and what the
461
467
  // pipeline actually produces cannot drift apart - `$sort` contributes its own to-one joins here
462
468
  // exactly as it does on the SQL dialects.
@@ -467,6 +473,13 @@ export class MongoDialect extends AbstractDialect {
467
473
  collectSort(meta, sort, joins, path, out) {
468
474
  for (const [key, value] of Object.entries(sort ?? {})) {
469
475
  const relation = meta.relations[key];
476
+ if (key === '$text') {
477
+ if (path) {
478
+ throw new TypeError(`$sort by $text is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
479
+ }
480
+ out[TEXT_SCORE_ALIAS] = { $meta: 'textScore' };
481
+ continue;
482
+ }
470
483
  if (!relation) {
471
484
  // The queried entity's own vector search is lifted out before this walk, so one reaching it
472
485
  // sits under a relation, which a `$lookup` brings in one row at a time - there is nothing to
@@ -553,7 +566,7 @@ export class MongoDialect extends AbstractDialect {
553
566
  const relOpts = relationOf(meta, spec.relation);
554
567
  const query = spec.query ?? {};
555
568
  const tail = [
556
- ...(query.$sort ? [{ $sort: this.sort(relOpts.entity(), query.$sort) }] : []),
569
+ ...(query.$sort ? [{ $sort: this.sort(relOpts.entity(), query) }] : []),
557
570
  ...this.pagerStages(query),
558
571
  spec.field
559
572
  ? {
@@ -669,7 +682,7 @@ export class MongoDialect extends AbstractDialect {
669
682
  return [
670
683
  ...this.matchStages(entity, q.$where, opts, this.aggregateKeys(entity, q)),
671
684
  ...this.readStages(entity, q, {
672
- sort: this.sort(entity, q.$sort, q.$populate),
685
+ sort: this.sort(entity, q),
673
686
  pager: this.pagerStages(q),
674
687
  }),
675
688
  ];
@@ -912,8 +925,14 @@ export class MongoDialect extends AbstractDialect {
912
925
  const set = {};
913
926
  const push = {};
914
927
  const pull = {};
928
+ const arithmetic = {};
915
929
  const unset = new Set();
916
930
  for (const [key, value] of Object.entries(persistable)) {
931
+ if (isFieldUpdateOp(value)) {
932
+ const [op, operand] = fieldUpdateOf(value);
933
+ arithmetic[key] = { [MONGO_ARITHMETIC[op]]: [{ $ifNull: [`$${key}`, 0] }, { $literal: operand }] };
934
+ continue;
935
+ }
917
936
  if (!isJsonUpdateOp(value)) {
918
937
  set[key] = value;
919
938
  continue;
@@ -931,7 +950,7 @@ export class MongoDialect extends AbstractDialect {
931
950
  pull[`${key}.${path}`] = v;
932
951
  }
933
952
  }
934
- return { set, push, pull, unset };
953
+ return { set, push, pull, arithmetic, unset };
935
954
  }
936
955
  /**
937
956
  * Turn a persistable payload into a MongoDB update, mapping UQL's JSON operators onto their native
@@ -940,12 +959,12 @@ export class MongoDialect extends AbstractDialect {
940
959
  */
941
960
  getUpdateFilter(persistable) {
942
961
  const groups = this.groupUpdateOperators(persistable);
943
- const { set, push, pull, unset } = groups;
962
+ const { set, push, pull, arithmetic, unset } = groups;
944
963
  const exprKeys = [...Object.keys(pull), ...Object.keys(set), ...Object.keys(push)];
945
- // Native `$pull` fails on a value that is no array, and MongoDB rejects two operators targeting one
946
- // path in a single update document: either forces the pipeline form.
964
+ // Native `$pull` fails on a value that is no array, native `$inc`/`$mul` on a `null`, and MongoDB
965
+ // rejects two operators targeting one path in a single update document: each forces the pipeline form.
947
966
  const allPaths = [...exprKeys, ...unset];
948
- if (hasKeys(pull) || new Set(allPaths).size < allPaths.length) {
967
+ if (hasKeys(pull) || hasKeys(arithmetic) || new Set(allPaths).size < allPaths.length) {
949
968
  return this.getUpdatePipeline(groups, new Set(exprKeys));
950
969
  }
951
970
  return {
@@ -955,11 +974,11 @@ export class MongoDialect extends AbstractDialect {
955
974
  };
956
975
  }
957
976
  /**
958
- * A JSON update as one pipeline, since MongoDB refuses two operators on one path: each path composed as
977
+ * An update as one pipeline, since MongoDB refuses two operators on one path: each path composed as
959
978
  * `$pull`, `$set`, `$push`, then `$unset`, as SQL does, with values as `$literal` so `$x` stays data.
960
979
  */
961
- getUpdatePipeline({ set, push, pull, unset }, exprPaths) {
962
- const assignments = {};
980
+ getUpdatePipeline({ set, push, pull, arithmetic, unset }, exprPaths) {
981
+ const assignments = { ...arithmetic };
963
982
  for (const path of exprPaths) {
964
983
  let expr = { $ifNull: [`$${path}`, []] };
965
984
  if (path in pull) {
@@ -93,7 +93,7 @@ export class MongodbQuerier extends AbstractQuerier {
93
93
  if (hasKeys(select)) {
94
94
  cursor.project(select);
95
95
  }
96
- const sort = this.dialect.sort(entity, q.$sort, q.$populate);
96
+ const sort = this.dialect.sort(entity, q);
97
97
  if (hasKeys(sort)) {
98
98
  cursor.sort(sort);
99
99
  }
@@ -119,7 +119,7 @@ export class MongodbQuerier extends AbstractQuerier {
119
119
  ...(scoreAlias ? [{ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } }] : []),
120
120
  // `$vectorSearch` has already applied `$limit`, so the pager is its own.
121
121
  ...this.dialect.readStages(entity, q, {
122
- sort: this.dialect.sort(entity, vectorSort.regularSort, q.$populate),
122
+ sort: this.dialect.sort(entity, { ...q, $sort: vectorSort.regularSort }),
123
123
  project: scoreAlias ? { [scoreAlias]: 1 } : undefined,
124
124
  }),
125
125
  ];
@@ -189,7 +189,6 @@ export class MongodbQuerier extends AbstractQuerier {
189
189
  return this.timed('internalUpdateMany', undefined, async () => {
190
190
  const persistable = this.dialect.getPersistable(getMeta(entity), payload, 'onUpdate');
191
191
  const filter = this.dialect.where(entity, qm.$where, opts);
192
- // Maps JSON operators ($set/$unset/$push/$pull) onto their native MongoDB equivalents.
193
192
  const update = this.dialect.getUpdateFilter(persistable);
194
193
  const { matchedCount } = await this.execute((session) => this.collection(entity).updateMany(filter, update, { session }));
195
194
  return matchedCount;
@@ -34,6 +34,7 @@ const MSSQL_FEATURES = {
34
34
  rowLocks: true,
35
35
  rowLockWithWindow: true,
36
36
  rowLockOf: true,
37
+ textScoreIndexes: false,
37
38
  orderedUpsertReturning: false,
38
39
  orderedJsonAggregates: true,
39
40
  narrowVectorTypes: false,
@@ -2,8 +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
6
  */
6
- export type IndexFacet = 'order' | 'nulls' | 'opsClass' | 'accessMethod' | 'include' | 'vector';
7
+ export type IndexFacet = 'order' | 'nulls' | 'opsClass' | 'accessMethod' | 'include' | 'vector' | 'textWeights';
7
8
  /**
8
9
  * Whether the table has this index already, by shape rather than name, uniqueness included. An index
9
10
  * over an expression, whose text the engine reprints, falls back to its name.
@@ -35,7 +35,7 @@ export function describeIndexDifferences(source, target, facets) {
35
35
  const differences = [];
36
36
  const comparableEntries = ![...source.entries, ...target.entries].some((entry) => entry.expression || entry.jsonPath || entry.jsonArray);
37
37
  if (comparableEntries) {
38
- const [sourceColumns, targetColumns] = [source.entries, target.entries].map((entries) => entries.map((entry) => entrySignature(entry, facets)).join(', '));
38
+ const [sourceColumns, targetColumns] = [source, target].map((index) => textFieldOrder(index, facets, index.entries.map((entry) => entrySignature(entry, facets))).join(', '));
39
39
  if (sourceColumns !== targetColumns) {
40
40
  differences.push(`columns: (${targetColumns}) -> (${sourceColumns})`);
41
41
  }
@@ -59,6 +59,10 @@ export function describeIndexDifferences(source, target, facets) {
59
59
  }
60
60
  return differences;
61
61
  }
62
+ /** A text index's fields as a set where the engine keeps its weights: MongoDB lists them alphabetically. */
63
+ function textFieldOrder(index, facets, entries) {
64
+ return facets.has('textWeights') && index.type === 'fulltext' ? entries.toSorted() : entries;
65
+ }
62
66
  function entrySignature(entry, facets) {
63
67
  const parts = [entry.column];
64
68
  if (facets.has('order')) {
@@ -72,5 +76,8 @@ function entrySignature(entry, facets) {
72
76
  if (facets.has('opsClass') && entry.opsClass) {
73
77
  parts.push(entry.opsClass);
74
78
  }
79
+ if (facets.has('textWeights') && (entry.weight ?? 1) !== 1) {
80
+ parts.push(`weight ${entry.weight}`);
81
+ }
75
82
  return parts.join(' ');
76
83
  }
@@ -24,6 +24,8 @@ export interface BuildSchemaASTOptions {
24
24
  compileDdl?: (sql: EntityWhereMeta<object>, entity: Type<object>) => string;
25
25
  /** A partial index's predicate as the engine writes it, `compileDdl` where none is given. `buildEntityAST` supplies it. */
26
26
  compileIndexPredicate?: (where: EntityWhereMeta<object>, entity: Type<object>, indexName: string) => string;
27
+ /** Whether a weighted fulltext index declares one of its own for each heavier column, as MySQL scores through one. */
28
+ textScoreIndexes?: boolean;
27
29
  }
28
30
  /**
29
31
  * Build a SchemaAST from entity classes (decorated with `@Entity`, `@Field`, etc.).
@@ -1,5 +1,6 @@
1
1
  import { fieldOf, foreignKeysOf, getMeta, soleIdOf } from '../entity/metadata/definition.js';
2
2
  import { declaredIndexes, declaredIndexName, renderIndexColumn } from '../util/ddlExpression.util.js';
3
+ import { fulltextWeights, textWeightSteps } from '../util/dialect.util.js';
3
4
  import { isInlinedExpression } from '../util/field.util.js';
4
5
  import { isSoleIdField } from '../util/field.util.js';
5
6
  import { isAutoIncrement } from '../util/field.util.js';
@@ -26,6 +27,7 @@ export function buildSchemaAST(entities, options = {}) {
26
27
  defaultForeignKeyAction: options.defaultForeignKeyAction ?? DEFAULT_FOREIGN_KEY_ACTION,
27
28
  compileDdl,
28
29
  compileIndexPredicate: options.compileIndexPredicate ?? compileDdl,
30
+ textScoreIndexes: options.textScoreIndexes ?? false,
29
31
  };
30
32
  for (const pass of [addTableFromEntity, addRelationshipsFromEntity, addIndexesFromEntity]) {
31
33
  for (const entity of entities) {
@@ -213,4 +215,25 @@ function addCompositeIndex(ctx, table, meta, idxMeta) {
213
215
  lists: idxMeta.lists,
214
216
  config: idxMeta.config,
215
217
  });
218
+ if (ctx.textScoreIndexes) {
219
+ addTextScoreIndexes(ctx, table, idxMeta.type, resolved);
220
+ }
221
+ }
222
+ /** A fulltext index of its own for each column heavier than the index's lightest, which its score reads. */
223
+ function addTextScoreIndexes(ctx, table, type, entries) {
224
+ const weights = fulltextWeights({ type, entries });
225
+ if (!weights)
226
+ return;
227
+ const { extra } = textWeightSteps(weights);
228
+ entries.forEach((entry, at) => {
229
+ if (extra[at] && typeof entry.column === 'string') {
230
+ ctx.ast.addIndex({
231
+ name: derivedIndexName(table.name, [entry.column, 'score']),
232
+ table,
233
+ entries: [{ column: entry.column }],
234
+ unique: false,
235
+ type: 'fulltext',
236
+ });
237
+ }
238
+ });
216
239
  }
@@ -67,6 +67,8 @@ export declare class SqliteDialect extends AbstractSqlDialect {
67
67
  * FTS5 virtual table (UQL does not create those; declare it outside your entities).
68
68
  */
69
69
  protected appendTextSearch<E>(ctx: QueryContext, entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
70
+ /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
71
+ protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>): void;
70
72
  protected jsonLength(slot: JsonSlot): string;
71
73
  /** `JSON_EACH` walks the array at the path itself, each element's `fullkey` naming it from the column. */
72
74
  protected jsonElemFrom(slot: JsonSlot, alias: string): string;
@@ -27,6 +27,7 @@ export const SQLITE_FEATURES = {
27
27
  rowLocks: false,
28
28
  rowLockWithWindow: true,
29
29
  rowLockOf: true,
30
+ textScoreIndexes: false,
30
31
  orderedUpsertReturning: true,
31
32
  orderedJsonAggregates: true,
32
33
  narrowVectorTypes: false,
@@ -163,6 +164,10 @@ export class SqliteDialect extends AbstractSqlDialect {
163
164
  ctx.append(`${this.escapedTableName(meta)} MATCH {${columns.join(' ')}} : `);
164
165
  ctx.addValue(search.$value);
165
166
  }
167
+ /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
168
+ appendTextScore(ctx, meta) {
169
+ ctx.append(`-BM25(${this.escapedTableName(meta)})`);
170
+ }
166
171
  jsonLength(slot) {
167
172
  return `JSON_ARRAY_LENGTH(${jsonArraySlotArgs(slot, this.jsonIsArray(slot))})`;
168
173
  }
@@ -142,6 +142,11 @@ export interface SqlDialectFeatures extends DialectFeatures {
142
142
  readonly rowLockWithWindow: boolean;
143
143
  /** Whether a lock can be narrowed to one table of a join, `FOR UPDATE OF`, which MariaDB lacks. */
144
144
  readonly rowLockOf: boolean;
145
+ /**
146
+ * Whether a fulltext index's heavier column needs a fulltext index of its own to be scored by, as
147
+ * MySQL's `MATCH` does, which reads only an index over exactly its columns.
148
+ */
149
+ readonly textScoreIndexes: boolean;
145
150
  /** Whether a multi-row upsert's `RETURNING` lists its rows in payload order; where not, the ids are read back. */
146
151
  readonly orderedUpsertReturning: boolean;
147
152
  /** Whether a JSON aggregate takes an `ORDER BY` of its own; where not, a relation's rows keep their derived table's order. */
@@ -2,7 +2,7 @@ import type { EnumValues, ForeignKeyAction, IndexType } from '../schema/types.js
2
2
  import type { FilterOptions, RelationQuery } from './query.js';
3
3
  import type { ColumnRef, QueryRaw, RelationAggregate } from './queryRaw.js';
4
4
  import type { QueryWhere } from './queryWhere.js';
5
- import type { Except, IsMany, Json, Scalar, Type, Unpacked, Writable } from './utility.js';
5
+ import type { Except, ExactlyOne, IsEqual, IsMany, Json, Scalar, Type, Unpacked, Writable } from './utility.js';
6
6
  import type { VectorDistance, VectorIndexOptions, VectorIndexType } from './vector.js';
7
7
  /** Brands the property an entity is identified by, where it is not `id`, `_id` or `uuid`. */
8
8
  export declare const idKey: unique symbol;
@@ -20,19 +20,13 @@ export type Key<E> = keyof E & string;
20
20
  export type FieldKey<E> = {
21
21
  readonly [K in keyof E]-?: [NonNullable<E[K]>] extends [Scalar | readonly Scalar[] | Json | readonly Json[]] ? K : never;
22
22
  }[Key<E>];
23
- /**
24
- * Whether `A` and `B` are the same type, `readonly` included - which no conditional sees, since
25
- * assignability ignores the modifier. Two identical generic signatures compare equal only when their
26
- * deferred bodies do.
27
- */
28
- type IfEquals<A, B, Yes, No> = (<T>() => T extends A ? 1 : 2) extends <T>() => (T extends B ? 1 : 2) ? Yes : No;
29
23
  /**
30
24
  * The fields a caller writes: every one the class does not declare `readonly`. A field the database
31
25
  * writes - a relation aggregate, a stored generated column, a trigger-kept stamp - is `readonly`, and
32
26
  * its value never reaches the database, so a write payload leaves it out rather than dropping it.
33
27
  */
34
28
  export type WritableKey<E> = {
35
- readonly [K in FieldKey<E>]-?: IfEquals<Pick<E, K>, Writable<Pick<E, K>>, K, never>;
29
+ readonly [K in FieldKey<E>]-?: IsEqual<Pick<E, K>, Writable<Pick<E, K>>> extends true ? K : never;
36
30
  }[FieldKey<E>];
37
31
  /** A whole-record write as a caller supplies one: {@link EntityData} without the fields it cannot write. */
38
32
  export type EntityWrite<E> = EntityData<E, WritableKey<E>>;
@@ -110,8 +104,17 @@ export type JsonUpdateOp<T = unknown> = {
110
104
  * operators would address keys it does not have (engines disagree on what that does).
111
105
  */
112
106
  type JsonUpdateOpFor<V, T = UnwrapJson<NonNullable<V>>> = [T] extends [never] ? never : IsMany<T> extends true ? never : JsonUpdateOp<T>;
113
- /** What an update takes beyond the value: `null` to clear an optional member, `raw` SQL, and JSON operators. */
114
- type UpdateExtra<V, Raw> = (undefined extends V ? null : never) | Raw | JsonUpdateOpFor<V>;
107
+ /**
108
+ * A scalar field's update operator, as {@link JsonUpdateOp} is a JSON field's, computed in the statement:
109
+ * `$inc` adds, `$mul` multiplies, a NULL counting as 0 on every engine. One per field, since their order
110
+ * would change the result. A `bigint` steps by a `bigint`, exactly.
111
+ * @example `{ stock: { $inc: -1 } }`
112
+ */
113
+ export type FieldUpdateOp<T extends number | bigint = number | bigint> = ExactlyOne<Record<'$inc' | '$mul', T>>;
114
+ /** The {@link FieldUpdateOp} a field takes: `never` on one it has no operator for, which is any but a number. */
115
+ type FieldUpdateOpFor<V> = [NonNullable<V>] extends [number] ? FieldUpdateOp<number> : [NonNullable<V>] extends [bigint] ? FieldUpdateOp<bigint> : never;
116
+ /** What an update takes beyond the value: `null` to clear an optional member, `raw` SQL, and update operators. */
117
+ type UpdateExtra<V, Raw> = (undefined extends V ? null : never) | Raw | JsonUpdateOpFor<V> | FieldUpdateOpFor<V>;
115
118
  /**
116
119
  * What a whole-record write persists: the fields and relations with their declared optionality, a
117
120
  * related row's alike, and no methods. Two mapped types, since asking each key costs a conditional.
@@ -560,6 +563,11 @@ export type IndexColumnModifiers = {
560
563
  readonly nulls?: 'first' | 'last';
561
564
  /** Operator class, e.g. `jsonb_path_ops` for a smaller GIN index. Postgres only. */
562
565
  readonly opsClass?: string;
566
+ /**
567
+ * How many times a match in this column of a fulltext index counts in `$sort: { $text }`, 1 by default:
568
+ * a whole number from 1 to 99999, the range MongoDB weighs by.
569
+ */
570
+ readonly weight?: number;
563
571
  /** Index a path inside a JSON column. See {@link IndexJsonPath}. */
564
572
  readonly jsonPath?: IndexJsonPath;
565
573
  /** Index every element of a JSON array. See {@link IndexJsonArray}. */
@@ -130,15 +130,22 @@ export type QuerySortByCount = {
130
130
  $count: QuerySortDirection;
131
131
  };
132
132
  /**
133
- * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count`, or a vector distance,
134
- * which `Vector` confines to the queried entity. One mapped type over the key sets: an intersection is
135
- * checked once per member, which made this the costliest type to check.
133
+ * Ordering by relevance to the `$text` at the root of `$where`: most relevant first, the one order every
134
+ * engine ranks by (MongoDB's `textScore` sorts no other way).
135
+ */
136
+ export type QuerySortByText = {
137
+ $text?: -1 | 'desc';
138
+ };
139
+ /**
140
+ * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count`, or a vector distance or
141
+ * `$text` relevance, which `Vector` confines to the queried entity. One mapped type over the key sets: an
142
+ * intersection is checked once per member, which made this the costliest type to check.
136
143
  */
137
144
  export type QuerySortMap<E, Vector extends boolean = true, K extends keyof E = FieldKey<E> | RelationKey<E>> = {
138
145
  [P in K]?: P extends RelationKey<E> ? IsMany<E[P]> extends true ? QuerySortByCount : QuerySortMap<RelationTarget<E[P]>, false> : Vector extends true ? NonNullable<E[P]> extends readonly number[] ? QuerySortValue : QuerySortDirection : QuerySortDirection;
139
146
  } & ([JsonFieldPaths<E>] extends [never] ? unknown : {
140
147
  [P in JsonFieldPaths<E>]?: QuerySortDirection;
141
- });
148
+ }) & (Vector extends true ? QuerySortByText : unknown);
142
149
  /**
143
150
  * pager options.
144
151
  */