uql-orm 0.28.0 → 0.28.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/dist/dialect/abstractSqlDialect.d.ts +0 -2
- package/dist/dialect/abstractSqlDialect.js +12 -22
- package/dist/dialect/queryJoins.d.ts +12 -3
- package/dist/dialect/queryJoins.js +21 -3
- package/dist/entity/metadata/definition.js +2 -2
- package/dist/mongo/mongoDialect.d.ts +32 -8
- package/dist/mongo/mongoDialect.js +81 -70
- package/dist/mongo/mongodbQuerier.js +15 -41
- package/dist/type/query.d.ts +3 -3
- package/dist/util/relationQuery.util.d.ts +5 -2
- package/dist/util/relationQuery.util.js +4 -1
- package/package.json +2 -2
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="
|
|
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
|
|
10
|
+
<h3>The JSON-native TypeScript ORM</h3>
|
|
11
11
|
|
|
12
|
-
<p>Queries are plain JSON
|
|
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> ·
|
|
@@ -77,4 +77,4 @@ Release notes live in [CHANGELOG.md](https://github.com/rogerpadilla/uql/blob/ma
|
|
|
77
77
|
|
|
78
78
|
Built something? [Open a PR](https://github.com/rogerpadilla/uql/blob/main/CONTRIBUTING.md) and add it here.
|
|
79
79
|
|
|
80
|
-
[](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,
|
|
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 {
|
|
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
|
-
|
|
664
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
42
|
-
*
|
|
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
|
|
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
|
-
*
|
|
69
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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*
|
|
102
|
-
* would change what the caller gets back
|
|
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>,
|
|
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
|
|
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>,
|
|
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,
|
|
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*
|
|
368
|
-
* would change what the caller gets back
|
|
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
|
-
|
|
384
|
+
out[path + this.pathOf(meta, key)] = sortDirection(value);
|
|
377
385
|
continue;
|
|
378
386
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
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,
|
|
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
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
if (
|
|
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(
|
|
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(
|
|
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(
|
|
516
|
-
...this.joinKeys(
|
|
526
|
+
from: this.resolveTableName(join.entity, join.meta),
|
|
527
|
+
...this.joinKeys(parentMeta, join.meta, join.relation),
|
|
517
528
|
...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
|
|
518
|
-
as:
|
|
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: `$${
|
|
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 (
|
|
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,
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 () => {
|
package/dist/type/query.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
8
|
-
|
|
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
|
-
/**
|
|
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": "
|
|
4
|
+
"description": "JSON-native TypeScript ORM: queries are plain JSON, typed to the leaf. One API for SQL databases and MongoDB.",
|
|
5
5
|
"license": "MIT",
|
|
6
|
-
"version": "0.28.
|
|
6
|
+
"version": "0.28.1",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24"
|