uql-orm 0.28.0 → 0.28.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.
package/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  <a href="https://uql-orm.dev">
4
4
  <picture>
5
5
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/rogerpadilla/uql/main/assets/logo-dark.svg">
6
- <img src="https://raw.githubusercontent.com/rogerpadilla/uql/main/assets/logo.svg" alt="UQL" width="90">
6
+ <img src="https://raw.githubusercontent.com/rogerpadilla/uql/main/assets/logo.svg" alt="UQL" width="72" height="72">
7
7
  </picture>
8
8
  </a>
9
9
 
10
- <h3>The ORM with type-safe JSON queries</h3>
10
+ <h3>The JSON-native TypeScript ORM</h3>
11
11
 
12
- <p>Queries are plain JSON values, typed to the leaf. Build them, store them, send them from the browser, and run them unchanged on every SQL database and MongoDB. And it is the <a href="https://uql-orm.dev/benchmark">fastest</a>.</p>
12
+ <p>Queries are plain JSON, typed to the leaf. Unified across SQL databases and MongoDB.</p>
13
13
 
14
14
  <p>
15
15
  <a href="https://uql-orm.dev"><b>Website</b></a> ·
@@ -32,9 +32,7 @@
32
32
  npm install uql-orm pg # or mysql2, mariadb, better-sqlite3, mongodb, @tursodatabase/serverless, @libsql/client
33
33
  ```
34
34
 
35
- That is the whole install. Decorators are the standard TC39 ones, so there is no `reflect-metadata`
36
- and no compiler flag to turn on ([setup](https://uql-orm.dev/getting-started)), and the
37
- [imperative API](https://uql-orm.dev/entities/imperative) skips decorators altogether.
35
+ That is the whole install ([setup](https://uql-orm.dev/getting-started)), and the [imperative API](https://uql-orm.dev/entities/imperative) skips decorators altogether.
38
36
 
39
37
  ```ts
40
38
  await pool.findMany(User, {
@@ -51,14 +49,14 @@ from the browser to the server. The same object runs on every supported database
51
49
  ## Why UQL?
52
50
 
53
51
  - **Serializable queries (JSON), not method chains.** Plain JSON in, typed rows out. No DSL to learn.
54
- - **Type-safe to the leaf, nothing to generate.** Every key is checked against your entity, down into populated relations and [JSON/JSONB](https://uql-orm.dev/querying/json) dot-paths, so `$like` on a numeric column is a compile error. Entities are plain classes: no `.prisma` file, no generated client, no build step.
52
+ - **Type-safe to the leaf, nothing to generate.** Every key is checked against your entity, down into populated relations and [JSON/JSONB](https://uql-orm.dev/querying/json) dot-paths, so `$like` on a numeric column is a compile error. Entities are plain classes on the standard TC39 decorators: no `.prisma` file, no generated client, no `reflect-metadata`, no `experimentalDecorators`.
55
53
  - **One API, everywhere it runs.** PostgreSQL, CockroachDB, MySQL, MariaDB, SQLite, Turso, libSQL, Neon, Cloudflare D1, Bun's native SQL, and even MongoDB. The same code on Node 24+, Bun, Deno, [Cloudflare Workers](https://uql-orm.dev/cloudflare-d1), [AWS Lambda and Vercel](https://uql-orm.dev/serverless), and [the browser](https://uql-orm.dev/browser), with no native binaries on the `fetch`-based drivers.
56
54
  - **Relations without N+1.** [`$populate`](https://uql-orm.dev/querying/relations) loads a to-many with one query for all parents, not one per parent. Nothing is lazy, so nothing fires behind your back in a serializer.
57
55
  - **Migrations you read before they run.** Edit an entity, run `uql-migrate generate:entities`, review the SQL in the PR like any other file. [`drift:check`](https://uql-orm.dev/migrations) catches a database that no longer matches.
58
56
  - **Raw SQL when you want it.** [`raw()`](https://uql-orm.dev/querying/raw-sql) fits anywhere in a query, [virtual fields](https://uql-orm.dev/entities/virtual-fields) are sub-queries you can filter on, and a migration can be plain SQL.
59
- - **Light.** Zero runtime dependencies, 269 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
57
+ - **Light.** Zero runtime dependencies, under 280 kB on the wire, every dialect included. See [what we deleted to get there](https://uql-orm.dev/blog/zero-dependencies).
60
58
  - **The hard things are built in.** [Semantic and vector search](https://uql-orm.dev/ai-semantic-search), [multi-tenant filters you cannot bypass by accident](https://uql-orm.dev/multi-tenancy), [soft-delete with restore](https://uql-orm.dev/entities/soft-delete), [streaming](https://uql-orm.dev/querying/streaming), and [a REST API from your entities](https://uql-orm.dev/http).
61
- - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark): 206µs, against 644µs for the next closest and 2,236µs for the slowest.
59
+ - **The fastest ORM.** On a full PostgreSQL round trip it adds the least over hand-written driver code of any ORM in our open-source [benchmark](https://github.com/rogerpadilla/ts-orm-benchmark), by roughly 3x over the next closest and an order of magnitude over the slowest.
62
60
 
63
61
  ## Get started
64
62
 
@@ -77,4 +75,4 @@ Release notes live in [CHANGELOG.md](https://github.com/rogerpadilla/uql/blob/ma
77
75
 
78
76
  Built something? [Open a PR](https://github.com/rogerpadilla/uql/blob/main/CONTRIBUTING.md) and add it here.
79
77
 
80
- [![Made with UQL](https://img.shields.io/badge/made%20with-UQL-4F46E5?style=flat)](https://uql-orm.dev)
78
+ [![Made with UQL](https://img.shields.io/badge/made%20with-UQL-3282b5?style=flat)](https://uql-orm.dev)
@@ -203,8 +203,6 @@ export declare abstract class AbstractSqlDialect extends IndexSqlDialect impleme
203
203
  * carries instead of two quoted identifiers.
204
204
  */
205
205
  private collectSortTerms;
206
- /** The join an `ORDER BY` term addresses, or why the statement cannot order by it. */
207
- private resolveSortJoin;
208
206
  /**
209
207
  * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
210
208
  * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
@@ -1,12 +1,12 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
3
- import { asSelectMap, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorObject, isOperatorOnlyObject, isToManyRelation, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, populatesRelations, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { asSelectMap, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, populatesRelations, raw, someValue, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  import { escapeAnsiSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
5
5
  import { IndexSqlDialect } from './indexSqlDialect.js';
6
6
  import { buildElemMatchConditions } from './jsonArrayElemMatchUtils.js';
7
7
  import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } from './jsonSql.js';
8
8
  import { SqlQueryContext } from './queryContext.js';
9
- import { isSortMap, NO_JOINS, resolveQueryJoins, } from './queryJoins.js';
9
+ import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
10
10
  import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
11
11
  export class AbstractSqlDialect extends IndexSqlDialect {
12
12
  isolationLevelStrategy = 'inline';
@@ -323,6 +323,10 @@ export class AbstractSqlDialect extends IndexSqlDialect {
323
323
  compareLogicalOperator(ctx, entity, key, val, opts) {
324
324
  const op = AbstractSqlDialect.NEGATE_OP_MAP.get(key) ?? key;
325
325
  const negate = AbstractSqlDialect.NEGATE_OP_MAP.has(key);
326
+ if (val !== undefined && !Array.isArray(val)) {
327
+ // Not covered by the types: `/http` casts client JSON straight to `Query`, so this arrives untyped.
328
+ throw TypeError(`${key} expects an array, got ${val === null ? 'null' : typeof val}`);
329
+ }
326
330
  const items = val ?? [];
327
331
  // With more than one item each is an operand of the operator joining them, so a compound item
328
332
  // parenthesizes itself and precedence never applies; a lone item is this group verbatim, so it
@@ -660,11 +664,13 @@ export class AbstractSqlDialect extends IndexSqlDialect {
660
664
  const relation = meta.relations[key];
661
665
  if (relation) {
662
666
  const relPath = path ? `${path}.${key}` : key;
663
- if (!isSortMap(value)) {
664
- throw new TypeError(`$sort by relation '${relPath}' expects a map of its fields, got ${String(value)}`);
667
+ const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, opts.joins ?? NO_JOINS, `cannot $sort by relation '${relPath}': this statement joins no relations`);
668
+ // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join
669
+ // brought in for the sort alone has nothing to order by. Populating it selects its columns.
670
+ if (opts.distinct && !join.projected) {
671
+ throw new TypeError(`cannot $sort by relation '${relPath}' with $distinct unless '${relPath}' is populated: SELECT DISTINCT orders only by selected columns`);
665
672
  }
666
- const join = this.resolveSortJoin(relation, relPath, opts);
667
- this.collectSortTerms(ctx, join.meta, value, opts, vectors, columns, relPath);
673
+ this.collectSortTerms(ctx, join.meta, relationSort, opts, vectors, columns, relPath);
668
674
  continue;
669
675
  }
670
676
  if (isVectorSearch(value)) {
@@ -680,22 +686,6 @@ export class AbstractSqlDialect extends IndexSqlDialect {
680
686
  columns.push(this.sortColumn(meta, key, prefix) + this.resolveSortDirection(value));
681
687
  }
682
688
  }
683
- /** The join an `ORDER BY` term addresses, or why the statement cannot order by it. */
684
- resolveSortJoin(relation, path, opts) {
685
- if (isToManyRelation(relation)) {
686
- throw new TypeError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
687
- }
688
- const join = opts.joins?.get(path);
689
- if (!join) {
690
- throw new TypeError(`cannot $sort by relation '${path}': this statement joins no relations`);
691
- }
692
- // `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join brought in
693
- // for the sort alone has nothing to order by. Populating it puts its columns in the select list.
694
- if (opts.distinct && !join.projected) {
695
- throw new TypeError(`cannot $sort by relation '${path}' with $distinct unless '${path}' is populated: SELECT DISTINCT orders only by selected columns`);
696
- }
697
- return join;
698
- }
699
689
  /**
700
690
  * The `ORDER BY` operand for one key. A key that is not a column of `meta` - a virtual field, a
701
691
  * `raw()` projection - is an output alias, which is never table-qualified and needs no resolving.
@@ -6,6 +6,9 @@ import { type RelationQuery } from '../util/index.js';
6
6
  * `$sort` needs - which joins the same way, filters included, but adds nothing to the result.
7
7
  */
8
8
  export type QueryJoin = {
9
+ /** The relation key on its parent, which is how MongoDB names the field a `$lookup` adds. */
10
+ readonly key: string;
11
+ /** Dotted path from the queried entity, which is how the SQL dialects alias the join. */
9
12
  readonly path: string;
10
13
  readonly entity: Type<object>;
11
14
  readonly meta: EntityMeta<object>;
@@ -38,7 +41,13 @@ export type QuerySortOptions = {
38
41
  */
39
42
  export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
40
43
  /**
41
- * A nested `$sort` map, as opposed to a direction or a vector search. Shared with the `ORDER BY`
42
- * renderer so what counts as a relation sort is decided once, not once per side.
44
+ * The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
45
+ * Every backend answers this the same way - a to-many has no single value to order by, a relation
46
+ * sort is a map of that relation's fields, and the path has to be joined - so it is answered once
47
+ * here rather than per dialect, where the three checks had already drifted apart twice. Only the
48
+ * remedy for an unjoined path is the dialect's business, which is what `unjoinable` says.
43
49
  */
44
- export declare function isSortMap(value: unknown): value is QuerySortMap<object>;
50
+ export declare function resolveSortableJoin(relation: RelationMeta, path: string, value: unknown, joins: QueryJoins, unjoinable: string): {
51
+ readonly join: QueryJoin;
52
+ readonly sort: QuerySortMap<object>;
53
+ };
@@ -26,6 +26,7 @@ function addJoin(joins, parent, key, relation, query, required, projected) {
26
26
  }
27
27
  const entity = relation.entity();
28
28
  const join = {
29
+ key,
29
30
  path,
30
31
  entity,
31
32
  meta: getMeta(entity),
@@ -65,9 +66,26 @@ function addSortJoins(joins, meta, sort, parent) {
65
66
  }
66
67
  }
67
68
  /**
68
- * A nested `$sort` map, as opposed to a direction or a vector search. Shared with the `ORDER BY`
69
- * renderer so what counts as a relation sort is decided once, not once per side.
69
+ * The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
70
+ * Every backend answers this the same way - a to-many has no single value to order by, a relation
71
+ * sort is a map of that relation's fields, and the path has to be joined - so it is answered once
72
+ * here rather than per dialect, where the three checks had already drifted apart twice. Only the
73
+ * remedy for an unjoined path is the dialect's business, which is what `unjoinable` says.
70
74
  */
71
- export function isSortMap(value) {
75
+ export function resolveSortableJoin(relation, path, value, joins, unjoinable) {
76
+ if (isToManyRelation(relation)) {
77
+ throw new TypeError(`cannot $sort by '${path}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
78
+ }
79
+ if (!isSortMap(value)) {
80
+ throw new TypeError(`$sort by relation '${path}' expects a map of its fields, got ${String(value)}`);
81
+ }
82
+ const join = joins.get(path);
83
+ if (!join) {
84
+ throw new TypeError(unjoinable);
85
+ }
86
+ return { join, sort: value };
87
+ }
88
+ /** A nested `$sort` map, as opposed to a direction or a vector search. */
89
+ function isSortMap(value) {
72
90
  return typeof value === 'object' && value !== null && !Array.isArray(value) && !('$vector' in value);
73
91
  }
@@ -1,4 +1,4 @@
1
- import { getKeys, hasKeys, lowerFirst, normalizeIndexColumn, upperFirst } from '../../util/index.js';
1
+ import { getKeys, hasKeys, isToManyRelation, lowerFirst, normalizeIndexColumn, upperFirst } from '../../util/index.js';
2
2
  import { ownRegistrations } from '../decorator/bag.js';
3
3
  // Held on `globalThis` via the global symbol registry so a single metadata map survives multiple
4
4
  // evaluations of this module (HMR, duplicated/federated bundles, ESM+CJS dual-loading). Version-suffixed
@@ -225,7 +225,7 @@ function fillOwningSide(at, meta, relKey, relOpts) {
225
225
  ];
226
226
  return;
227
227
  }
228
- if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
228
+ if (isToManyRelation(relOpts)) {
229
229
  throw new TypeError(`${at} is a to-many relation with no way to join: it needs 'mappedBy' (the field on the other side), ` +
230
230
  "'through' (a junction entity), or 'references' (the columns).");
231
231
  }
@@ -1,7 +1,15 @@
1
1
  import { type Document, type Filter, ObjectId, type Sort, type UpdateFilter } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
- import type { DialectFeatures, EntityMeta, FieldValue, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
- import { type CallbackKey, type RelationRequestSummary } from '../util/index.js';
3
+ import type { DialectFeatures, EntityMeta, FieldValue, Query, QueryAggMap, QueryAggregate, QueryExclude, QueryGroupMap, QueryOptions, QueryPopulate, QuerySelectValue, QuerySortMap, QueryVectorSearch, QueryWhere, Type } from '../type/index.js';
4
+ import { type CallbackKey } from '../util/index.js';
5
+ /** What a read pipeline contributes to {@link MongoDialect.readStages} beyond the query itself. */
6
+ type MongoReadStages = {
7
+ /** Ordering, which runs after the lookups when it reads one of their fields. */
8
+ readonly sort?: Sort;
9
+ readonly pager?: MongoAggregationPipelineEntry<Document>[];
10
+ /** Keys merged into the query's projection, when it has one: a vector search's score. */
11
+ readonly project?: Record<string, 1>;
12
+ };
5
13
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
6
14
  export declare const mongoDialectFeatures: DialectFeatures;
7
15
  export declare class MongoDialect extends AbstractDialect {
@@ -98,10 +106,12 @@ export declare class MongoDialect extends AbstractDialect {
98
106
  /**
99
107
  * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
100
108
  * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
101
- * means a *populated* to-one: a lookup adds a field to the result, so one added for the sort alone
102
- * would change what the caller gets back, and MongoDB's lookups do not nest.
109
+ * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
110
+ * added for the sort alone would change what the caller gets back.
103
111
  */
104
- sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>): Sort;
112
+ sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
113
+ /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
114
+ private collectSort;
105
115
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
106
116
  sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
107
117
  /**
@@ -114,19 +124,33 @@ export declare class MongoDialect extends AbstractDialect {
114
124
  * embedded path (`kind.city` -> `<kind's column>.city`).
115
125
  */
116
126
  private pathOf;
117
- aggregationPipeline<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
127
+ aggregationPipeline<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
128
+ /**
129
+ * What a read runs after its entry stage, in the one order that works: the lookups its relations
130
+ * need, the ordering and paging that may read them, and the projection last of all - it names the
131
+ * fields the lookups add, and no stage after it could read what it dropped.
132
+ *
133
+ * Shared by the plain pipeline and the `$vectorSearch` one, which each used to spell the order out
134
+ * for themselves and each got a different part of it wrong.
135
+ */
136
+ readStages<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions, extra?: MongoReadStages): MongoAggregationPipelineEntry<Document>[];
118
137
  /**
119
138
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
120
139
  * the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
121
140
  * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
122
141
  * empty, and is why the pipeline emitted no projection at all and returned every column.
123
142
  */
124
- pipelineProjection<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>): Record<string, 0 | 1> | undefined;
143
+ pipelineProjection<E extends Document>(entity: Type<E>, q: Query<E>): Record<string, 0 | 1> | undefined;
125
144
  /**
126
145
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
127
146
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
128
147
  */
129
- relationStages<E extends Document>(entity: Type<E>, q: Query<E>, relationSummary?: RelationRequestSummary<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
148
+ relationStages<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): MongoAggregationPipelineEntry<E>[];
149
+ /**
150
+ * The `$lookup`/`$unwind` pair for each relation joined below `parent`, its own relations nested
151
+ * inside its pipeline and resolved before the projection that reads them.
152
+ */
153
+ private lookupStages;
130
154
  /**
131
155
  * The correlated join for a single-valued or one-to-many relation. MongoDB runs a lookup's `pipeline`
132
156
  * after its own localField/foreignField match, so the target's filters layer on top of the join
@@ -1,8 +1,9 @@
1
1
  import { ObjectId } from 'mongodb';
2
2
  import { AbstractDialect } from '../dialect/abstractDialect.js';
3
+ import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
3
4
  import { getMeta } from '../entity/index.js';
4
5
  import { QueryRaw } from '../type/queryRaw.js';
5
- import { asSelectMap, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorObject, isToManyRelation, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationAtKey, parseRelationSize, } from '../util/index.js';
6
+ import { asSelectMap, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, } from '../util/index.js';
6
7
  /** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
7
8
  export const mongoDialectFeatures = {
8
9
  explicitJsonCast: false,
@@ -364,30 +365,32 @@ export class MongoDialect extends AbstractDialect {
364
365
  /**
365
366
  * The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
366
367
  * on the SQL dialects - it is only addressable when the statement joins that relation. Here that
367
- * means a *populated* to-one: a lookup adds a field to the result, so one added for the sort alone
368
- * would change what the caller gets back, and MongoDB's lookups do not nest.
368
+ * means a *populated* one, at every level of the path: a lookup adds a field to the result, so one
369
+ * added for the sort alone would change what the caller gets back.
369
370
  */
370
- sort(entity, sort) {
371
+ sort(entity, sort, populate) {
371
372
  const meta = getMeta(entity);
372
373
  const normalized = {};
374
+ // The same join set the lookups are built from, so what an `ORDER BY` may address and what the
375
+ // pipeline actually produces cannot drift apart.
376
+ this.collectSort(meta, sort, resolveQueryJoins(meta, { $populate: populate }), '', normalized);
377
+ return normalized;
378
+ }
379
+ /** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
380
+ collectSort(meta, sort, joins, path, out) {
373
381
  for (const [key, value] of Object.entries(sort ?? {})) {
374
382
  const relation = meta.relations[key];
375
383
  if (!relation) {
376
- normalized[this.pathOf(meta, key)] = sortDirection(value);
384
+ out[path + this.pathOf(meta, key)] = sortDirection(value);
377
385
  continue;
378
386
  }
379
- if (isToManyRelation(relation)) {
380
- throw new TypeError(`cannot $sort by '${key}': a parent has many of them, so there is no single value to order by. Sort the relation's own rows inside $populate instead.`);
381
- }
382
- const relMeta = getMeta(relation.entity());
383
- for (const [relKey, relValue] of Object.entries(value ?? {})) {
384
- if (relMeta.relations[relKey]) {
385
- throw new TypeError(`cannot $sort by '${key}.${relKey}' on MongoDB: its lookups reach one level, so a nested relation is not joined`);
386
- }
387
- normalized[`${key}.${this.pathOf(relMeta, relKey)}`] = sortDirection(relValue);
388
- }
387
+ // A `$lookup` is what puts the relation's fields on the document, and only `$populate` asks for
388
+ // one: ordering by a relation nothing looked up reads a field that is not there, which MongoDB
389
+ // ranks as all-equal rather than rejecting. The SQL dialects can add the join themselves.
390
+ const relPath = `${path}${key}`;
391
+ const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, joins, `cannot $sort by relation '${relPath}' on MongoDB unless it is populated: only $populate adds its fields to the document`);
392
+ this.collectSort(join.meta, relationSort, joins, `${relPath}.`, out);
389
393
  }
390
- return normalized;
391
394
  }
392
395
  /** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
393
396
  sortsRelations(entity, sort) {
@@ -416,44 +419,47 @@ export class MongoDialect extends AbstractDialect {
416
419
  }
417
420
  return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
418
421
  }
419
- aggregationPipeline(entity, q, relationSummary, opts) {
420
- const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
421
- const sort = this.sort(entity, q.$sort);
422
- // Ordering by a related field reads what the lookups produced, so it cannot ride along with the
423
- // `$match` the way an ordering by the parent's own columns does.
424
- const sortsRelations = this.sortsRelations(entity, q.$sort);
425
- const sortStage = hasKeys(sort) ? [{ $sort: sort }] : [];
426
- const match = {};
427
- if (hasKeys(filter)) {
428
- match.$match = filter;
429
- }
430
- if (!sortsRelations && sortStage.length) {
431
- match.$sort = sort;
432
- }
422
+ aggregationPipeline(entity, q, opts) {
433
423
  // Lookups that a relation condition needs come first, then the match that reads them, then the
434
424
  // temporary fields are dropped so they never reach the caller.
435
- const pipeline = [...stages];
436
- if (hasKeys(match)) {
437
- pipeline.push(match);
438
- }
439
- if (unset.length) {
440
- pipeline.push({ $unset: unset });
441
- }
442
- const relStages = this.relationStages(entity, q, relationSummary, opts);
443
- const pager = [
444
- ...(q.$skip === undefined ? [] : [{ $skip: q.$skip }]),
445
- ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
425
+ const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
426
+ return [
427
+ ...stages,
428
+ ...(hasKeys(filter) ? [{ $match: filter }] : []),
429
+ ...(unset.length ? [{ $unset: unset }] : []),
430
+ ...this.readStages(entity, q, opts, {
431
+ sort: this.sort(entity, q.$sort, q.$populate),
432
+ pager: [
433
+ ...(q.$skip === undefined ? [] : [{ $skip: q.$skip }]),
434
+ ...(q.$limit === undefined ? [] : [{ $limit: q.$limit }]),
435
+ ],
436
+ }),
437
+ ];
438
+ }
439
+ /**
440
+ * What a read runs after its entry stage, in the one order that works: the lookups its relations
441
+ * need, the ordering and paging that may read them, and the projection last of all - it names the
442
+ * fields the lookups add, and no stage after it could read what it dropped.
443
+ *
444
+ * Shared by the plain pipeline and the `$vectorSearch` one, which each used to spell the order out
445
+ * for themselves and each got a different part of it wrong.
446
+ */
447
+ readStages(entity, q, opts, extra = {}) {
448
+ const lookups = this.relationStages(entity, q, opts);
449
+ const projection = this.pipelineProjection(entity, q);
450
+ const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
451
+ const pager = extra.pager ?? [];
452
+ // A `$required` relation drops parents when it unwinds, and an ordering may read a field only a
453
+ // lookup produces: either one puts the lookups first, as an INNER JOIN does. Otherwise paging
454
+ // first is equivalent and spares the lookups the rows it cuts.
455
+ const lookupsFirst = this.sortsRelations(entity, q.$sort) ||
456
+ lookups.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
457
+ return [
458
+ ...(lookupsFirst ? [...lookups, ...sort, ...pager] : [...sort, ...pager, ...lookups]),
459
+ // Merged into the query's own projection rather than standing in for one: a query that asked
460
+ // for no columns wants the whole document, not just the field this adds to it.
461
+ ...(projection ? [{ $project: { ...projection, ...extra.project } }] : []),
446
462
  ];
447
- // A `$required` relation drops parents when it unwinds, so paging has to come after it - as it
448
- // does after an INNER JOIN. So does a sort that reads one, or a page would be cut from unordered
449
- // rows. Otherwise paging first is equivalent and spares the lookups.
450
- const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
451
- pipeline.push(...(dropsParents || sortsRelations ? [...relStages, ...sortStage, ...pager] : [...pager, ...relStages]));
452
- const projection = this.pipelineProjection(entity, q, relationSummary);
453
- if (projection) {
454
- pipeline.push({ $project: projection });
455
- }
456
- return pipeline;
457
463
  }
458
464
  /**
459
465
  * The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
@@ -461,12 +467,12 @@ export class MongoDialect extends AbstractDialect {
461
467
  * lookups have read the join keys - projecting any earlier is what used to leave `$populate`
462
468
  * empty, and is why the pipeline emitted no projection at all and returned every column.
463
469
  */
464
- pipelineProjection(entity, q, relationSummary) {
470
+ pipelineProjection(entity, q) {
465
471
  if (!q.$select && !q.$exclude) {
466
472
  return undefined;
467
473
  }
468
474
  const projection = this.select(entity, q.$select, q.$exclude);
469
- const summary = relationSummary ?? getRelationRequestSummary(getMeta(entity), q.$populate);
475
+ const summary = getRelationRequestSummary(getMeta(entity), q.$populate);
470
476
  for (const relKey of summary.joinableKeys) {
471
477
  projection[relKey] = 1;
472
478
  }
@@ -480,46 +486,51 @@ export class MongoDialect extends AbstractDialect {
480
486
  * `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
481
487
  * aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
482
488
  */
483
- relationStages(entity, q, relationSummary, opts) {
489
+ relationStages(entity, q, opts) {
490
+ // Resolved from `$populate` alone, deliberately: on the SQL dialects a `$sort` can add a join of
491
+ // its own because a join is invisible in the result, while a `$lookup` puts a field on the
492
+ // document. Same join model, and this backend takes the part of it that it can carry.
484
493
  const meta = getMeta(entity);
494
+ return this.lookupStages(meta, resolveQueryJoins(meta, { $populate: q.$populate }), undefined, opts);
495
+ }
496
+ /**
497
+ * The `$lookup`/`$unwind` pair for each relation joined below `parent`, its own relations nested
498
+ * inside its pipeline and resolved before the projection that reads them.
499
+ */
500
+ lookupStages(parentMeta, joins, parent, opts) {
485
501
  const pipeline = [];
486
- const relKeys = (relationSummary ?? getRelationRequestSummary(meta, q.$populate)).joinableKeys;
487
- for (const relKey of relKeys) {
488
- const relOpts = meta.relations[relKey];
489
- if (!relOpts)
490
- continue;
491
- if (isToManyRelation(relOpts)) {
492
- // '1m' and 'mm' are resolved in a higher layer: they need a second query each.
502
+ // Every join at this level hangs off `parent`, so its metadata is `parentMeta` - no branch, and
503
+ // no union of two unrelated entity types to resolve the join column through.
504
+ for (const join of joins.values()) {
505
+ if (join.parent !== parent) {
493
506
  continue;
494
507
  }
495
- const relEntity = relOpts.entity();
496
- const relMeta = getMeta(relEntity);
497
- const { query: relQuery, required } = parseRelationAtKey(relKey, q.$populate);
498
508
  // Unconditional, not gated by an explicit relation-level `$where`: the related entity's own
499
509
  // filters (in particular `security: true` ones) must apply even to a bare
500
510
  // `$populate: { rel: true }`, exactly like the SQL dialects' JOIN ON-clause filters.
501
- const relationFilter = this.where(relEntity, relQuery.$where ?? {}, opts);
511
+ const relationFilter = this.where(join.entity, join.query.$where ?? {}, opts);
502
512
  // The relation's own projection runs inside the lookup, where its keys resolve against the
503
513
  // related entity. Left out, `$populate: { rel: { $select } }` returned all of `rel`'s columns.
504
- const relationProjection = this.pipelineProjection(relEntity, relQuery);
514
+ const relationProjection = this.pipelineProjection(join.entity, join.query);
505
515
  // MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
506
516
  // how a joined document keeps its own id - as it does on the SQL dialects, and as a nested
507
517
  // to-many fill needs.
508
518
  delete relationProjection?.[MongoDialect.ID_KEY];
509
519
  const lookupPipeline = [
510
520
  ...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
521
+ ...this.lookupStages(join.meta, joins, join, opts),
511
522
  ...(relationProjection ? [{ $project: relationProjection }] : []),
512
523
  ];
513
524
  pipeline.push({
514
525
  $lookup: {
515
- from: this.resolveTableName(relEntity, relMeta),
516
- ...this.joinKeys(meta, relMeta, relOpts),
526
+ from: this.resolveTableName(join.entity, join.meta),
527
+ ...this.joinKeys(parentMeta, join.meta, join.relation),
517
528
  ...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
518
- as: relKey,
529
+ as: join.key,
519
530
  },
520
531
  });
521
532
  // `$required` drops parents with no match, the aggregation equivalent of an INNER JOIN.
522
- pipeline.push({ $unwind: { path: `$${relKey}`, preserveNullAndEmptyArrays: !required } });
533
+ pipeline.push({ $unwind: { path: `$${join.key}`, preserveNullAndEmptyArrays: !join.required } });
523
534
  }
524
535
  return pipeline;
525
536
  }
@@ -1,6 +1,6 @@
1
1
  import { getMeta } from '../entity/index.js';
2
2
  import { AbstractQuerier, enrichError } from '../querier/index.js';
3
- import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
3
+ import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
4
4
  export class MongodbQuerier extends AbstractQuerier {
5
5
  dialect;
6
6
  conn;
@@ -30,14 +30,13 @@ export class MongodbQuerier extends AbstractQuerier {
30
30
  await this.fillToManyRelations(entity, documents, q.$populate);
31
31
  }
32
32
  else {
33
- const relationSummary = getRelationRequestSummary(meta, q.$populate);
34
33
  // A relation condition needs `$lookup`, so it forces the aggregation path just like populating
35
34
  // one does - and so does ordering by a relation, which reads what a lookup produced. A plain
36
35
  // `find` cursor can express none of the three.
37
- if (relationSummary.requestedKeys.length ||
36
+ if (populatesRelations(meta, q.$populate) ||
38
37
  this.dialect.constrainsRelations(entity, q.$where) ||
39
38
  this.dialect.sortsRelations(entity, q.$sort)) {
40
- const pipeline = this.dialect.aggregationPipeline(entity, q, relationSummary, opts);
39
+ const pipeline = this.dialect.aggregationPipeline(entity, q, opts);
41
40
  documents = await this.runPipeline(entity, meta, pipeline);
42
41
  await this.fillToManyRelations(entity, documents, q.$populate);
43
42
  }
@@ -86,7 +85,7 @@ export class MongodbQuerier extends AbstractQuerier {
86
85
  if (hasKeys(select)) {
87
86
  cursor.project(select);
88
87
  }
89
- const sort = this.dialect.sort(entity, q.$sort);
88
+ const sort = this.dialect.sort(entity, q.$sort, q.$populate);
90
89
  if (hasKeys(sort)) {
91
90
  cursor.sort(sort);
92
91
  }
@@ -108,43 +107,18 @@ export class MongodbQuerier extends AbstractQuerier {
108
107
  * `$vectorSearch` is always the first stage; `$where` is merged into its `filter`.
109
108
  */
110
109
  buildVectorPipeline(entity, q, vectorSort, opts) {
111
- const pipeline = [];
112
- pipeline.push(this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts));
113
- const meta = getMeta(entity);
114
- const relationSummary = getRelationRequestSummary(meta, q.$populate);
115
110
  const scoreAlias = vectorSort.vectorSearch.$project;
116
- // With relations the score is captured with `$addFields` before the lookups, and the scalar
117
- // projection waits until after them: projecting any earlier drops the join keys and the joined
118
- // documents, which is why `$populate` used to come back empty under a vector sort.
119
- if (relationSummary.requestedKeys.length) {
120
- if (scoreAlias) {
121
- pipeline.push({ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } });
122
- }
123
- pipeline.push(...this.dialect.relationStages(entity, q, relationSummary));
124
- const projection = this.dialect.pipelineProjection(entity, q, relationSummary);
125
- if (projection) {
126
- // `$addFields` already made the score a real field, so it projects like any other.
127
- pipeline.push({ $project: scoreAlias ? { ...projection, [scoreAlias]: 1 } : projection });
128
- }
129
- }
130
- else if (scoreAlias) {
131
- const select = q.$select || q.$exclude ? this.buildScalarProjection(entity, q) : {};
132
- pipeline.push({
133
- $project: {
134
- ...select,
135
- [scoreAlias]: { $meta: 'vectorSearchScore' },
136
- },
137
- });
138
- }
139
- else if ((q.$select && hasKeys(q.$select)) || (q.$exclude && hasKeys(q.$exclude))) {
140
- pipeline.push({ $project: this.buildScalarProjection(entity, q) });
141
- }
142
- // Secondary sort for non-vector fields
143
- const regularSort = this.dialect.sort(entity, vectorSort.regularSort);
144
- if (hasKeys(regularSort)) {
145
- pipeline.push({ $sort: regularSort });
146
- }
147
- return pipeline;
111
+ return [
112
+ this.dialect.buildVectorSearchStage(entity, vectorSort.vectorKey, vectorSort.vectorSearch, q.$where, q.$limit ?? 10, opts),
113
+ // The score becomes a real field before anything reads it, so the lookups and the projection
114
+ // that follow treat it like any other - and a query with no projection keeps its own columns.
115
+ ...(scoreAlias ? [{ $addFields: { [scoreAlias]: { $meta: 'vectorSearchScore' } } }] : []),
116
+ // `$vectorSearch` has already applied `$limit`, so the pager is its own.
117
+ ...this.dialect.readStages(entity, q, opts, {
118
+ sort: this.dialect.sort(entity, vectorSort.regularSort, q.$populate),
119
+ project: scoreAlias ? { [scoreAlias]: 1 } : undefined,
120
+ }),
121
+ ];
148
122
  }
149
123
  async internalAggregate(entity, q, opts) {
150
124
  return this.timed('internalAggregate', undefined, async () => {
@@ -121,12 +121,12 @@ export type QuerySortValue = QuerySortDirection | QueryVectorSearch;
121
121
  * like `QueryWhereMap`), relation sort via nested objects, and vector similarity search on
122
122
  * `number[]` fields.
123
123
  */
124
- export type QuerySortMap<E> = {
125
- [K in FieldKey<E>]?: NonNullable<E[K]> extends readonly number[] ? QuerySortValue : QuerySortDirection;
124
+ export type QuerySortMap<E, Vector extends boolean = true> = {
125
+ [K in FieldKey<E>]?: Vector extends true ? NonNullable<E[K]> extends readonly number[] ? QuerySortValue : QuerySortDirection : QuerySortDirection;
126
126
  } & {
127
127
  [P in JsonFieldPaths<E>]?: QuerySortDirection;
128
128
  } & {
129
- [K in RelationKey<E> as NonNullable<E[K]> extends readonly unknown[] ? never : K]?: QuerySortMap<NonNullable<E[K]>>;
129
+ [K in RelationKey<E> as NonNullable<E[K]> extends readonly unknown[] ? never : K]?: QuerySortMap<NonNullable<E[K]>, false>;
130
130
  };
131
131
  /**
132
132
  * pager options.
@@ -4,8 +4,11 @@ export type RelationRequestSummary<E> = {
4
4
  readonly joinableKeys: RelationKey<E>[];
5
5
  readonly toManyKeys: RelationKey<E>[];
6
6
  };
7
- /** Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. */
8
- export declare function isToManyRelation(relation: RelationMeta): boolean;
7
+ /**
8
+ * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
9
+ * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
10
+ */
11
+ export declare function isToManyRelation(relation: Pick<RelationMeta, 'cardinality'>): boolean;
9
12
  /**
10
13
  * What a joined relation cannot carry, and why. A to-many is loaded by a query of its own, which is
11
14
  * what gives these four a meaning there; a to-one is one row of the parent's, so every backend used
@@ -1,5 +1,8 @@
1
1
  import { getKeys } from './object.util.js';
2
- /** Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. */
2
+ /**
3
+ * Whether a relation holds many rows per parent, so it cannot be joined into the parent's row. Takes
4
+ * the one field it reads, so it answers for a relation being declared as well as for a resolved one.
5
+ */
3
6
  export function isToManyRelation(relation) {
4
7
  return relation.cardinality === '1m' || relation.cardinality === 'mm';
5
8
  }
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "uql-orm",
3
3
  "homepage": "https://uql-orm.dev",
4
- "description": "Extremely fast, type-safe TypeScript ORM - one API for every database",
4
+ "description": "JSON-native TypeScript ORM for Node.js, Bun and Deno. Supports PostgreSQL, MySQL, MariaDB, SQLite, CockroachDB, Turso, Neon, Cloudflare D1 and MongoDB. Queries are plain JSON, typed to the leaf.",
5
5
  "license": "MIT",
6
- "version": "0.28.0",
6
+ "version": "0.28.2",
7
7
  "type": "module",
8
8
  "engines": {
9
9
  "node": ">=24"
@@ -158,40 +158,45 @@
158
158
  },
159
159
  "keywords": [
160
160
  "orm",
161
+ "uql",
162
+ "sql",
161
163
  "query-builder",
162
164
  "data-mapper",
163
- "persistence",
164
165
  "database",
165
166
  "db",
167
+ "typescript",
168
+ "ts",
169
+ "javascript",
166
170
  "typesafe",
167
- "typescript-orm",
168
- "javascript-orm",
169
- "mariadb",
170
- "mariadb-orm",
171
- "mysql",
172
- "mysql-orm",
171
+ "type-safe",
172
+ "json",
173
+ "postgres",
173
174
  "postgresql",
174
- "postgresql-orm",
175
+ "pg",
176
+ "mysql",
177
+ "mariadb",
175
178
  "sqlite",
176
- "sqlite-orm",
179
+ "sqlite3",
177
180
  "cockroachdb",
178
- "cockroachdb-orm",
179
181
  "mongodb",
180
- "mongodb-orm",
182
+ "mongo",
181
183
  "libsql",
182
184
  "turso",
183
185
  "neon",
184
186
  "d1",
185
187
  "cloudflare",
186
188
  "bun",
189
+ "deno",
190
+ "node",
191
+ "nodejs",
187
192
  "edge",
193
+ "serverless",
188
194
  "entity",
189
- "dao",
190
- "transaction",
191
- "repository",
192
- "service",
195
+ "transactions",
193
196
  "migrations",
194
197
  "vector-search",
198
+ "semantic-search",
199
+ "embeddings",
195
200
  "nestjs",
196
201
  "express"
197
202
  ],