uql-orm 0.72.0 → 0.72.2

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 (48) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +3 -3
  3. package/dist/dialect/abstractDialect.d.ts +6 -3
  4. package/dist/dialect/abstractDialect.js +22 -1
  5. package/dist/dialect/abstractSqlDialect.d.ts +15 -8
  6. package/dist/dialect/abstractSqlDialect.js +61 -28
  7. package/dist/dialect/aliases.d.ts +1 -1
  8. package/dist/dialect/aliases.js +1 -1
  9. package/dist/dialect/mysqlLikeSqlDialect.d.ts +6 -3
  10. package/dist/dialect/mysqlLikeSqlDialect.js +9 -6
  11. package/dist/dialect/pgLikeSqlDialect.d.ts +8 -5
  12. package/dist/dialect/pgLikeSqlDialect.js +14 -11
  13. package/dist/dialect/queryJoins.d.ts +11 -1
  14. package/dist/dialect/queryJoins.js +14 -0
  15. package/dist/dialect/vectorSqlDialect.d.ts +0 -5
  16. package/dist/dialect/vectorSqlDialect.js +0 -15
  17. package/dist/entity/metadata/definition.js +4 -2
  18. package/dist/migrate/cli.js +2 -1
  19. package/dist/migrate/ddl/indexDdl.d.ts +2 -0
  20. package/dist/migrate/ddl/indexDdl.js +4 -0
  21. package/dist/migrate/ddl/mysqlIndexDdl.d.ts +5 -0
  22. package/dist/migrate/ddl/mysqlIndexDdl.js +7 -0
  23. package/dist/migrate/generator/mongoCommand.d.ts +4 -0
  24. package/dist/migrate/generator/mongoSchemaGenerator.js +5 -1
  25. package/dist/migrate/introspection/mongoIntrospector.d.ts +1 -1
  26. package/dist/migrate/introspection/mongoIntrospector.js +17 -6
  27. package/dist/migrate/schemaGenerator.d.ts +4 -1
  28. package/dist/migrate/schemaGenerator.js +14 -7
  29. package/dist/mongo/mongoDialect.d.ts +15 -2
  30. package/dist/mongo/mongoDialect.js +49 -8
  31. package/dist/mongo/mongodbQuerier.js +6 -7
  32. package/dist/mongo/mongodbQuerierPool.js +4 -1
  33. package/dist/mssql/mssqlDialect.js +1 -0
  34. package/dist/schema/indexDifferences.d.ts +2 -1
  35. package/dist/schema/indexDifferences.js +15 -1
  36. package/dist/schema/schemaASTBuilder.d.ts +2 -0
  37. package/dist/schema/schemaASTBuilder.js +23 -0
  38. package/dist/sqlite/sqliteDialect.d.ts +5 -5
  39. package/dist/sqlite/sqliteDialect.js +17 -7
  40. package/dist/type/dialect.d.ts +5 -0
  41. package/dist/type/entity.d.ts +5 -0
  42. package/dist/type/query.d.ts +11 -3
  43. package/dist/type/queryWhere.d.ts +3 -2
  44. package/dist/util/dialect.util.d.ts +25 -1
  45. package/dist/util/dialect.util.js +37 -0
  46. package/dist/util/wideNumber.d.ts +5 -3
  47. package/dist/util/wideNumber.js +8 -4
  48. package/package.json +1 -1
@@ -2,7 +2,7 @@ import { AGGREGATE_VALUE_ALIAS } from '../dialect/aliases.js';
2
2
  import { hasRequiredJoin } from '../dialect/queryJoins.js';
3
3
  import { fieldOf, getMeta, namesKey, soleIdOf } from '../entity/index.js';
4
4
  import { AbstractQuerier, enrichError } from '../querier/index.js';
5
- import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
5
+ import { clone, getKeys, getSoftDeleteValue, hasKeys, populatesRelations, textSortOf, throwNoPendingTransaction, throwPendingTransaction, vectorCandidates, withoutSoftDeleteFilter, } from '../util/index.js';
6
6
  /**
7
7
  * `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
8
8
  * as *unlimited*, so a read that passed it straight to the driver came back with the whole
@@ -77,7 +77,8 @@ export class MongodbQuerier extends AbstractQuerier {
77
77
  populatesRelations(getMeta(entity), q.$populate) ||
78
78
  this.dialect.constrainsRelations(entity, q.$where) ||
79
79
  this.dialect.sortsRelations(entity, q.$sort) ||
80
- this.dialect.readsAggregates(entity, q));
80
+ this.dialect.readsAggregates(entity, q) ||
81
+ textSortOf(q.$sort) !== undefined);
81
82
  }
82
83
  buildScalarProjection(entity, q) {
83
84
  return this.dialect.select(entity, q.$select, q.$exclude);
@@ -114,20 +115,18 @@ export class MongodbQuerier extends AbstractQuerier {
114
115
  const scoreAlias = vectorSort.vectorSearch.$project;
115
116
  return [
116
117
  this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts, vectorCandidates(q)),
117
- // The score becomes a real field before anything reads it, so the lookups and the projection
118
- // that follow treat it like any other - and a query with no projection keeps its own columns.
119
- ...(scoreAlias ? [{ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } }] : []),
120
118
  // `$vectorSearch` has already applied `$limit`, so the pager is its own.
121
119
  ...this.dialect.readStages(entity, q, {
122
120
  sort: this.dialect.sort(entity, { ...q, $sort: vectorSort.regularSort }),
123
- project: scoreAlias ? { [scoreAlias]: 1 } : undefined,
121
+ score: scoreAlias ? { field: scoreAlias, meta: 'vectorSearchScore' } : undefined,
124
122
  }),
125
123
  ];
126
124
  }
127
125
  async internalAggregate(entity, q, opts) {
128
126
  return this.timed('internalAggregate', undefined, async () => {
129
127
  const pipeline = this.dialect.buildAggregateStages(entity, q, opts);
130
- return this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
128
+ const rows = await this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
129
+ return this.dialect.normalizeAggregateRows(entity, q, rows);
131
130
  });
132
131
  }
133
132
  /**
@@ -7,7 +7,10 @@ export class MongodbQuerierPool extends AbstractQuerierPool {
7
7
  client;
8
8
  constructor(uri, opts, extra) {
9
9
  super(new MongoDialect(dialectOptionsFrom(extra)), extra);
10
- this.client = new MongoClient(uri, opts);
10
+ // A 64-bit integer read as the exact `bigint` it is, where the driver would round it past 2^53 or
11
+ // hand back its own `Long`; each read then decodes it the way every SQL driver does. First, as the
12
+ // MySQL pool's `supportBigNumbers` is, so an explicit choice of the caller's wins.
13
+ this.client = new MongoClient(uri, { useBigInt64: true, ...opts });
11
14
  }
12
15
  async getQuerier() {
13
16
  const conn = await this.client.connect();
@@ -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
+ * `textIndex` is a text index's weights and language, 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' | 'textIndex';
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.
@@ -1,4 +1,5 @@
1
1
  import { isVectorIndexType } from '../type/vector.js';
2
+ import { fulltextConfig } from '../util/dialect.util.js';
2
3
  /**
3
4
  * Whether the table has this index already, by shape rather than name, uniqueness included. An index
4
5
  * over an expression, whose text the engine reprints, falls back to its name.
@@ -35,11 +36,17 @@ export function describeIndexDifferences(source, target, facets) {
35
36
  const differences = [];
36
37
  const comparableEntries = ![...source.entries, ...target.entries].some((entry) => entry.expression || entry.jsonPath || entry.jsonArray);
37
38
  if (comparableEntries) {
38
- const [sourceColumns, targetColumns] = [source.entries, target.entries].map((entries) => entries.map((entry) => entrySignature(entry, facets)).join(', '));
39
+ const [sourceColumns, targetColumns] = [source, target].map((index) => textFieldOrder(index, facets, index.entries.map((entry) => entrySignature(entry, facets))).join(', '));
39
40
  if (sourceColumns !== targetColumns) {
40
41
  differences.push(`columns: (${targetColumns}) -> (${sourceColumns})`);
41
42
  }
42
43
  }
44
+ if (facets.has('textIndex') && source.type === 'fulltext' && target.type === 'fulltext') {
45
+ const [expected, actual] = [fulltextConfig(source), fulltextConfig(target)];
46
+ if (expected !== actual) {
47
+ differences.push(`config: ${actual} -> ${expected}`);
48
+ }
49
+ }
43
50
  if (source.unique !== target.unique) {
44
51
  differences.push(`unique: ${target.unique} -> ${source.unique}`);
45
52
  }
@@ -59,6 +66,10 @@ export function describeIndexDifferences(source, target, facets) {
59
66
  }
60
67
  return differences;
61
68
  }
69
+ /** A text index's fields as a set where the engine keeps its weights: MongoDB lists them alphabetically. */
70
+ function textFieldOrder(index, facets, entries) {
71
+ return facets.has('textIndex') && index.type === 'fulltext' ? entries.toSorted() : entries;
72
+ }
62
73
  function entrySignature(entry, facets) {
63
74
  const parts = [entry.column];
64
75
  if (facets.has('order')) {
@@ -72,5 +83,8 @@ function entrySignature(entry, facets) {
72
83
  if (facets.has('opsClass') && entry.opsClass) {
73
84
  parts.push(entry.opsClass);
74
85
  }
86
+ if (facets.has('textIndex') && (entry.weight ?? 1) !== 1) {
87
+ parts.push(`weight ${entry.weight}`);
88
+ }
75
89
  return parts.join(' ');
76
90
  }
@@ -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
  }
@@ -1,6 +1,6 @@
1
1
  import { AbstractSqlDialect, type DerivedRelation, type HydrateKind, type RelationRows } from '../dialect/abstractSqlDialect.js';
2
2
  import { type JsonAccessMode, type JsonSlot } from '../dialect/jsonSql.js';
3
- import { type EntityMeta, type FieldOptions, type Query, type QueryContext, type QueryPager, type QueryTextSearchOptions, type QueryWhere, type SqlDialectFeatures, type Type, type VectorDistance, type VectorMetric } from '../type/index.js';
3
+ import { type EntityMeta, type FieldOptions, type Query, type QueryContext, type QueryPager, type QueryTextSearchOptions, type QueryWhere, type SqlDialectFeatures, type VectorDistance, type VectorMetric } from '../type/index.js';
4
4
  /** What SQLite and the engines derived from it have. */
5
5
  export declare const SQLITE_FEATURES: SqlDialectFeatures;
6
6
  export declare class SqliteDialect extends AbstractSqlDialect {
@@ -63,12 +63,12 @@ export declare class SqliteDialect extends AbstractSqlDialect {
63
63
  /** A date reads back as SQLite stored it, a number or text, which JSON carries unchanged. */
64
64
  protected hydrateKind(field: FieldOptions | undefined): HydrateKind | undefined;
65
65
  /**
66
- * FTS5 matches the table itself rather than its columns, so this only works when the table *is* an
67
- * FTS5 virtual table (UQL does not create those; declare it outside your entities).
66
+ * FTS5 matches the table itself, so this works only where the table *is* an FTS5 virtual table (UQL does
67
+ * not create those; declare it outside your entities). The whole query is bound, column filter and all.
68
68
  */
69
- protected appendTextSearch<E>(ctx: QueryContext, entity: Type<E>, meta: EntityMeta<E>, search: QueryTextSearchOptions<E>): void;
69
+ protected appendTextSearch<E>(ctx: QueryContext, 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;
@@ -6,6 +6,15 @@ import { indexDistance, isVectorIndexType } from '../type/vector.js';
6
6
  import { declaredIndexName } from '../util/ddlExpression.util.js';
7
7
  import { findVectorIndex, findVectorSort, textSearchFields, vectorCandidates } from '../util/dialect.util.js';
8
8
  import { columnFamily, isIntegerColumn } from '../util/field.util.js';
9
+ /**
10
+ * An FTS5 query over `columns` for what a person typed: each word a quoted string, which FTS5 reads as a
11
+ * term to match and never as syntax, and every one required, as the other engines read plain words.
12
+ */
13
+ function ftsQuery(columns, value) {
14
+ const quote = (text) => `"${text.replaceAll('"', '""')}"`;
15
+ const words = value.split(/\s+/).filter(Boolean);
16
+ return `{${columns.map(quote).join(' ')}} : (${words.map(quote).join(' ') || '""'})`;
17
+ }
9
18
  /** What SQLite and the engines derived from it have. */
10
19
  export const SQLITE_FEATURES = {
11
20
  ifNotExists: true,
@@ -27,6 +36,7 @@ export const SQLITE_FEATURES = {
27
36
  rowLocks: false,
28
37
  rowLockWithWindow: true,
29
38
  rowLockOf: true,
39
+ textScoreIndexes: false,
30
40
  orderedUpsertReturning: true,
31
41
  orderedJsonAggregates: true,
32
42
  narrowVectorTypes: false,
@@ -155,16 +165,16 @@ export class SqliteDialect extends AbstractSqlDialect {
155
165
  return columnFamily(field?.type) === 'date' ? undefined : super.hydrateKind(field);
156
166
  }
157
167
  /**
158
- * FTS5 matches the table itself rather than its columns, so this only works when the table *is* an
159
- * FTS5 virtual table (UQL does not create those; declare it outside your entities).
168
+ * FTS5 matches the table itself, so this works only where the table *is* an FTS5 virtual table (UQL does
169
+ * not create those; declare it outside your entities). The whole query is bound, column filter and all.
160
170
  */
161
- appendTextSearch(ctx, entity, meta, search) {
162
- const columns = textSearchFields(meta, search).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
163
- ctx.append(`${this.escapedTableName(meta)} MATCH {${columns.join(' ')}} : `);
164
- ctx.addValue(search.$value);
171
+ appendTextSearch(ctx, meta, search) {
172
+ const columns = textSearchFields(meta, search).map((key) => this.resolveColumnName(key, meta.fields[key]));
173
+ ctx.append(`${this.escapedTableName(meta)} MATCH `);
174
+ ctx.addValue(ftsQuery(columns, search.$value));
165
175
  }
166
176
  /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
167
- appendTextRank(ctx, meta) {
177
+ appendTextScore(ctx, meta) {
168
178
  ctx.append(`-BM25(${this.escapedTableName(meta)})`);
169
179
  }
170
180
  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}. */
@@ -130,12 +130,20 @@ export type QuerySortByCount = {
130
130
  $count: QuerySortDirection;
131
131
  };
132
132
  /**
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).
133
+ * Ordering by relevance to the `$text` at the root of `$where`, in either direction as any key sorts. The
134
+ * object form also answers it under the name `$project` gives it, most relevant first unless `$order` says.
135
135
  */
136
136
  export type QuerySortByText = {
137
- $text?: -1 | 'desc';
137
+ $text?: QuerySortDirection | {
138
+ readonly $project: string;
139
+ readonly $order?: QuerySortDirection;
140
+ };
138
141
  };
142
+ /**
143
+ * A row with the relevance a `$sort: { $text: { $project } }` names, which is not inferred:
144
+ * `(await querier.findMany(Post, q)) as WithScore<Post, 'score'>[]`.
145
+ */
146
+ export type WithScore<E, K extends string> = E & Record<K, number>;
139
147
  /**
140
148
  * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count`, or a vector distance or
141
149
  * `$text` relevance, which `Vector` confines to the queried entity. One mapped type over the key sets: an
@@ -16,8 +16,9 @@ export type QueryTextSearchOptions<E> = {
16
16
  */
17
17
  $fields?: QuerySelect<E>;
18
18
  /**
19
- * Postgres text-search configuration (e.g. `'english'`), applied to both the document and the
20
- * query. Defaults to the server's `default_text_search_config`. Ignored by other dialects.
19
+ * The language the search is parsed in (e.g. `'english'`, or `'simple'` for no stemming), else that of
20
+ * the fulltext index over its fields: the Postgres family's text-search config, MongoDB's `$language`.
21
+ * MySQL and SQLite parse by their index alone.
21
22
  */
22
23
  $config?: string;
23
24
  };
@@ -1,4 +1,5 @@
1
- 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';
1
+ import type { IndexType } from '../schema/types.js';
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 QuerySortDirection, 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. */
4
5
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E> | UpdatePayload<E>, callbackKey: CallbackKey): FieldKey<E>[];
@@ -171,8 +172,31 @@ 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;
195
+ /** How a `$sort` orders by `$text`: its direction, and the name it answers the relevance under, if any. */
196
+ export declare function textSortOf<E>(sort: QuerySortMap<E> | undefined): {
197
+ readonly order: QuerySortDirection;
198
+ readonly project?: string;
199
+ } | undefined;
176
200
  /**
177
201
  * The search a `$sort` by `$text` ranks by: the one at the root of the same query's `$where`. A nested or
178
202
  * negated one has no score to order by, and MongoDB scores only the one `$text` it allows.
@@ -456,12 +456,49 @@ 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' &&
462
491
  index.columns.length === fields.length &&
463
492
  index.columns.every((entry, at) => entry.column === fields[at]));
464
493
  }
494
+ /** How a `$sort` orders by `$text`: its direction, and the name it answers the relevance under, if any. */
495
+ export function textSortOf(sort) {
496
+ const text = sort?.$text;
497
+ if (text === undefined) {
498
+ return undefined;
499
+ }
500
+ return isRecord(text) ? { order: text.$order ?? 'desc', project: text.$project } : { order: text };
501
+ }
465
502
  /**
466
503
  * The search a `$sort` by `$text` ranks by: the one at the root of the same query's `$where`. A nested or
467
504
  * negated one has no score to order by, and MongoDB scores only the one `$text` it allows.
@@ -7,8 +7,10 @@ import type { RawRow } from '../type/index.js';
7
7
  */
8
8
  export declare function decodeWideNumber(value: string | bigint): number | string;
9
9
  /**
10
- * {@link decodeWideNumber} over every `bigint` cell of a row, for the drivers that hand a BIGINT back
11
- * as one (`bun:sql`, `mariadb`, and every SQLite driver but D1). In place: the row is the driver's fresh
12
- * object, and a copy per row cost more than the decode it carried.
10
+ * {@link decodeWideNumber} over every `bigint` cell of a row, for the drivers that hand a BIGINT back as
11
+ * one (`bun:sql`, `mariadb`, and every SQLite driver but D1). In place: the row is the driver's fresh
12
+ * object, and a copy per row cost more than the decode it carried. One argument, so it maps rows as is.
13
13
  */
14
14
  export declare function decodeBigInts(row: RawRow): RawRow;
15
+ /** {@link decodeBigInts}, keeping the cells `exact` names: what MongoDB reads a `BigInt` field into. */
16
+ export declare function decodeBigIntsExcept(row: RawRow, exact: (key: string) => boolean): RawRow;
@@ -9,14 +9,18 @@ export function decodeWideNumber(value) {
9
9
  return Math.abs(decoded) <= Number.MAX_SAFE_INTEGER ? decoded : String(value);
10
10
  }
11
11
  /**
12
- * {@link decodeWideNumber} over every `bigint` cell of a row, for the drivers that hand a BIGINT back
13
- * as one (`bun:sql`, `mariadb`, and every SQLite driver but D1). In place: the row is the driver's fresh
14
- * object, and a copy per row cost more than the decode it carried.
12
+ * {@link decodeWideNumber} over every `bigint` cell of a row, for the drivers that hand a BIGINT back as
13
+ * one (`bun:sql`, `mariadb`, and every SQLite driver but D1). In place: the row is the driver's fresh
14
+ * object, and a copy per row cost more than the decode it carried. One argument, so it maps rows as is.
15
15
  */
16
16
  export function decodeBigInts(row) {
17
+ return decodeBigIntsExcept(row, () => false);
18
+ }
19
+ /** {@link decodeBigInts}, keeping the cells `exact` names: what MongoDB reads a `BigInt` field into. */
20
+ export function decodeBigIntsExcept(row, exact) {
17
21
  for (const key in row) {
18
22
  const value = row[key];
19
- if (typeof value === 'bigint') {
23
+ if (typeof value === 'bigint' && !exact(key)) {
20
24
  row[key] = decodeWideNumber(value);
21
25
  }
22
26
  }
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.2",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"