uql-orm 0.72.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.
@@ -183,10 +183,16 @@ export declare abstract class AbstractSqlDialect extends VectorSqlDialect implem
183
183
  */
184
184
  protected appendTextSearch<E>(_ctx: QueryContext, _entity: Type<E>, _meta: EntityMeta<E>, _search: QueryTextSearchOptions<E>): void;
185
185
  /**
186
- * A row's relevance to a `$text` search, higher for a better match: what `$sort: { $text }` orders by.
187
- * Each engine that searches scores too, so a dialect overrides this beside {@link appendTextSearch}.
186
+ * A row's relevance to a `$text` search, what `$sort: { $text }` orders by. Where the fulltext index
187
+ * weighs its columns, a match counts its column's weight, as MongoDB's `textScore` counts it: the score
188
+ * over every column times the lightest weight, plus each heavier column's own times what it weighs more.
188
189
  */
189
- protected appendTextRank<E>(_ctx: QueryContext, _meta: EntityMeta<E>, _search: QueryTextSearchOptions<E>): void;
190
+ private appendTextRank;
191
+ /**
192
+ * How relevant the `keys` of a row are to a `$text` search, higher for a better match. Each engine that
193
+ * searches scores too, so a dialect overrides this beside {@link appendTextSearch}.
194
+ */
195
+ protected appendTextScore<E>(_ctx: QueryContext, _meta: EntityMeta<E>, _search: QueryTextSearchOptions<E>, _keys: readonly string[]): void;
190
196
  /** Ranks by the root `$text` of `where`, which is looked up only once a `$sort` asks for it. */
191
197
  private textRanker;
192
198
  select<E>(ctx: QueryContext, entity: Type<E>, q: Query<E>, opts?: ReadOptions, joins?: QueryJoins, totalAlias?: string): ReadProjection;
@@ -1,7 +1,7 @@
1
1
  import { fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
2
2
  import { COUNT_RESULT_KEY, parseQueryLock, QueryRaw, RAW_ALIAS, VECTOR_QUERY_KEYS, } from '../type/index.js';
3
3
  import { isInlinedExpression } from '../util/field.util.js';
4
- import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, fieldUpdateOf, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, rankedTextSearch, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, aggregateOf, raw, refs, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
4
+ import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, fieldUpdateOf, fulltextIndexOver, fulltextWeights, isFieldUpdateOp, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, rankedTextSearch, targetKeyColumns, textSearchFields, textWeightSteps, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, aggregateOf, raw, refs, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
5
5
  import { escapeAnsiSqlLiteral } from '../util/sqlLiteral.js';
6
6
  import { AGGREGATE_PAGE_ALIAS, AGGREGATE_VALUE_ALIAS, ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn, } from './aliases.js';
7
7
  import { holdsOperator, isJsonScalar, jsonCompareMode, jsonElemExists, jsonPath, } from './jsonSql.js';
@@ -225,10 +225,34 @@ export class AbstractSqlDialect extends VectorSqlDialect {
225
225
  throw new TypeError(`${this.dialectName} does not support $text full-text search`);
226
226
  }
227
227
  /**
228
- * A row's relevance to a `$text` search, higher for a better match: what `$sort: { $text }` orders by.
229
- * Each engine that searches scores too, so a dialect overrides this beside {@link appendTextSearch}.
228
+ * A row's relevance to a `$text` search, what `$sort: { $text }` orders by. Where the fulltext index
229
+ * weighs its columns, a match counts its column's weight, as MongoDB's `textScore` counts it: the score
230
+ * over every column times the lightest weight, plus each heavier column's own times what it weighs more.
230
231
  */
231
- appendTextRank(_ctx, _meta, _search) {
232
+ appendTextRank(ctx, meta, search) {
233
+ const keys = textSearchFields(meta, search);
234
+ const index = fulltextIndexOver(meta, keys);
235
+ const weights = index && fulltextWeights({ type: index.type, entries: index.columns });
236
+ if (!weights) {
237
+ this.appendTextScore(ctx, meta, search, keys);
238
+ return;
239
+ }
240
+ const { lightest, extra } = textWeightSteps(weights);
241
+ ctx.append(`(${lightest} * `);
242
+ this.appendTextScore(ctx, meta, search, keys);
243
+ keys.forEach((key, at) => {
244
+ if (extra[at]) {
245
+ ctx.append(` + ${extra[at]} * `);
246
+ this.appendTextScore(ctx, meta, search, [key]);
247
+ }
248
+ });
249
+ ctx.append(')');
250
+ }
251
+ /**
252
+ * How relevant the `keys` of a row are to a `$text` search, higher for a better match. Each engine that
253
+ * searches scores too, so a dialect overrides this beside {@link appendTextSearch}.
254
+ */
255
+ appendTextScore(_ctx, _meta, _search, _keys) {
232
256
  throw new TypeError(`${this.dialectName} does not support $text full-text search`);
233
257
  }
234
258
  /** Ranks by the root `$text` of `where`, which is looked up only once a `$sort` asks for it. */
@@ -80,8 +80,11 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
80
80
  * `@Index((post) => [...], { type: 'fulltext' })`.
81
81
  */
82
82
  protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
83
- /** `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. */
84
- protected appendTextRank<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
83
+ /**
84
+ * `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. Over `keys`
85
+ * alone, it needs a `FULLTEXT` index of exactly those: a weighted index declares one per heavier column.
86
+ */
87
+ protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>, keys: readonly string[]): void;
85
88
  /** `DOUBLE`, never a bare `DECIMAL`, which is `DECIMAL(10,0)` and rounds `1.4` to `1`. */
86
89
  protected numericCast(expr: string): string;
87
90
  protected neExpr(field: string, ph: string): string;
@@ -32,6 +32,7 @@ export const MYSQL_FEATURES = {
32
32
  rowLocks: true,
33
33
  rowLockWithWindow: true,
34
34
  rowLockOf: true,
35
+ textScoreIndexes: true,
35
36
  orderedUpsertReturning: true,
36
37
  orderedJsonAggregates: true,
37
38
  narrowVectorTypes: false,
@@ -178,11 +179,14 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
178
179
  * `@Index((post) => [...], { type: 'fulltext' })`.
179
180
  */
180
181
  appendTextSearch(ctx, _entity, meta, search) {
181
- this.appendTextRank(ctx, meta, search);
182
+ this.appendTextScore(ctx, meta, search, textSearchFields(meta, search));
182
183
  }
183
- /** `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. */
184
- appendTextRank(ctx, meta, search) {
185
- const columns = textSearchFields(meta, search).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
184
+ /**
185
+ * `MATCH ... AGAINST` is the relevance itself, a match being any row it scores above zero. Over `keys`
186
+ * alone, it needs a `FULLTEXT` index of exactly those: a weighted index declares one per heavier column.
187
+ */
188
+ appendTextScore(ctx, meta, search, keys) {
189
+ const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
186
190
  ctx.append(`MATCH(${this.textSearchTarget(columns)}) AGAINST(`);
187
191
  ctx.addValue(search.$value);
188
192
  ctx.append(')');
@@ -64,9 +64,12 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
64
64
  * input (quoted phrases, `or`, `-negation`) and never raises a syntax error, unlike `TO_TSQUERY`.
65
65
  */
66
66
  protected appendTextSearch<E>(ctx: QueryContext, _entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
67
- /** `TS_RANK` of the document the predicate matches, against the same search. */
68
- protected appendTextRank<E>(ctx: QueryContext, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
69
- /** The document a search reads and the search itself, open for its value. */
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;
69
+ /**
70
+ * The document over `keys` and the search itself, open for its value, under the `$config` asked for,
71
+ * else that of the fulltext index over every field searched, which is what serves the match.
72
+ */
70
73
  private textSearchParts;
71
74
  /**
72
75
  * The document, `TO_TSVECTOR('english'::regconfig, COALESCE("a", '') || ' ' || COALESCE("b", ''))`, a
@@ -36,6 +36,7 @@ export const PG_FEATURES = {
36
36
  rowLocks: true,
37
37
  rowLockWithWindow: false,
38
38
  rowLockOf: true,
39
+ textScoreIndexes: false,
39
40
  orderedUpsertReturning: true,
40
41
  orderedJsonAggregates: true,
41
42
  narrowVectorTypes: false,
@@ -134,19 +135,22 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
134
135
  ctx.addValue(search.$value);
135
136
  ctx.append(')');
136
137
  }
137
- /** `TS_RANK` of the document the predicate matches, against the same search. */
138
- appendTextRank(ctx, meta, search) {
139
- const { document, query } = this.textSearchParts(meta, search);
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);
140
141
  ctx.append(`TS_RANK(${document}, ${query}`);
141
142
  ctx.addValue(search.$value);
142
143
  ctx.append('))');
143
144
  }
144
- /** The document a search reads and the search itself, open for its value. */
145
- textSearchParts(meta, search) {
146
- const keys = textSearchFields(meta, search);
147
- const index = fulltextIndexOver(meta, keys);
145
+ /**
146
+ * The document over `keys` and the search itself, open for its value, under the `$config` asked for,
147
+ * else that of the fulltext index over every field searched, which is what serves the match.
148
+ */
149
+ textSearchParts(meta, search, keys) {
150
+ const fields = textSearchFields(meta, search);
151
+ const index = fulltextIndexOver(meta, fields);
148
152
  const config = search.$config ?? (index && fulltextConfig(index));
149
- const columns = keys.map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
153
+ const columns = (keys ?? fields).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
150
154
  return {
151
155
  document: this.textSearchTarget(columns, config),
152
156
  query: `${this.textQueryFn}(${this.textConfigArg(config)}`,
@@ -1,6 +1,6 @@
1
1
  import { RelationAggregate, SOFT_DELETE_FILTER } from '../../type/index.js';
2
2
  import { isInlinedExpression } from '../../util/field.util.js';
3
- import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
3
+ import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, fulltextWeights, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
4
4
  import { ownRegistrations } from '../decorator/bag.js';
5
5
  /**
6
6
  * A map held on `globalThis` through the global symbol registry, so a single one survives multiple
@@ -101,11 +101,13 @@ export function defineHook(entity, methodName, event) {
101
101
  export function defineIndex(entity, index) {
102
102
  const meta = ensureWritableMeta(entity);
103
103
  const refs = memberRefs();
104
+ const columns = index.columns(refs).map(normalizeIndexColumn);
105
+ fulltextWeights({ type: index.type, entries: columns });
104
106
  (meta.indexes ??= []).push({
105
107
  ...index,
106
108
  unique: index.unique ?? false,
107
109
  where: index.where && entityWhere(index.where),
108
- columns: index.columns(refs).map(normalizeIndexColumn),
110
+ columns,
109
111
  include: index.include?.(refs).map((ref) => ref.key),
110
112
  });
111
113
  return meta;
@@ -239,7 +239,8 @@ export async function runDriftCheck(migrator, config) {
239
239
  }
240
240
  else {
241
241
  console.log('\nChecking for schema drift...');
242
- const expectedAST = buildEntityAST(await migrator.getSchemaGenerator(), config.entities);
242
+ const generator = await migrator.getSchemaGenerator();
243
+ const expectedAST = generator.buildAST?.(config.entities) ?? buildEntityAST(generator, config.entities);
243
244
  // Build actual schema from database
244
245
  const actualAST = await migrator.schemaIntrospector.introspect();
245
246
  // Detect drift. The dialect renders canonical types as SQL - without it every type formats as
@@ -15,6 +15,8 @@ export declare class IndexDdl<D extends AbstractSqlDialect = AbstractSqlDialect>
15
15
  getCreateIndexStatement(tableName: string, index: IndexSchema, opts?: {
16
16
  ifNotExists?: boolean;
17
17
  }): string;
18
+ /** What an index added to a table that has rows needs run after it to serve queries; nothing, mostly. */
19
+ settleStatements(_tableName: string, _index: IndexSchema): string[];
18
20
  /**
19
21
  * Index features this dialect can express. Everything here is supported by at least one engine and
20
22
  * refused by at least one other, so an index asking for a missing one is rejected rather than
@@ -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
  }
@@ -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
  }
@@ -68,7 +68,7 @@ export declare class SqliteDialect extends AbstractSqlDialect {
68
68
  */
69
69
  protected appendTextSearch<E>(ctx: QueryContext, entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
70
70
  /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
71
- protected appendTextRank<E>(ctx: QueryContext, meta: EntityMeta<E>): void;
71
+ protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>): void;
72
72
  protected jsonLength(slot: JsonSlot): string;
73
73
  /** `JSON_EACH` walks the array at the path itself, each element's `fullkey` naming it from the column. */
74
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,
@@ -164,7 +165,7 @@ export class SqliteDialect extends AbstractSqlDialect {
164
165
  ctx.addValue(search.$value);
165
166
  }
166
167
  /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
167
- appendTextRank(ctx, meta) {
168
+ appendTextScore(ctx, meta) {
168
169
  ctx.append(`-BM25(${this.escapedTableName(meta)})`);
169
170
  }
170
171
  jsonLength(slot) {
@@ -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. */
@@ -563,6 +563,11 @@ export type IndexColumnModifiers = {
563
563
  readonly nulls?: 'first' | 'last';
564
564
  /** Operator class, e.g. `jsonb_path_ops` for a smaller GIN index. Postgres only. */
565
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;
566
571
  /** Index a path inside a JSON column. See {@link IndexJsonPath}. */
567
572
  readonly jsonPath?: IndexJsonPath;
568
573
  /** Index every element of a JSON array. See {@link IndexJsonArray}. */
@@ -1,3 +1,4 @@
1
+ import type { IndexType } from '../schema/types.js';
1
2
  import { type CascadeType, type EntityData, type EntityId, type EntityIndexMeta, type EntityMeta, type FieldKey, type FieldOptions, type FieldUpdateOp, type JsonUpdateOp, type OnFieldCallback, type Query, type QueryAggMap, type QueryAggregateOp, type QueryExclude, type QueryGroupMap, type QueryOptions, QueryRaw, type QuerySearch, type QuerySelect, type QuerySelectValue, type QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryVectorSearch, type QueryWhere, type RelationKey, type UpdatePayload } from '../type/index.js';
2
3
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
3
4
  /** The keys of `payload` a write persists as columns. */
@@ -171,6 +172,24 @@ export declare function assertAggregateColumns(clauseMap: object, emitted: Reado
171
172
  export declare function fulltextConfig(index: {
172
173
  readonly config?: string;
173
174
  }): string;
175
+ /**
176
+ * Each column's weight in a fulltext index, 1 where it states none, or none at all where they are alike.
177
+ * Checked wherever it is read, so the migration, the search and its rank refuse the same declaration.
178
+ */
179
+ export declare function fulltextWeights(index: {
180
+ readonly type?: IndexType;
181
+ readonly entries: readonly {
182
+ readonly weight?: number;
183
+ }[];
184
+ }): readonly number[] | undefined;
185
+ /**
186
+ * A weighted fulltext index's lightest weight, and what each column weighs beyond it: the score over every
187
+ * column counts the lightest, and a column weighing more adds its own score times the rest.
188
+ */
189
+ export declare function textWeightSteps(weights: readonly number[]): {
190
+ lightest: number;
191
+ extra: number[];
192
+ };
174
193
  /** The fulltext index over exactly `fields`, in order, which a search of them is served by. */
175
194
  export declare function fulltextIndexOver<E>(meta: EntityMeta<E>, fields: readonly string[]): EntityIndexMeta<E> | undefined;
176
195
  /**
@@ -456,6 +456,35 @@ const DEFAULT_TEXT_CONFIG = 'simple';
456
456
  export function fulltextConfig(index) {
457
457
  return index.config ?? DEFAULT_TEXT_CONFIG;
458
458
  }
459
+ /** The largest weight MongoDB's text index takes, which truncates a fraction to the whole number below. */
460
+ const MAX_TEXT_WEIGHT = 99_999;
461
+ /**
462
+ * Each column's weight in a fulltext index, 1 where it states none, or none at all where they are alike.
463
+ * Checked wherever it is read, so the migration, the search and its rank refuse the same declaration.
464
+ */
465
+ export function fulltextWeights(index) {
466
+ if (!index.entries.some((entry) => entry.weight !== undefined)) {
467
+ return undefined;
468
+ }
469
+ if (index.type !== 'fulltext') {
470
+ throw new TypeError(`a column weight ranks a fulltext index, and this one is ${index.type ?? 'btree'}`);
471
+ }
472
+ const weights = index.entries.map(({ weight = 1 }) => {
473
+ if (!Number.isInteger(weight) || weight < 1 || weight > MAX_TEXT_WEIGHT) {
474
+ throw new TypeError(`a column weight is a whole number from 1 to ${MAX_TEXT_WEIGHT}, not ${weight}`);
475
+ }
476
+ return weight;
477
+ });
478
+ return new Set(weights).size > 1 ? weights : undefined;
479
+ }
480
+ /**
481
+ * A weighted fulltext index's lightest weight, and what each column weighs beyond it: the score over every
482
+ * column counts the lightest, and a column weighing more adds its own score times the rest.
483
+ */
484
+ export function textWeightSteps(weights) {
485
+ const lightest = Math.min(...weights);
486
+ return { lightest, extra: weights.map((weight) => weight - lightest) };
487
+ }
459
488
  /** The fulltext index over exactly `fields`, in order, which a search of them is served by. */
460
489
  export function fulltextIndexOver(meta, fields) {
461
490
  return meta.indexes?.find((index) => index.type === 'fulltext' &&
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "homepage": "https://uql-orm.dev",
4
4
  "description": "The JSON-native TypeScript ORM for Bun, Browsers, Edge, Deno, Node, Workers. Supports PostgreSQL, PGlite, MySQL, MariaDB, SQLite, CockroachDB, SQL Server, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.72.0",
6
+ "version": "0.72.1",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"