uql-orm 0.73.1 → 0.74.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.
@@ -1,5 +1,5 @@
1
- import { DEFAULT_VECTOR_DISTANCE, unsupportedVectorMetric } from '../type/vector.js';
2
- import { findVectorIndex, findVectorSort, vectorCandidates } from '../util/dialect.util.js';
1
+ import { unsupportedVectorMetric } from '../type/vector.js';
2
+ import { findVectorIndex, findVectorSort, vectorCandidates, vectorDistanceOf } from '../util/dialect.util.js';
3
3
  import { AbstractDialect } from './abstractDialect.js';
4
4
  import { encodeFloat32s } from './vectorCast.js';
5
5
  /**
@@ -44,15 +44,10 @@ export class VectorSqlDialect extends AbstractDialect {
44
44
  hasVectorIndex() {
45
45
  return [...this.vectorMetrics.values()].some((metric) => metric.index);
46
46
  }
47
- /**
48
- * What a distance expression reads, for a `$sort` and a `$near` alike. The metric falls back to the
49
- * field's, then its index's, which serves no other, then cosine.
50
- */
47
+ /** What a distance expression reads, for a `$sort` and a `$near` alike. */
51
48
  resolveVectorDistance(meta, key, search) {
52
49
  const field = meta.fields[key];
53
- const colName = this.resolveColumnName(key, field);
54
- const distance = search.$distance ?? field?.distance ?? findVectorIndex(meta, key)?.distance ?? DEFAULT_VECTOR_DISTANCE;
55
- return { colName, distance, field };
50
+ return { colName: this.resolveColumnName(key, field), distance: vectorDistanceOf(meta, key, search), field };
56
51
  }
57
52
  /**
58
53
  * Binds a vector, both as a persisted value and as the query vector of a distance expression, so a
@@ -70,9 +65,10 @@ export class VectorSqlDialect extends AbstractDialect {
70
65
  }
71
66
  /**
72
67
  * The distance expression, in whichever of the two shapes this dialect spells it. One method for
73
- * both, so the metric lookup and its refusal exist once rather than per shape.
68
+ * both, so the metric lookup and its refusal exist once rather than per shape. The column is read
69
+ * under `prefix`, the alias in scope, since a joined table may have a column of the same name.
74
70
  */
75
- appendVectorDistance(ctx, meta, key, search) {
71
+ appendVectorDistance(ctx, meta, key, search, prefix) {
76
72
  if (this.vectorMetrics.size === 0) {
77
73
  throw new TypeError(`${this.dialectName} does not support vector similarity search. Use raw() for vector queries.`);
78
74
  }
@@ -81,14 +77,15 @@ export class VectorSqlDialect extends AbstractDialect {
81
77
  if (!metric) {
82
78
  throw unsupportedVectorMetric(this.dialectName, distance);
83
79
  }
80
+ const column = this.escapeId(prefix, true, true) + this.escapeId(colName);
84
81
  if ('fn' in metric) {
85
82
  const leading = metric.metricArg === undefined ? '' : `'${metric.metricArg}', `;
86
- ctx.append(`${metric.fn}(${leading}${this.escapeId(colName)}, `);
83
+ ctx.append(`${metric.fn}(${leading}${column}, `);
87
84
  this.appendVectorValue(ctx, search.$vector, field);
88
85
  ctx.append(')');
89
86
  return;
90
87
  }
91
- ctx.append(`${this.escapeId(colName)} ${metric.op} `);
88
+ ctx.append(`${column} ${metric.op} `);
92
89
  this.appendVectorValue(ctx, search.$vector, field);
93
90
  }
94
91
  }
@@ -199,7 +199,7 @@ function detectIndexDrifts(diff) {
199
199
  table: idxDiff.table,
200
200
  index: idxDiff.name,
201
201
  details: `Index "${idxDiff.name}" exists in database but not defined in entity`,
202
- suggestion: 'Add @Field({ index }) or create migration to drop',
202
+ suggestion: 'Declare it, or drop it via migration: generate:entities drops one uql named',
203
203
  });
204
204
  }
205
205
  else {
@@ -211,7 +211,7 @@ function detectIndexDrifts(diff) {
211
211
  table: idxDiff.table,
212
212
  index: idxDiff.name,
213
213
  details: `Index "${idxDiff.name}" differs from the entity (${idxDiff.description})`,
214
- suggestion: 'Drop and recreate the index via migration',
214
+ suggestion: 'Recreate it via migration, which generate:entities writes',
215
215
  });
216
216
  }
217
217
  }
@@ -33,6 +33,7 @@ export declare class MongoSchemaGenerator extends MongoDialect implements Schema
33
33
  generateDropTable(tableName: string): string;
34
34
  generateAlterTable(diff: SchemaDiff): string[];
35
35
  generateAlterTableDown(diff: SchemaDiff): string[];
36
+ private dropIndexCommand;
36
37
  /** An index as MongoDB's key spec (`-1` descending, `'text'` full-text), refusing the SQL-only options. */
37
38
  generateCreateIndex(tableName: string, index: IndexSchema): string;
38
39
  /** An Atlas vector search index: its vector field first, then each field a `$vectorSearch` pre-filters on. */
@@ -1,6 +1,7 @@
1
1
  import { getMeta } from '../../entity/index.js';
2
2
  import { MongoDialect } from '../../mongo/mongoDialect.js';
3
3
  import { textLanguage } from '../../mongo/textLanguage.js';
4
+ import { indexChanges } from '../../schema/indexDifferences.js';
4
5
  import { QueryRaw, } from '../../type/index.js';
5
6
  import { indexDistance, unsupportedVectorMetric } from '../../type/vector.js';
6
7
  import { declaredIndexes, declaredIndexName, renderIndexColumn } from '../../util/ddlExpression.util.js';
@@ -8,6 +9,7 @@ import { fulltextConfig, fulltextWeights } from '../../util/dialect.util.js';
8
9
  import { assertIndexFeatures, assertIndexType } from '../ddl/indexDdl.js';
9
10
  import { assertIndexPredicate, refusedIndexPredicate } from '../indexPredicate.js';
10
11
  import { renderIndexDefinition } from './definitionToNode.js';
12
+ import { indexNodeToSchema } from './indexNodeToSchema.js';
11
13
  import { serializeMongoCommand } from './mongoCommand.js';
12
14
  /** The index types a key spec can say, a plain key or `'text'`, and Atlas's vector search index. */
13
15
  const MONGO_INDEX_TYPES = new Set(['btree', 'fulltext', 'vectorSearch']);
@@ -105,12 +107,21 @@ export class MongoSchemaGenerator extends MongoDialect {
105
107
  return serializeMongoCommand({ action: 'dropCollection', name: tableName });
106
108
  }
107
109
  generateAlterTable(diff) {
108
- return (diff.indexesToAdd ?? []).map((index) => this.generateCreateIndex(diff.tableName, index));
110
+ return [
111
+ ...(diff.indexesToDrop ?? []).map((index) => this.dropIndexCommand(diff.tableName, index)),
112
+ ...(diff.indexesToAdd ?? []).map((index) => this.generateCreateIndex(diff.tableName, index)),
113
+ ];
109
114
  }
110
115
  generateAlterTableDown(diff) {
111
- return (diff.indexesToAdd ?? []).map((index) => index.type === 'vectorSearch'
112
- ? serializeMongoCommand({ action: 'dropSearchIndex', collection: diff.tableName, name: index.name })
113
- : this.generateDropIndex(diff.tableName, index.name));
116
+ return [
117
+ ...(diff.indexesToAdd ?? []).map((index) => this.dropIndexCommand(diff.tableName, index)),
118
+ ...(diff.indexesToDrop ?? []).map((index) => this.generateCreateIndex(diff.tableName, index)),
119
+ ];
120
+ }
121
+ dropIndexCommand(tableName, index) {
122
+ return index.type === 'vectorSearch'
123
+ ? serializeMongoCommand({ action: 'dropSearchIndex', collection: tableName, name: index.name })
124
+ : this.generateDropIndex(tableName, index.name);
114
125
  }
115
126
  /** An index as MongoDB's key spec (`-1` descending, `'text'` full-text), refusing the SQL-only options. */
116
127
  generateCreateIndex(tableName, index) {
@@ -200,15 +211,16 @@ export class MongoSchemaGenerator extends MongoDialect {
200
211
  if (!currentTable) {
201
212
  return { tableName: collectionName, type: 'create' };
202
213
  }
203
- const existingIndexes = new Set(currentTable.indexes.map((i) => i.name));
204
- const indexesToAdd = this.indexesOf(meta, collectionName).filter((index) => !existingIndexes.has(index.name));
205
- if (indexesToAdd.length === 0) {
214
+ // By name: MongoDB lists a text index's fields alphabetically, so a shape would not match its own.
215
+ const { toAdd, toDrop } = indexChanges(collectionName, this.indexesOf(meta, collectionName), currentTable.indexes, (index) => index.name);
216
+ if (!toAdd.length && !toDrop.length) {
206
217
  return undefined;
207
218
  }
208
219
  return {
209
220
  tableName: collectionName,
210
221
  type: 'alter',
211
- indexesToAdd,
222
+ indexesToAdd: toAdd.length ? toAdd : undefined,
223
+ indexesToDrop: toDrop.length ? toDrop.map(indexNodeToSchema) : undefined,
212
224
  };
213
225
  }
214
226
  }
@@ -189,15 +189,16 @@ export class Migrator {
189
189
  this.logger.logInfo('No schema changes detected.');
190
190
  return '';
191
191
  }
192
+ // Diff by diff in reverse, each rolled back in the order its generator wrote it.
192
193
  const down = [
193
- ...created.map((tableName) => generator.generateDropTable(tableName, { ifExists: true })),
194
- ...altered.flatMap((diff) => generator.generateAlterTableDown(diff)),
194
+ ...altered.toReversed().flatMap((diff) => generator.generateAlterTableDown(diff)),
195
+ ...created.toReversed().map((tableName) => generator.generateDropTable(tableName, { ifExists: true })),
195
196
  ];
196
197
  const { emit } = this.target.source;
197
198
  const filePath = await this.writeMigration(name, {
198
199
  docExtraLines: ['Generated from entity definitions'],
199
200
  upInner: emit(up),
200
- downInner: emit(down.reverse()),
201
+ downInner: emit(down),
201
202
  });
202
203
  this.logger.logInfo(`Created migration from entities: ${filePath}`);
203
204
  return filePath;
@@ -345,7 +346,13 @@ export class Migrator {
345
346
  this.logger.logSkippedMigration(`[AutoSync] Skipped altering ${filteredDiff.foreignKeysToAlter.length} foreign keys in table '${diff.tableName}': ${filteredDiff.foreignKeysToAlter.map((fk) => fk.to.name).join(', ')} (safe mode active). Use a migration or { safe: false } to apply.`);
346
347
  delete filteredDiff.foreignKeysToAlter;
347
348
  }
348
- delete filteredDiff.indexesToDrop;
349
+ if (filteredDiff.indexesToDrop?.length) {
350
+ // An index recreated under its old name is a drop and an add, held back together.
351
+ const dropped = new Set(filteredDiff.indexesToDrop.map((index) => index.name));
352
+ this.logger.logSkippedMigration(`[AutoSync] Skipped dropping ${dropped.size} indexes in table '${diff.tableName}': ${[...dropped].join(', ')} (safe mode active). Use a migration or { safe: false } to apply.`);
353
+ filteredDiff.indexesToAdd = filteredDiff.indexesToAdd?.filter((index) => !dropped.has(index.name));
354
+ delete filteredDiff.indexesToDrop;
355
+ }
349
356
  delete filteredDiff.foreignKeysToDrop;
350
357
  }
351
358
  if (!options.drop && filteredDiff.columnsToDrop?.length) {
@@ -62,12 +62,16 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
62
62
  private addForeignKeyStatements;
63
63
  /** `DROP CONSTRAINT` for each of `constraintNames`, the mirror of {@link addForeignKeyStatements}. */
64
64
  private dropForeignKeyStatements;
65
+ /** The `ALTER COLUMN` restating each of `columns`. */
66
+ private alterColumnStatements;
67
+ /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
68
+ private addIndexStatements;
69
+ /** `DROP INDEX` for each of `indexes`, the mirror of {@link addIndexStatements}. */
70
+ private dropIndexStatements;
65
71
  generateAlterTableDown(diff: SchemaDiff): string[];
66
72
  generateCreateIndex(tableName: string, index: IndexSchema, options?: {
67
73
  ifNotExists?: boolean;
68
74
  }): string;
69
- /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
70
- private addIndexStatements;
71
75
  /**
72
76
  * `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
73
77
  * operand instead, which is already qualified.
@@ -106,13 +110,6 @@ export declare class SqlSchemaGenerator implements SchemaGenerator {
106
110
  * drift detection runs, with types normalized as the engine stores them.
107
111
  */
108
112
  diffSchema(entity: Type<object>, currentTable: TableNode | undefined, desiredAst?: SchemaAST): SchemaDiff | undefined;
109
- /**
110
- * Indexes the entity declares that the table does not already have, in any shape.
111
- *
112
- * Additive only: an index the entity does not name may well have been created deliberately outside
113
- * the ORM, and dropping it is a decision for a reviewed migration.
114
- */
115
- private missingIndexes;
116
113
  protected diffOptions(): DiffOptions;
117
114
  /** Spread, not copied field by field, so a field the node gains cannot go missing here. */
118
115
  private columnNodeToSchema;
@@ -1,6 +1,6 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { canonicalToSql, engineType, isVectorCategory } from '../schema/canonicalType.js';
3
- import { indexSignature } from '../schema/indexDifferences.js';
3
+ import { indexChanges } from '../schema/indexDifferences.js';
4
4
  import { buildSchemaAST, resolveColumnCanonicalType } from '../schema/schemaASTBuilder.js';
5
5
  import { diffRelationshipNodes, diffTable } from '../schema/schemaASTDiffer.js';
6
6
  import { isAutoIncrement, qualifyName } from '../util/index.js';
@@ -144,40 +144,19 @@ export class SqlSchemaGenerator {
144
144
  ...(diff.foreignKeysToDrop ?? []),
145
145
  ...(diff.foreignKeysToAlter ?? []).map((it) => constraintNameOf(diff.tableName, it.from)),
146
146
  ]));
147
- // Add new columns
148
- if (diff.columnsToAdd?.length) {
149
- for (const column of diff.columnsToAdd) {
150
- this.assertColumnAddable(diff.tableName, column);
151
- statements.push(this.tableDdl.addColumn(diff.tableName, this.generateColumnDefinitionFromSchema(column)));
152
- statements.push(...this.generateColumnCommentStatement(diff.tableName, column, diff.schema));
153
- }
154
- }
155
- // Alter existing columns
156
- if (diff.columnsToAlter?.length) {
157
- for (const { to } of diff.columnsToAlter) {
158
- const colDef = this.generateColumnDefinitionFromSchema(to);
159
- const colStatements = this.generateAlterColumnStatements(diff.tableName, to, colDef);
160
- statements.push(...colStatements);
161
- }
162
- }
163
- // Drop columns
164
- if (diff.columnsToDrop?.length) {
165
- for (const columnName of diff.columnsToDrop) {
166
- statements.push(...this.tableDdl.dropColumn(diff.tableName, columnName));
167
- }
168
- }
169
- // Add indexes
170
- if (diff.indexesToAdd?.length) {
171
- for (const index of diff.indexesToAdd) {
172
- statements.push(...this.addIndexStatements(diff.tableName, index));
173
- }
174
- }
175
- // Drop indexes
176
- if (diff.indexesToDrop?.length) {
177
- for (const indexName of diff.indexesToDrop) {
178
- statements.push(this.generateDropIndex(diff.tableName, indexName, diff.schema));
179
- }
180
- }
147
+ // Before the adds, which may reuse a dropped index's name, and before the columns: some engines
148
+ // drop an index along with its column, which would leave nothing here to name.
149
+ statements.push(...this.dropIndexStatements(diff.tableName, diff.indexesToDrop, diff.schema));
150
+ for (const column of diff.columnsToAdd ?? []) {
151
+ this.assertColumnAddable(diff.tableName, column);
152
+ statements.push(this.tableDdl.addColumn(diff.tableName, this.generateColumnDefinitionFromSchema(column)));
153
+ statements.push(...this.generateColumnCommentStatement(diff.tableName, column, diff.schema));
154
+ }
155
+ statements.push(...this.alterColumnStatements(diff.tableName, (diff.columnsToAlter ?? []).map((it) => it.to)));
156
+ for (const columnName of diff.columnsToDrop ?? []) {
157
+ statements.push(...this.tableDdl.dropColumn(diff.tableName, columnName));
158
+ }
159
+ statements.push(...this.addIndexStatements(diff.tableName, diff.indexesToAdd));
181
160
  // Last, so every column it names exists by now.
182
161
  if (diff.primaryKey?.to.length) {
183
162
  statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.to));
@@ -198,6 +177,21 @@ export class SqlSchemaGenerator {
198
177
  dropForeignKeyStatements(tableName, constraintNames) {
199
178
  return constraintNames.map((name) => this.generateDropForeignKeySql(tableName, name));
200
179
  }
180
+ /** The `ALTER COLUMN` restating each of `columns`. */
181
+ alterColumnStatements(tableName, columns) {
182
+ return columns.flatMap((column) => this.generateAlterColumnStatements(tableName, column, this.generateColumnDefinitionFromSchema(column)));
183
+ }
184
+ /** An index added to a table that may already have rows: its `CREATE`, then what the engine needs after. */
185
+ addIndexStatements(tableName, indexes = []) {
186
+ return indexes.flatMap((index) => [
187
+ this.generateCreateIndex(tableName, index),
188
+ ...this.indexDdl.settleStatements(tableName, index),
189
+ ]);
190
+ }
191
+ /** `DROP INDEX` for each of `indexes`, the mirror of {@link addIndexStatements}. */
192
+ dropIndexStatements(tableName, indexes = [], schema) {
193
+ return indexes.map((index) => this.generateDropIndex(tableName, index.name, schema));
194
+ }
201
195
  generateAlterTableDown(diff) {
202
196
  const statements = [];
203
197
  // Constraints first, mirroring the up direction: the up added them last, so the down drops them
@@ -212,44 +206,26 @@ export class SqlSchemaGenerator {
212
206
  if (diff.primaryKey?.to.length) {
213
207
  statements.push(this.generateDropPrimaryKeySql(diff.tableName, derivedPrimaryKeyName(diff.tableName, diff.primaryKey.to)));
214
208
  }
215
- // Reverse column additions by dropping them
216
- if (diff.columnsToAdd?.length) {
217
- for (const column of diff.columnsToAdd) {
218
- statements.push(...this.tableDdl.dropColumn(diff.tableName, column.name));
219
- }
220
- }
221
- // Reverse column alterations by restoring original schema
222
- if (diff.columnsToAlter?.length) {
223
- for (const { from } of diff.columnsToAlter) {
224
- const colDef = this.generateColumnDefinitionFromSchema(from);
225
- const colStatements = this.generateAlterColumnStatements(diff.tableName, from, colDef);
226
- statements.push(...colStatements);
227
- }
228
- }
229
- // Reverse index additions by dropping them
230
- if (diff.indexesToAdd?.length) {
231
- for (const index of diff.indexesToAdd) {
232
- statements.push(this.generateDropIndex(diff.tableName, index.name, diff.schema));
233
- }
209
+ for (const column of diff.columnsToAdd ?? []) {
210
+ statements.push(...this.tableDdl.dropColumn(diff.tableName, column.name));
234
211
  }
212
+ statements.push(...this.alterColumnStatements(diff.tableName, (diff.columnsToAlter ?? []).map((it) => it.from)));
213
+ statements.push(...this.dropIndexStatements(diff.tableName, diff.indexesToAdd, diff.schema));
214
+ statements.push(...this.addIndexStatements(diff.tableName, diff.indexesToDrop));
235
215
  if (diff.primaryKey?.from.length) {
236
216
  statements.push(this.generateAddPrimaryKeySql(diff.tableName, diff.primaryKey.from, diff.primaryKey.fromName));
237
217
  }
238
218
  // The constraint the up replaced, back under the name the database had for it. A foreign key the
239
219
  // up *dropped* is not restored: only its name survived the diff, never what it pointed at.
240
220
  statements.push(...this.addForeignKeyStatements(diff.tableName, (diff.foreignKeysToAlter ?? []).map((it) => it.from)));
241
- if (diff.columnsToDrop?.length || diff.indexesToDrop?.length || diff.foreignKeysToDrop?.length) {
242
- statements.push(`-- TODO: Manual reversal needed for dropped columns/indexes/foreign keys`);
221
+ if (diff.columnsToDrop?.length || diff.foreignKeysToDrop?.length) {
222
+ statements.push(`-- TODO: Manual reversal needed for dropped columns/foreign keys`);
243
223
  }
244
224
  return statements;
245
225
  }
246
226
  generateCreateIndex(tableName, index, options = {}) {
247
227
  return this.indexDdl.getCreateIndexStatement(tableName, index, options);
248
228
  }
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
- }
253
229
  /**
254
230
  * `schema` is the table's, because that is where its indexes live. MySQL takes it from the table
255
231
  * operand instead, which is already qualified.
@@ -351,10 +327,12 @@ export class SqlSchemaGenerator {
351
327
  }
352
328
  // Indexes are matched here rather than by the differ, which pairs them by name so that a changed
353
329
  // one reads as one index that altered. A migration needs the opposite: an index already in the
354
- // table, under whatever name, must not be created again, and one whose shape differs is a
355
- // separate index rather than a change - no engine alters an index's columns or uniqueness.
330
+ // table, under whatever name, must not be created again, and one whose shape differs is dropped
331
+ // and created anew - no engine alters an index's columns or uniqueness.
356
332
  const tableDiff = diffTable(desired, currentTable, { ...this.diffOptions(), compareIndexes: false });
357
- const indexesToAdd = this.missingIndexes(desired, currentTable);
333
+ const indexes = indexChanges(currentTable.name, desired.indexes, currentTable.indexes);
334
+ const indexesToAdd = indexes.toAdd.map(indexNodeToSchema);
335
+ const indexesToDrop = indexes.toDrop.map(indexNodeToSchema);
358
336
  const columnDiffs = tableDiff?.columnDiffs ?? [];
359
337
  const columnsToAdd = columnDiffs.flatMap((it) => (it.type === 'add' ? [this.columnNodeToSchema(it.expected)] : []));
360
338
  const columnsToDrop = columnDiffs.flatMap((it) => (it.type === 'drop' ? [it.column] : []));
@@ -387,6 +365,7 @@ export class SqlSchemaGenerator {
387
365
  !columnsToAlter.length &&
388
366
  !columnsToDrop.length &&
389
367
  !indexesToAdd.length &&
368
+ !indexesToDrop.length &&
390
369
  !foreignKeysToAdd.length &&
391
370
  !foreignKeysToDrop.length &&
392
371
  !foreignKeysToAlter.length &&
@@ -402,21 +381,12 @@ export class SqlSchemaGenerator {
402
381
  columnsToAlter: columnsToAlter.length ? columnsToAlter : undefined,
403
382
  columnsToDrop: columnsToDrop.length ? columnsToDrop : undefined,
404
383
  indexesToAdd: indexesToAdd.length ? indexesToAdd : undefined,
384
+ indexesToDrop: indexesToDrop.length ? indexesToDrop : undefined,
405
385
  foreignKeysToAdd: foreignKeysToAdd.length ? foreignKeysToAdd : undefined,
406
386
  foreignKeysToDrop: foreignKeysToDrop.length ? foreignKeysToDrop : undefined,
407
387
  foreignKeysToAlter: foreignKeysToAlter.length ? foreignKeysToAlter : undefined,
408
388
  };
409
389
  }
410
- /**
411
- * Indexes the entity declares that the table does not already have, in any shape.
412
- *
413
- * Additive only: an index the entity does not name may well have been created deliberately outside
414
- * the ORM, and dropping it is a decision for a reviewed migration.
415
- */
416
- missingIndexes(desired, currentTable) {
417
- const present = new Set(currentTable.indexes.map(indexSignature));
418
- return desired.indexes.filter((index) => !present.has(indexSignature(index))).map(indexNodeToSchema);
419
- }
420
390
  diffOptions() {
421
391
  return {
422
392
  normalizeType: engineType(this.dialect),
@@ -563,7 +533,9 @@ export class SqlSchemaGenerator {
563
533
  case 'alterColumn':
564
534
  return this.generateAlterColumnSql(operation.tableName, operation.columnName, operation.changes);
565
535
  case 'createIndex':
566
- return this.addIndexStatements(operation.tableName, renderIndexDefinition(operation.index, (sql) => this.dialect.compileDdl(sql)));
536
+ return this.addIndexStatements(operation.tableName, [
537
+ renderIndexDefinition(operation.index, (sql) => this.dialect.compileDdl(sql)),
538
+ ]);
567
539
  case 'dropIndex':
568
540
  return [this.generateDropIndex(operation.tableName, operation.indexName)];
569
541
  case 'addForeignKey':
@@ -130,11 +130,11 @@ export declare class MongoDialect extends AbstractDialect {
130
130
  /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
131
131
  private collectSort;
132
132
  /**
133
- * The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
134
- * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
135
- * orders by. A parent with no related row gets no lookup result at all, which is a zero.
133
+ * The stages a `$sort` by a relation's aggregate needs - its size, or its row nearest a vector: one
134
+ * correlated `$lookup` reading it per parent, and the `$set` that lifts it onto the document as the
135
+ * field the `$sort` then orders by. A parent with no related row reads a zero tally and no distance.
136
136
  */
137
- sortCountStages<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): {
137
+ sortAggregateStages<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): {
138
138
  readonly stages: MongoAggregationPipelineEntry<Document>[];
139
139
  readonly fields: string[];
140
140
  };
@@ -1,13 +1,14 @@
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, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
- import { aggregateColumnField, groupPathField, resolveGroupJoins, resolveQueryJoins, resolveSortableJoin, } from '../dialect/queryJoins.js';
3
+ import { AGGREGATE_VALUE_ALIAS, REL_NESTED_KEY, REL_TEMP_PREFIX, SUM_COUNT_ALIAS, sortAggregateField, TEXT_SCORE_ALIAS, } from '../dialect/aliases.js';
4
+ import { aggregateColumnField, groupPathField, resolveGroupJoins, relationSortTerms, 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, textSortOf, } 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, rankedTextSearch, someKey, targetKeyColumns, textSortOf, vectorDistanceOf, } from '../util/index.js';
9
9
  import { decodeBigIntsExcept } from '../util/wideNumber.js';
10
10
  import { textLanguage } from './textLanguage.js';
11
+ import { vectorDistanceExpr } from './vectorDistance.js';
11
12
  /** A scalar field's operator as the aggregation operator computing it. */
12
13
  const MONGO_ARITHMETIC = { $inc: '$add', $mul: '$multiply' };
13
14
  /** Default {@link DialectFeatures} for MongoDB. */
@@ -461,7 +462,8 @@ export class MongoDialect extends AbstractDialect {
461
462
  */
462
463
  sort(entity, { $sort: sort, $populate: populate, $where: where }) {
463
464
  const meta = getMeta(entity);
464
- const normalized = {};
465
+ const nearest = {};
466
+ const columns = {};
465
467
  // Refused as the SQL dialects refuse it, before MongoDB answers a missing score with its own error.
466
468
  if (sort?.$text) {
467
469
  rankedTextSearch(where);
@@ -469,11 +471,13 @@ export class MongoDialect extends AbstractDialect {
469
471
  // The same join set the lookups are built from, so what an ordering may address and what the
470
472
  // pipeline actually produces cannot drift apart - `$sort` contributes its own to-one joins here
471
473
  // exactly as it does on the SQL dialects.
472
- this.collectSort(meta, sort, resolveQueryJoins(meta, { $populate: populate, $sort: sort }), '', normalized);
473
- return normalized;
474
+ const joins = resolveQueryJoins(meta, { $populate: populate, $sort: sort });
475
+ this.collectSort(meta, sort, joins, '', nearest, columns);
476
+ // A vector distance is the primary ordering wherever it appears, as on the SQL dialects.
477
+ return { ...nearest, ...columns };
474
478
  }
475
479
  /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
476
- collectSort(meta, sort, joins, path, out) {
480
+ collectSort(meta, sort, joins, path, nearest, out) {
477
481
  for (const [key, value] of Object.entries(sort ?? {})) {
478
482
  const relation = meta.relations[key];
479
483
  if (key === '$text') {
@@ -485,12 +489,11 @@ export class MongoDialect extends AbstractDialect {
485
489
  continue;
486
490
  }
487
491
  if (!relation) {
488
- // The queried entity's own vector search is lifted out before this walk, so one reaching it
489
- // sits under a relation, which a `$lookup` brings in one row at a time - there is nothing to
490
- // rank. `sortDirection` would read the operator object as "ascending" and order by the raw
491
- // vector column instead, which is the SQL dialects' rejection turned into a silent answer.
492
+ // The queried entity's first vector search is lifted out into `$vectorSearch` before this walk,
493
+ // so one reaching it is a second. `sortDirection` would read the operator object as "ascending"
494
+ // and order by the raw vector column instead, a silent answer where the caller asked for a rank.
492
495
  if (isVectorSearch(value)) {
493
- throw new TypeError(`$vector sort is only supported on the queried entity, not on relation '${path.slice(0, -1)}'`);
496
+ throw new TypeError(`cannot $sort by a second vector '${key}' on MongoDB: $vectorSearch ranks by one`);
494
497
  }
495
498
  out[path + this.pathOf(meta, key)] = sortDirection(value);
496
499
  continue;
@@ -499,39 +502,38 @@ export class MongoDialect extends AbstractDialect {
499
502
  // one: ordering by a relation nothing looked up reads a field that is not there, which MongoDB
500
503
  // ranks as all-equal rather than rejecting. The SQL dialects can add the join themselves.
501
504
  const relPath = `${path}${key}`;
502
- const countDirection = parseSortByCount(value);
503
- if (countDirection !== undefined) {
504
- // The tally rides on a field {@link sortCountStages} adds, which only the queried entity's
505
+ const { aggregates, rest } = relationSortTerms(key, relPath, value);
506
+ for (const { spec, direction } of aggregates) {
507
+ // The value rides on a field {@link sortAggregateStages} adds, which only the queried entity's
505
508
  // own pipeline has: a nested one is built inside its parent's `$lookup`, where there is no
506
509
  // parent document left to hang it off.
507
510
  if (path) {
508
- throw new TypeError(`$sort by '${relPath}.$count' is only supported on the queried entity`);
511
+ throw new TypeError(`$sort by '${relPath}.${spec.field ?? '$count'}' is only supported on the queried entity`);
512
+ }
513
+ if (spec.search) {
514
+ nearest[sortAggregateField(spec)] = 1;
515
+ }
516
+ else {
517
+ out[sortAggregateField(spec)] = sortDirection(direction);
509
518
  }
510
- out[sortCountField(key)] = sortDirection(countDirection);
519
+ }
520
+ if (rest === undefined) {
511
521
  continue;
512
522
  }
513
- const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, joins, `cannot $sort by relation '${relPath}' on MongoDB unless it is populated: only $populate adds its fields to the document`);
514
- this.collectSort(join.meta, relationSort, joins, `${relPath}.`, out);
523
+ const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, rest, joins, `cannot $sort by relation '${relPath}' on MongoDB unless it is populated: only $populate adds its fields to the document`);
524
+ this.collectSort(join.meta, relationSort, joins, `${relPath}.`, nearest, out);
515
525
  }
516
526
  }
517
527
  /**
518
- * The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
519
- * per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
520
- * orders by. A parent with no related row gets no lookup result at all, which is a zero.
528
+ * The stages a `$sort` by a relation's aggregate needs - its size, or its row nearest a vector: one
529
+ * correlated `$lookup` reading it per parent, and the `$set` that lifts it onto the document as the
530
+ * field the `$sort` then orders by. A parent with no related row reads a zero tally and no distance.
521
531
  */
522
- sortCountStages(entity, sort) {
532
+ sortAggregateStages(entity, sort) {
523
533
  const meta = getMeta(entity);
524
- const stages = [];
525
- const fields = [];
526
- for (const [key, value] of Object.entries(sort ?? {})) {
527
- const relOpts = meta.relations[key];
528
- if (!relOpts || parseSortByCount(value) === undefined) {
529
- continue;
530
- }
531
- const temp = sortCountField(key);
532
- stages.push(...this.aggregateStages(meta, { relation: key, op: '$count' }, `${REL_TEMP_PREFIX}${temp}`, temp));
533
- fields.push(temp);
534
- }
534
+ const specs = Object.entries(sort ?? {}).flatMap(([key, value]) => meta.relations[key] ? relationSortTerms(key, key, value).aggregates.map(({ spec }) => spec) : []);
535
+ const fields = specs.map(sortAggregateField);
536
+ const stages = specs.flatMap((spec, index) => this.aggregateStages(meta, spec, `${REL_TEMP_PREFIX}${fields[index]}`, fields[index]));
535
537
  return { stages, fields };
536
538
  }
537
539
  /** Whether a read answers with a relation aggregate, which only the pipeline can build. */
@@ -568,15 +570,24 @@ export class MongoDialect extends AbstractDialect {
568
570
  */
569
571
  aggregateStages(meta, spec, temp, field) {
570
572
  const relOpts = relationOf(meta, spec.relation);
573
+ const relMeta = getMeta(relOpts.entity());
571
574
  const page = spec.page ?? {};
575
+ // A many-to-many's lookup runs over its junction's rows, each carrying its target: read as that
576
+ // target, so the page's order and the aggregate reach the target's own fields.
577
+ const targets = relOpts.through ? [{ $replaceRoot: { newRoot: { $arrayElemAt: [`$${REL_NESTED_KEY}`, 0] } } }] : [];
572
578
  const tail = [
579
+ ...targets,
573
580
  ...(page.$sort ? [{ $sort: this.sort(relOpts.entity(), page) }] : []),
574
581
  ...this.pagerStages(page),
575
582
  spec.field
576
583
  ? {
577
584
  $group: {
578
585
  _id: null,
579
- [AGGREGATE_VALUE_ALIAS]: { [spec.op]: `$${this.columnOf(getMeta(relOpts.entity()), spec.field)}` },
586
+ [AGGREGATE_VALUE_ALIAS]: {
587
+ [spec.op]: spec.search
588
+ ? vectorDistanceExpr(this.columnOf(relMeta, spec.field), spec.search.$vector, vectorDistanceOf(relMeta, spec.field, spec.search))
589
+ : `$${this.columnOf(relMeta, spec.field)}`,
590
+ },
580
591
  },
581
592
  }
582
593
  : { $count: AGGREGATE_VALUE_ALIAS },
@@ -708,10 +719,10 @@ export class MongoDialect extends AbstractDialect {
708
719
  readStages(entity, q, extra = {}) {
709
720
  const meta = getMeta(entity);
710
721
  const joins = resolveQueryJoins(meta, q);
711
- // The tally an ordering by a relation's size reads, and the field it parks it on: both belong
722
+ // The value an ordering by a relation's aggregate reads, and the field it parks it on: both belong
712
723
  // with the lookups, since the `$sort` right after them is what they exist for.
713
- const counted = this.sortCountStages(entity, q.$sort);
714
- const lookups = [...this.lookupStages(meta, joins), ...counted.stages];
724
+ const aggregated = this.sortAggregateStages(entity, q.$sort);
725
+ const lookups = [...this.lookupStages(meta, joins), ...aggregated.stages];
715
726
  // Each to-many and each `$count`, which neither drop nor reorder a row, so they read the page alone.
716
727
  const related = this.relationReadStages(entity, q);
717
728
  const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
@@ -732,13 +743,13 @@ export class MongoDialect extends AbstractDialect {
732
743
  // which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
733
744
  // it has run, so ordering by an unpopulated relation costs the same nothing it does there.
734
745
  const sortOnly = [...joins.values()].filter((join) => !join.projected).map((join) => join.path);
735
- const dropped = [...sortOnly, ...counted.fields];
746
+ const dropped = [...sortOnly, ...aggregated.fields];
736
747
  const unset = dropped.length ? [{ $unset: dropped }] : [];
737
748
  // The grouping collapses rows onto the columns it projects, which leaves nothing for an ordering
738
749
  // that reads a lookup those columns do not carry. Refused rather than answered all-equal, and in
739
750
  // the same terms the SQL dialects refuse `SELECT DISTINCT` ordered by an unselected column.
740
- if (q.$distinct && counted.fields.length) {
741
- throw new TypeError(`cannot $sort by a relation's $count with $distinct: the grouping keeps only the columns it projects`);
751
+ if (q.$distinct && aggregated.fields.length) {
752
+ throw new TypeError(`cannot $sort by a relation's aggregate with $distinct: the grouping keeps only the columns it projects`);
742
753
  }
743
754
  if (q.$distinct && sortOnly.length) {
744
755
  throw new TypeError(`cannot $sort by relation '${sortOnly[0]}' with $distinct unless '${sortOnly[0]}' is populated: the grouping keeps only the columns it projects`);
@@ -0,0 +1,8 @@
1
+ import type { Document } from 'mongodb';
2
+ import type { VectorDistance } from '../type/index.js';
3
+ /**
4
+ * A document's distance from `vector` as an aggregation expression, computed exactly as the SQL engines
5
+ * compute theirs: Atlas ranks only through its own index, which no related document reaches. `null` where
6
+ * the field holds no vector, or a cosine has a zero-length side, and `$min` skips a `null`.
7
+ */
8
+ export declare function vectorDistanceExpr(column: string, vector: readonly number[], metric: VectorDistance): Document;