uql-orm 0.72.1 → 0.73.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/browser/uql-browser.min.js +2 -2
  2. package/dist/browser/uql-browser.min.js.map +3 -3
  3. package/dist/dialect/abstractDialect.d.ts +6 -0
  4. package/dist/dialect/abstractDialect.js +22 -1
  5. package/dist/dialect/abstractSqlDialect.d.ts +7 -6
  6. package/dist/dialect/abstractSqlDialect.js +46 -38
  7. package/dist/dialect/aliases.d.ts +1 -1
  8. package/dist/dialect/aliases.js +1 -1
  9. package/dist/dialect/hydrateColumn.js +1 -1
  10. package/dist/dialect/mysqlLikeSqlDialect.d.ts +2 -2
  11. package/dist/dialect/mysqlLikeSqlDialect.js +4 -5
  12. package/dist/dialect/pgLikeSqlDialect.d.ts +3 -3
  13. package/dist/dialect/pgLikeSqlDialect.js +6 -7
  14. package/dist/dialect/queryJoins.d.ts +11 -1
  15. package/dist/dialect/queryJoins.js +14 -0
  16. package/dist/dialect/vectorSqlDialect.d.ts +0 -5
  17. package/dist/dialect/vectorSqlDialect.js +0 -15
  18. package/dist/migrate/generator/mongoCommand.d.ts +2 -0
  19. package/dist/migrate/generator/mongoSchemaGenerator.js +3 -2
  20. package/dist/migrate/introspection/mongoIntrospector.js +8 -3
  21. package/dist/mongo/mongoDialect.d.ts +15 -2
  22. package/dist/mongo/mongoDialect.js +67 -18
  23. package/dist/mongo/mongodbQuerier.js +6 -7
  24. package/dist/mongo/mongodbQuerierPool.js +4 -1
  25. package/dist/schema/canonicalType.js +1 -6
  26. package/dist/schema/indexDifferences.d.ts +2 -2
  27. package/dist/schema/indexDifferences.js +9 -2
  28. package/dist/sqlite/sqliteDialect.d.ts +4 -4
  29. package/dist/sqlite/sqliteDialect.js +17 -7
  30. package/dist/type/dialect.d.ts +19 -6
  31. package/dist/type/query.d.ts +17 -9
  32. package/dist/type/queryWhere.d.ts +3 -2
  33. package/dist/type/vector.d.ts +0 -5
  34. package/dist/util/dialect.util.d.ts +13 -9
  35. package/dist/util/dialect.util.js +27 -7
  36. package/dist/util/raw.js +8 -2
  37. package/dist/util/wideNumber.d.ts +5 -3
  38. package/dist/util/wideNumber.js +8 -4
  39. package/package.json +1 -1
  40. package/skills/uql-orm/SKILL.md +3 -0
@@ -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,10 +63,10 @@ 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
71
  protected appendTextScore<E>(ctx: QueryContext, meta: EntityMeta<E>): void;
72
72
  protected jsonLength(slot: JsonSlot): 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,
@@ -146,7 +155,8 @@ export class SqliteDialect extends AbstractSqlDialect {
146
155
  carriedFields = {
147
156
  numeric: (expr, field) => (isIntegerColumn(field) ? `CAST(${expr} AS TEXT)` : expr),
148
157
  blob: (expr) => this.bytesAsText(expr),
149
- vector: (expr) => `CASE WHEN typeof(${expr}) = 'blob' THEN ${this.bytesAsText(expr)} ELSE ${expr} END`,
158
+ // D1 keeps a vector as its text; every other engine here, as float32 bytes.
159
+ vector: (expr) => (this.features.vectorBytes ? this.bytesAsText(expr) : expr),
150
160
  };
151
161
  bytesAsText(expr) {
152
162
  return `${this.escape(BYTES_PREFIX)} || hex(${expr})`;
@@ -156,13 +166,13 @@ export class SqliteDialect extends AbstractSqlDialect {
156
166
  return columnFamily(field?.type) === 'date' ? undefined : super.hydrateKind(field);
157
167
  }
158
168
  /**
159
- * FTS5 matches the table itself rather than its columns, so this only works when the table *is* an
160
- * FTS5 virtual table (UQL does not create those; declare it outside your entities).
169
+ * FTS5 matches the table itself, so this works only where the table *is* an FTS5 virtual table (UQL does
170
+ * not create those; declare it outside your entities). The whole query is bound, column filter and all.
161
171
  */
162
- appendTextSearch(ctx, entity, meta, search) {
163
- const columns = textSearchFields(meta, search).map((key) => this.escapeId(this.resolveColumnName(key, meta.fields[key])));
164
- ctx.append(`${this.escapedTableName(meta)} MATCH {${columns.join(' ')}} : `);
165
- ctx.addValue(search.$value);
172
+ appendTextSearch(ctx, meta, search) {
173
+ const columns = textSearchFields(meta, search).map((key) => this.resolveColumnName(key, meta.fields[key]));
174
+ ctx.append(`${this.escapedTableName(meta)} MATCH `);
175
+ ctx.addValue(ftsQuery(columns, search.$value));
166
176
  }
167
177
  /** FTS5's `BM25` of the match, lower for a better one, so negated to rank as every other engine does. */
168
178
  appendTextScore(ctx, meta) {
@@ -1,6 +1,7 @@
1
1
  import type { EntityMeta, UpdatePayload } from './entity.js';
2
2
  import type { Query, QueryConflictPaths, QueryOptions, QueryPage, QuerySearch, RelationQuery } from './query.js';
3
3
  import type { QueryAggMap, QueryAggregate, QueryAggregateOp, QueryGroupMap } from './queryAggregate.js';
4
+ import type { QueryWhere } from './queryWhere.js';
4
5
  import type { Type } from './utility.js';
5
6
  /**
6
7
  * comparison options.
@@ -236,6 +237,16 @@ export interface SqlQueryDialect {
236
237
  * a row change turns into a delta.
237
238
  */
238
239
  export type RelationAggregateOp = QueryAggregateOp;
240
+ /**
241
+ * One aggregate as every renderer reads it: its op, the field it reads - none counts the rows - and the
242
+ * rows it reads, where not all of them. A statement's `$select` entry and a relation aggregate are each
243
+ * this, beside what names the rows they aggregate over.
244
+ */
245
+ export type AggregateCall<E = object> = {
246
+ readonly op: QueryAggregateOp;
247
+ readonly field?: string;
248
+ readonly where?: QueryWhere<E>;
249
+ };
239
250
  /** What a relation aggregate reads: how many rows, or one of the target's columns. */
240
251
  export type RelationAggregateProjection = {
241
252
  readonly op: '$count';
@@ -244,14 +255,16 @@ export type RelationAggregateProjection = {
244
255
  readonly op: Exclude<RelationAggregateOp, '$count'>;
245
256
  readonly field: string;
246
257
  };
247
- /** A relation aggregate as a `computed` field holds it: what it reads, off which relation, filtered how. */
248
- export type RelationAggregateSpec = RelationAggregateProjection & {
258
+ /**
259
+ * A relation aggregate as a `computed` field holds it: an {@link AggregateCall} over the rows of the
260
+ * relation it names, capped where it declared a page.
261
+ */
262
+ export type RelationAggregateSpec = RelationAggregateProjection & Pick<AggregateCall, 'where'> & {
249
263
  readonly relation: string;
250
- /** Which of the related rows it reads, and the page it caps them to, as the field declared them. */
251
- readonly query?: RelationSubqueryQuery;
264
+ readonly page?: RelationAggregatePage;
252
265
  };
253
- /** The rows a relation subquery reads: which ones, in what order, and the page capping them. */
254
- export type RelationSubqueryQuery = Pick<RelationQuery, '$where' | '$sort' | '$limit' | '$skip'>;
266
+ /** The page of a relation's rows an aggregate reads, and the order picking them. */
267
+ export type RelationAggregatePage = Pick<RelationQuery, '$sort' | '$limit' | '$skip'>;
255
268
  /**
256
269
  * Supported SQL dialect identifiers.
257
270
  */
@@ -130,22 +130,30 @@ 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
  };
139
142
  /**
140
- * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count`, or a vector distance or
141
- * `$text` relevance, which `Vector` confines to the queried entity. One mapped type over the key sets: an
142
- * intersection is checked once per member, which made this the costliest type to check.
143
+ * A row with the value a `$sort` projects under the name its `$project` gives - a vector's distance, or a
144
+ * `$text` relevance - which is not inferred: `(await querier.findMany(Post, q)) as WithProjection<Post, 'score'>[]`.
145
+ */
146
+ export type WithProjection<E, K extends string> = E & Record<K, number>;
147
+ /**
148
+ * A sort by fields, JSON paths, a to-one relation's fields, a to-many's `$count`, or - where `Root` says it
149
+ * sorts the queried entity itself, not a relation's rows - a vector distance or a `$text` relevance. One
150
+ * mapped type over the key sets: an intersection is checked once per member, which made this the costliest.
143
151
  */
144
- export type QuerySortMap<E, Vector extends boolean = true, K extends keyof E = FieldKey<E> | RelationKey<E>> = {
145
- [P in K]?: P extends RelationKey<E> ? IsMany<E[P]> extends true ? QuerySortByCount : QuerySortMap<RelationTarget<E[P]>, false> : Vector extends true ? NonNullable<E[P]> extends readonly number[] ? QuerySortValue : QuerySortDirection : QuerySortDirection;
152
+ export type QuerySortMap<E, Root extends boolean = true, K extends keyof E = FieldKey<E> | RelationKey<E>> = {
153
+ [P in K]?: P extends RelationKey<E> ? IsMany<E[P]> extends true ? QuerySortByCount : QuerySortMap<RelationTarget<E[P]>, false> : Root extends true ? NonNullable<E[P]> extends readonly number[] ? QuerySortValue : QuerySortDirection : QuerySortDirection;
146
154
  } & ([JsonFieldPaths<E>] extends [never] ? unknown : {
147
155
  [P in JsonFieldPaths<E>]?: QuerySortDirection;
148
- }) & (Vector extends true ? QuerySortByText : unknown);
156
+ }) & (Root extends true ? QuerySortByText : unknown);
149
157
  /**
150
158
  * pager options.
151
159
  */
@@ -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
  };
@@ -23,11 +23,6 @@ export interface QueryVectorSearch extends QueryVectorQuery {
23
23
  /** Project the computed distance as a named field in the result. */
24
24
  readonly $project?: string;
25
25
  }
26
- /**
27
- * A row with the distance a `$sort` `$project` names, which is not inferred:
28
- * `(await querier.findMany(Article, q)) as WithDistance<Article, 'similarity'>[]`.
29
- */
30
- export type WithDistance<E, K extends string = '_distance'> = E & Record<K, number>;
31
26
  /**
32
27
  * How a dialect spells a metric: an operator, or a function taking the metric by name where `metricArg`
33
28
  * says so, and `index`, how the dialect's vector index names it where it builds one. One map serves the
@@ -1,5 +1,5 @@
1
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 QuerySizeComparisonOps, type QuerySortMap, type QueryTextSearchOptions, type QueryVectorSearch, type QueryWhere, type RelationKey, type UpdatePayload } from '../type/index.js';
2
+ import { type AggregateCall, 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 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';
3
3
  export type CallbackKey = keyof Pick<FieldOptions, 'onInsert' | 'onUpdate'>;
4
4
  /** The keys of `payload` a write persists as columns. */
5
5
  export declare function filterFieldKeys<E>(meta: EntityMeta<E>, payload: EntityData<E> | UpdatePayload<E>, callbackKey: CallbackKey): FieldKey<E>[];
@@ -90,8 +90,11 @@ export declare function hasVectorNear(where: unknown): boolean;
90
90
  export declare function isJsonUpdateOp(value: unknown): value is JsonUpdateOp;
91
91
  /** Type guard: checks whether an update payload value is a scalar field's operator. */
92
92
  export declare function isFieldUpdateOp(value: unknown): value is FieldUpdateOp;
93
- /** The one operator a scalar field's update carries, and its operand. */
94
- export declare function fieldUpdateOf(value: FieldUpdateOp): [keyof FieldUpdateOp, number | bigint];
93
+ /**
94
+ * The one operator a scalar field's update carries, and its operand. Naming both throws rather than
95
+ * reading one: their order would change the result, and an untyped payload is how both arrive.
96
+ */
97
+ export declare function fieldUpdateOf(key: string, value: FieldUpdateOp): [keyof FieldUpdateOp, number | bigint];
95
98
  /**
96
99
  * The `$where` naming rows by key: a bare value names the one key column (refused on a composite), a
97
100
  * composite's key map is a `$where` already, and a list is an `IN` of bare values or an OR of maps.
@@ -118,16 +121,12 @@ export type ParsedGroupEntry<E = object> = {
118
121
  readonly alias: string;
119
122
  /** The field it reads, behind the to-one relations leading to it: `['transaction', 'orderId']`. */
120
123
  readonly path: readonly string[];
121
- } | {
124
+ } | (AggregateCall<E> & {
122
125
  readonly kind: 'fn';
123
126
  readonly alias: string;
124
- readonly op: QueryAggregateOp;
125
- readonly fieldRef: string;
126
127
  /** `true` for `$countDistinct`: `COUNT(DISTINCT field)`. */
127
128
  readonly distinct: boolean;
128
- /** The rows it reads, where not all of the statement's. */
129
- readonly where?: QueryWhere<E>;
130
- };
129
+ });
131
130
  /**
132
131
  * The `$size` of a relation condition, `{ comments: { $size: { $gte: 2 } } }`, or `undefined` where it
133
132
  * filters the target's fields; a mix of the two, `{ $size: 2, name: 'x' }`, is refused.
@@ -192,6 +191,11 @@ export declare function textWeightSteps(weights: readonly number[]): {
192
191
  };
193
192
  /** The fulltext index over exactly `fields`, in order, which a search of them is served by. */
194
193
  export declare function fulltextIndexOver<E>(meta: EntityMeta<E>, fields: readonly string[]): EntityIndexMeta<E> | undefined;
194
+ /** How a `$sort` orders by `$text`: its direction, and the name it answers the relevance under, if any. */
195
+ export declare function textSortOf<E>(sort: QuerySortMap<E> | undefined): {
196
+ readonly order: QuerySortDirection;
197
+ readonly project?: string;
198
+ } | undefined;
195
199
  /**
196
200
  * The search a `$sort` by `$text` ranks by: the one at the root of the same query's `$where`. A nested or
197
201
  * negated one has no score to order by, and MongoDB scores only the one `$text` it allows.
@@ -242,8 +242,14 @@ const FIELD_UPDATE_OPS = ['$inc', '$mul'];
242
242
  export function isFieldUpdateOp(value) {
243
243
  return isRecord(value) && someKey(value, (key) => FIELD_UPDATE_OPS.includes(key));
244
244
  }
245
- /** The one operator a scalar field's update carries, and its operand. */
246
- export function fieldUpdateOf(value) {
245
+ /**
246
+ * The one operator a scalar field's update carries, and its operand. Naming both throws rather than
247
+ * reading one: their order would change the result, and an untyped payload is how both arrive.
248
+ */
249
+ export function fieldUpdateOf(key, value) {
250
+ if (value.$inc !== undefined && value.$mul !== undefined) {
251
+ throw new TypeError(`'${key}' takes one of $inc and $mul`);
252
+ }
247
253
  return value.$inc === undefined ? ['$mul', value.$mul] : ['$inc', value.$inc];
248
254
  }
249
255
  /**
@@ -379,8 +385,11 @@ export function parseGroupMap(group, select) {
379
385
  }
380
386
  // `$countDistinct` normalizes to `$count` plus a `distinct` flag.
381
387
  const { op, distinct } = resolveAggregateOp(key);
382
- const fieldRef = aggregateFieldRef(alias, call[key]);
383
- entries.push({ kind: 'fn', alias, op, fieldRef, distinct, ...(hasKeys(where) ? { where } : {}) });
388
+ const field = aggregateField(alias, call[key]);
389
+ if (field === undefined && (op !== '$count' || distinct)) {
390
+ throw new TypeError(`aggregate '${alias}' takes '*' only as a $count`);
391
+ }
392
+ entries.push({ kind: 'fn', alias, op, distinct, ...(field && { field }), ...(hasKeys(where) ? { where } : {}) });
384
393
  }
385
394
  return entries;
386
395
  }
@@ -392,9 +401,12 @@ function groupRefPath(alias, ref) {
392
401
  }
393
402
  return ref[key] === true ? [key] : [key, ...groupRefPath(alias, ref[key])];
394
403
  }
395
- /** The column an aggregate reads: `'*'`, or the one field its `{ field: true }` names. */
396
- function aggregateFieldRef(alias, arg) {
397
- const [field, ...rest] = arg === '*' ? [arg] : namedKeys(arg);
404
+ /** The field an aggregate reads, the one its `{ field: true }` names; none for `'*'`, which counts the rows. */
405
+ function aggregateField(alias, arg) {
406
+ if (arg === '*') {
407
+ return undefined;
408
+ }
409
+ const [field, ...rest] = namedKeys(arg);
398
410
  if (field === undefined || rest.length) {
399
411
  throw new TypeError(`aggregate '${alias}' takes one field as { field: true }, or '*': got ${JSON.stringify(arg)}`);
400
412
  }
@@ -491,6 +503,14 @@ export function fulltextIndexOver(meta, fields) {
491
503
  index.columns.length === fields.length &&
492
504
  index.columns.every((entry, at) => entry.column === fields[at]));
493
505
  }
506
+ /** How a `$sort` orders by `$text`: its direction, and the name it answers the relevance under, if any. */
507
+ export function textSortOf(sort) {
508
+ const text = sort?.$text;
509
+ if (text === undefined) {
510
+ return undefined;
511
+ }
512
+ return isRecord(text) ? { order: text.$order ?? 'desc', project: text.$project } : { order: text };
513
+ }
494
514
  /**
495
515
  * The search a `$sort` by `$text` ranks by: the one at the root of the same query's `$where`. A nested or
496
516
  * negated one has no score to order by, and MongoDB scores only the one `$text` it allows.
package/dist/util/raw.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { getMeta } from '../entity/metadata/definition.js';
2
2
  import { ColumnRef, QueryRaw, RelationAggregate, } from '../type/index.js';
3
3
  import { isInlinedExpression } from './field.util.js';
4
+ import { hasKeys } from './object.util.js';
4
5
  export function raw(value, ...rest) {
5
6
  if (!isTemplateStrings(value)) {
6
7
  return new QueryRaw(value);
@@ -49,15 +50,20 @@ export function entityWhere(where) {
49
50
  * a member decorator sees no class and so cannot know which the key is; the types keep them apart.
50
51
  */
51
52
  function memberRef(relation) {
52
- const over = (op) => (pick, q) => relationAggregate({ relation, op, field: pick(memberRefs()).key, ...(q && { query: q }) });
53
+ const over = (op) => (pick, q) => relationAggregate({ relation, op, field: pick(memberRefs()).key, ...rowsOf(q) });
53
54
  return Object.assign(columnRef(undefined, relation), {
54
- count: (q) => relationAggregate({ relation, op: '$count', ...(q && { query: q }) }),
55
+ count: (q) => relationAggregate({ relation, op: '$count', ...rowsOf(q) }),
55
56
  sum: over('$sum'),
56
57
  min: over('$min'),
57
58
  max: over('$max'),
58
59
  avg: over('$avg'),
59
60
  });
60
61
  }
62
+ /** The rows an aggregate's declared query reads: its `$where`, and the page the rest of it caps them to. */
63
+ function rowsOf(q) {
64
+ const { $where, ...page } = q ?? {};
65
+ return { ...($where && { where: $where }), ...(hasKeys(page) && { page }) };
66
+ }
61
67
  /**
62
68
  * A relation aggregate as SQL: the dialect writes the same correlated subquery a `$count` reads,
63
69
  * correlated to whichever alias the clause naming the field is rendering under.
@@ -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.1",
6
+ "version": "0.73.0",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -103,6 +103,9 @@ const users = await pool.findMany(User, {
103
103
  - `$where` takes a value for equality or an operator map: `$eq`, `$ne`, `$lt`, `$lte`, `$gt`, `$gte`, `$in`,
104
104
  `$nin`, `$between`, `$like`, `$ilike`, `$regex`, `$startsWith`, `$endsWith`, `$includes`, `$isNull`,
105
105
  `$isNotNull`. `$and`, `$or`, `$not` and `$nor` combine clauses.
106
+ - `$text: { $value }` in `$where` searches text on every engine with full-text search, through the entity's
107
+ `@Index(..., { type: 'fulltext', config })`, whose columns may carry a `weight`. `$sort: { $text: 'desc' }` ranks by
108
+ relevance, and `{ $text: { $project: 'score' } }` also returns it, typed with `WithProjection<E, 'score'>`.
106
109
  - A result is narrowed to what the query selected and populated: reading an unselected field is a compile error.
107
110
  Name that shape with `QueryFindResult<User, 'id' | 'email'>` rather than widening the query.
108
111
  - `$populate` loads relations in the same statement. Nothing is lazy: a relation not populated is not there.