uql-orm 0.27.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 +6 -6
- package/dist/dialect/abstractSqlDialect.d.ts +69 -44
- package/dist/dialect/abstractSqlDialect.js +278 -315
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +0 -1
- package/dist/dialect/mysqlLikeSqlDialect.js +0 -3
- package/dist/dialect/pgLikeSqlDialect.d.ts +1 -1
- package/dist/dialect/pgLikeSqlDialect.js +1 -3
- package/dist/dialect/queryContext.d.ts +7 -5
- package/dist/dialect/queryContext.js +11 -6
- package/dist/dialect/queryJoins.d.ts +53 -0
- package/dist/dialect/queryJoins.js +91 -0
- package/dist/dialect/vectorSqlDialect.d.ts +1 -1
- package/dist/entity/metadata/definition.js +2 -2
- package/dist/mongo/mongoDialect.d.ts +38 -8
- package/dist/mongo/mongoDialect.js +103 -64
- package/dist/mongo/mongodbQuerier.js +19 -42
- package/dist/sqlite/sqliteDialect.d.ts +1 -1
- package/dist/sqlite/sqliteDialect.js +3 -3
- package/dist/type/dialect.d.ts +11 -3
- package/dist/type/query.d.ts +3 -3
- package/dist/util/dialect.util.d.ts +1 -2
- package/dist/util/dialect.util.js +0 -3
- package/dist/util/relationQuery.util.d.ts +17 -2
- package/dist/util/relationQuery.util.js +35 -2
- package/dist/util/sql.util.d.ts +1 -2
- package/dist/util/sql.util.js +1 -12
- package/package.json +2 -2
|
@@ -61,7 +61,6 @@ export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
|
|
|
61
61
|
*/
|
|
62
62
|
protected indexAccessMethod(index: IndexSchema): string;
|
|
63
63
|
protected numericCast(expr: string): string;
|
|
64
|
-
protected ilikeExpr(f: string, ph: string): string;
|
|
65
64
|
protected neExpr(field: string, ph: string): string;
|
|
66
65
|
/** How a surviving element is fed back into the array a `$pull` rebuilds. */
|
|
67
66
|
protected jsonPullElem(alias: string): string;
|
|
@@ -112,9 +112,6 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
|
|
|
112
112
|
numericCast(expr) {
|
|
113
113
|
return `CAST(${expr} AS DECIMAL)`;
|
|
114
114
|
}
|
|
115
|
-
ilikeExpr(f, ph) {
|
|
116
|
-
return `${f} LIKE ${ph}`;
|
|
117
|
-
}
|
|
118
115
|
neExpr(field, ph) {
|
|
119
116
|
// MySQL/MariaDB null-safe inequality: true when values differ or one side is NULL.
|
|
120
117
|
return `NOT (${field} <=> ${ph})`;
|
|
@@ -63,7 +63,7 @@ export declare abstract class PgLikeSqlDialect extends AbstractSqlDialect {
|
|
|
63
63
|
protected jsonElemFrom(jsonField: string, fields: readonly string[], alias: string, asJson?: boolean): string;
|
|
64
64
|
protected jsonElemRef(alias: string, field?: string, asJson?: boolean): string;
|
|
65
65
|
protected get regexpOp(): string;
|
|
66
|
-
protected
|
|
66
|
+
protected readonly caseInsensitiveMatch = "ilike";
|
|
67
67
|
protected get neOp(): string;
|
|
68
68
|
protected formatIn(ctx: QueryContext, values: unknown[], negate: boolean): string;
|
|
69
69
|
protected numericCast(expr: string): string;
|
|
@@ -155,9 +155,7 @@ export class PgLikeSqlDialect extends AbstractSqlDialect {
|
|
|
155
155
|
get regexpOp() {
|
|
156
156
|
return '~';
|
|
157
157
|
}
|
|
158
|
-
|
|
159
|
-
return `${f} ILIKE ${ph}`;
|
|
160
|
-
}
|
|
158
|
+
caseInsensitiveMatch = 'ilike';
|
|
161
159
|
get neOp() {
|
|
162
160
|
return 'IS DISTINCT FROM';
|
|
163
161
|
}
|
|
@@ -8,6 +8,7 @@ import type { QueryContext, QueryDialect } from '../type/index.js';
|
|
|
8
8
|
*/
|
|
9
9
|
export declare class SqlQueryContext implements QueryContext {
|
|
10
10
|
readonly dialect: QueryDialect;
|
|
11
|
+
private readonly statement?;
|
|
11
12
|
private readonly sqlChunks;
|
|
12
13
|
private readonly params;
|
|
13
14
|
private aliasCounter;
|
|
@@ -17,8 +18,11 @@ export declare class SqlQueryContext implements QueryContext {
|
|
|
17
18
|
* fragment context built via {@link AbstractSqlDialect.buildFragment}, so a bound value's
|
|
18
19
|
* placeholder is numbered correctly against the real query from the moment it's added, rather
|
|
19
20
|
* than needing to be reconciled after the fact.
|
|
21
|
+
* @param statement The context this one renders a fragment of, which owns the alias counter: a
|
|
22
|
+
* fragment is part of one statement, so its aliases have to be unique across the whole of it.
|
|
20
23
|
*/
|
|
21
|
-
constructor(dialect: QueryDialect, params?: unknown[]);
|
|
24
|
+
constructor(dialect: QueryDialect, params?: unknown[], statement?: SqlQueryContext | undefined);
|
|
25
|
+
createFragment(): QueryContext;
|
|
22
26
|
/**
|
|
23
27
|
* Appends raw SQL string fragments to the query.
|
|
24
28
|
*
|
|
@@ -43,10 +47,8 @@ export declare class SqlQueryContext implements QueryContext {
|
|
|
43
47
|
*/
|
|
44
48
|
pushValue(...values: unknown[]): this;
|
|
45
49
|
/**
|
|
46
|
-
* A fresh alias unique within
|
|
47
|
-
* `'_uql_elem_2'`, ...
|
|
48
|
-
* it shares values with - fine today since no fragment-building hook also generates aliases, but
|
|
49
|
-
* worth widening (share this counter too, the same way `params` is shared) if one ever does.
|
|
50
|
+
* A fresh alias unique within the statement being built, e.g. `nextAlias('_uql_elem')` ->
|
|
51
|
+
* `'_uql_elem_1'`, `'_uql_elem_2'`, ...
|
|
50
52
|
*/
|
|
51
53
|
nextAlias(prefix: string): string;
|
|
52
54
|
/**
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
export class SqlQueryContext {
|
|
9
9
|
dialect;
|
|
10
|
+
statement;
|
|
10
11
|
sqlChunks = [];
|
|
11
12
|
params;
|
|
12
13
|
aliasCounter = 0;
|
|
@@ -16,11 +17,17 @@ export class SqlQueryContext {
|
|
|
16
17
|
* fragment context built via {@link AbstractSqlDialect.buildFragment}, so a bound value's
|
|
17
18
|
* placeholder is numbered correctly against the real query from the moment it's added, rather
|
|
18
19
|
* than needing to be reconciled after the fact.
|
|
20
|
+
* @param statement The context this one renders a fragment of, which owns the alias counter: a
|
|
21
|
+
* fragment is part of one statement, so its aliases have to be unique across the whole of it.
|
|
19
22
|
*/
|
|
20
|
-
constructor(dialect, params = []) {
|
|
23
|
+
constructor(dialect, params = [], statement) {
|
|
21
24
|
this.dialect = dialect;
|
|
25
|
+
this.statement = statement;
|
|
22
26
|
this.params = params;
|
|
23
27
|
}
|
|
28
|
+
createFragment() {
|
|
29
|
+
return new SqlQueryContext(this.dialect, this.params, this.statement ?? this);
|
|
30
|
+
}
|
|
24
31
|
/**
|
|
25
32
|
* Appends raw SQL string fragments to the query.
|
|
26
33
|
*
|
|
@@ -56,13 +63,11 @@ export class SqlQueryContext {
|
|
|
56
63
|
return this;
|
|
57
64
|
}
|
|
58
65
|
/**
|
|
59
|
-
* A fresh alias unique within
|
|
60
|
-
* `'_uql_elem_2'`, ...
|
|
61
|
-
* it shares values with - fine today since no fragment-building hook also generates aliases, but
|
|
62
|
-
* worth widening (share this counter too, the same way `params` is shared) if one ever does.
|
|
66
|
+
* A fresh alias unique within the statement being built, e.g. `nextAlias('_uql_elem')` ->
|
|
67
|
+
* `'_uql_elem_1'`, `'_uql_elem_2'`, ...
|
|
63
68
|
*/
|
|
64
69
|
nextAlias(prefix) {
|
|
65
|
-
return `${prefix}_${++this.aliasCounter}`;
|
|
70
|
+
return this.statement ? this.statement.nextAlias(prefix) : `${prefix}_${++this.aliasCounter}`;
|
|
66
71
|
}
|
|
67
72
|
/**
|
|
68
73
|
* Returns the complete SQL query string by joining all accumulated chunks.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { EntityMeta, Query, QuerySortMap, RelationMeta, Type } from '../type/index.js';
|
|
2
|
+
import { type RelationQuery } from '../util/index.js';
|
|
3
|
+
/**
|
|
4
|
+
* One relation a statement joins, keyed by the alias its columns are addressed by (`tax`,
|
|
5
|
+
* `tax.category`). `projected` tells a `$populate` join, whose columns are selected, from one only
|
|
6
|
+
* `$sort` needs - which joins the same way, filters included, but adds nothing to the result.
|
|
7
|
+
*/
|
|
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. */
|
|
12
|
+
readonly path: string;
|
|
13
|
+
readonly entity: Type<object>;
|
|
14
|
+
readonly meta: EntityMeta<object>;
|
|
15
|
+
readonly relation: RelationMeta;
|
|
16
|
+
readonly query: RelationQuery;
|
|
17
|
+
readonly required: boolean;
|
|
18
|
+
readonly projected: boolean;
|
|
19
|
+
/** `undefined` at the first level, where the parent is the queried entity itself. */
|
|
20
|
+
readonly parent: QueryJoin | undefined;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Every relation a statement joins, in the order the joins are emitted. Flat rather than a tree: a
|
|
24
|
+
* parent is always resolved before its children, so iterating it in order visits them the same way
|
|
25
|
+
* recursion would, and looking an alias up - which is what `$sort` needs - is a plain `get`.
|
|
26
|
+
*/
|
|
27
|
+
export type QueryJoins = ReadonlyMap<string, QueryJoin>;
|
|
28
|
+
export declare const NO_JOINS: QueryJoins;
|
|
29
|
+
/** What rendering an `ORDER BY` needs beyond the map itself: where columns live, and what is joined. */
|
|
30
|
+
export type QuerySortOptions = {
|
|
31
|
+
/** Alias the queried entity's own columns are qualified by, when the statement qualifies them. */
|
|
32
|
+
readonly prefix?: string;
|
|
33
|
+
readonly joins?: QueryJoins;
|
|
34
|
+
readonly distinct?: boolean;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* What the statement joins, from the whole query rather than from `$populate` alone: ordering by a
|
|
38
|
+
* related column needs that relation joined just as much as selecting it does. The two sources meet
|
|
39
|
+
* here, so the columns, the `ORDER BY` and the row lock cannot disagree about what is in the
|
|
40
|
+
* statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
|
|
41
|
+
*/
|
|
42
|
+
export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
|
|
43
|
+
/**
|
|
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.
|
|
49
|
+
*/
|
|
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
|
+
};
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getMeta } from '../entity/index.js';
|
|
2
|
+
import { getKeys, getRelationRequestSummary, isToManyRelation, parseRelationAtKey, } from '../util/index.js';
|
|
3
|
+
export const NO_JOINS = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* What the statement joins, from the whole query rather than from `$populate` alone: ordering by a
|
|
6
|
+
* related column needs that relation joined just as much as selecting it does. The two sources meet
|
|
7
|
+
* here, so the columns, the `ORDER BY` and the row lock cannot disagree about what is in the
|
|
8
|
+
* statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
|
|
9
|
+
*/
|
|
10
|
+
export function resolveQueryJoins(meta, q) {
|
|
11
|
+
if (!q.$populate && !q.$sort) {
|
|
12
|
+
return NO_JOINS;
|
|
13
|
+
}
|
|
14
|
+
const joins = new Map();
|
|
15
|
+
addPopulateJoins(joins, meta, q.$populate);
|
|
16
|
+
addSortJoins(joins, meta, q.$sort);
|
|
17
|
+
return joins;
|
|
18
|
+
}
|
|
19
|
+
function addJoin(joins, parent, key, relation, query, required, projected) {
|
|
20
|
+
const path = parent ? `${parent.path}.${key}` : key;
|
|
21
|
+
const existing = joins.get(path);
|
|
22
|
+
// `$populate` runs first, so an already-joined relation keeps its columns and its `$required`
|
|
23
|
+
// INNER join: sorting by it asks for nothing a populated join does not already provide.
|
|
24
|
+
if (existing) {
|
|
25
|
+
return existing;
|
|
26
|
+
}
|
|
27
|
+
const entity = relation.entity();
|
|
28
|
+
const join = {
|
|
29
|
+
key,
|
|
30
|
+
path,
|
|
31
|
+
entity,
|
|
32
|
+
meta: getMeta(entity),
|
|
33
|
+
relation,
|
|
34
|
+
query,
|
|
35
|
+
required,
|
|
36
|
+
projected,
|
|
37
|
+
parent,
|
|
38
|
+
};
|
|
39
|
+
joins.set(path, join);
|
|
40
|
+
return join;
|
|
41
|
+
}
|
|
42
|
+
function addPopulateJoins(joins, meta, populate, parent) {
|
|
43
|
+
for (const key of getRelationRequestSummary(meta, populate).joinableKeys) {
|
|
44
|
+
const relation = meta.relations[key];
|
|
45
|
+
if (!relation)
|
|
46
|
+
continue;
|
|
47
|
+
const { query, required } = parseRelationAtKey(key, populate);
|
|
48
|
+
const join = addJoin(joins, parent, key, relation, query, required, true);
|
|
49
|
+
addPopulateJoins(joins, join.meta, query.$populate, join);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function addSortJoins(joins, meta, sort, parent) {
|
|
53
|
+
if (!sort) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
for (const key of getKeys(sort)) {
|
|
57
|
+
const relation = meta.relations[key];
|
|
58
|
+
const value = sort[key];
|
|
59
|
+
// A to-many, or a value that is not a map of the relation's own fields, cannot be joined and is
|
|
60
|
+
// reported where the `ORDER BY` is rendered - the one place that knows how to name it.
|
|
61
|
+
if (!relation || isToManyRelation(relation) || !isSortMap(value)) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const join = addJoin(joins, parent, key, relation, {}, false, false);
|
|
65
|
+
addSortJoins(joins, join.meta, value, join);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
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.
|
|
74
|
+
*/
|
|
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) {
|
|
90
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) && !('$vector' in value);
|
|
91
|
+
}
|
|
@@ -22,7 +22,7 @@ export declare abstract class VectorSqlDialect extends AbstractDialect {
|
|
|
22
22
|
*/
|
|
23
23
|
protected readonly vectorDistanceFns: ReadonlyMap<VectorDistance, string>;
|
|
24
24
|
/** Quotes an identifier; supplied by the SQL dialect built on top of this layer. */
|
|
25
|
-
abstract escapeId(val: string, forbidQualified?: boolean, addDot?: boolean): string;
|
|
25
|
+
abstract escapeId(val: string | undefined, forbidQualified?: boolean, addDot?: boolean): string;
|
|
26
26
|
/**
|
|
27
27
|
* Resolve common parameters for a vector similarity ORDER BY expression.
|
|
28
28
|
* Shared by all dialect overrides of `appendVectorSort`.
|
|
@@ -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 {
|
|
@@ -95,32 +103,54 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
95
103
|
*/
|
|
96
104
|
private transformElemMatch;
|
|
97
105
|
select<E extends Document>(entity: Type<E>, select?: QuerySelectValue<E>, exclude?: QueryExclude<E>): Record<string, 0 | 1>;
|
|
98
|
-
|
|
106
|
+
/**
|
|
107
|
+
* The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
|
|
108
|
+
* on the SQL dialects - it is only addressable when the statement joins that relation. Here that
|
|
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.
|
|
111
|
+
*/
|
|
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;
|
|
115
|
+
/** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
|
|
116
|
+
sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
|
|
99
117
|
/**
|
|
100
118
|
* Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
|
|
101
119
|
* `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
|
|
102
120
|
*/
|
|
103
121
|
private aliasSort;
|
|
104
|
-
/** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
|
|
105
|
-
private sortBy;
|
|
106
122
|
/**
|
|
107
123
|
* {@link columnOf} for a possibly dotted key: only the root is a field key, the rest addresses an
|
|
108
124
|
* embedded path (`kind.city` -> `<kind's column>.city`).
|
|
109
125
|
*/
|
|
110
126
|
private pathOf;
|
|
111
|
-
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>[];
|
|
112
137
|
/**
|
|
113
138
|
* The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
|
|
114
139
|
* the joined documents, and the `_id` a to-many fill groups children by. It goes last, after the
|
|
115
140
|
* lookups have read the join keys - projecting any earlier is what used to leave `$populate`
|
|
116
141
|
* empty, and is why the pipeline emitted no projection at all and returned every column.
|
|
117
142
|
*/
|
|
118
|
-
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;
|
|
119
144
|
/**
|
|
120
145
|
* `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
|
|
121
146
|
* aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
|
|
122
147
|
*/
|
|
123
|
-
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;
|
|
124
154
|
/**
|
|
125
155
|
* The correlated join for a single-valued or one-to-many relation. MongoDB runs a lookup's `pipeline`
|
|
126
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,
|
|
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,
|
|
@@ -361,27 +362,49 @@ export class MongoDialect extends AbstractDialect {
|
|
|
361
362
|
}
|
|
362
363
|
return projection;
|
|
363
364
|
}
|
|
364
|
-
|
|
365
|
+
/**
|
|
366
|
+
* The `$sort` stage. A relation key reads the document a `$lookup` unwound onto the parent, so - as
|
|
367
|
+
* on the SQL dialects - it is only addressable when the statement joins that relation. Here that
|
|
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.
|
|
370
|
+
*/
|
|
371
|
+
sort(entity, sort, populate) {
|
|
365
372
|
const meta = getMeta(entity);
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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) {
|
|
381
|
+
for (const [key, value] of Object.entries(sort ?? {})) {
|
|
382
|
+
const relation = meta.relations[key];
|
|
383
|
+
if (!relation) {
|
|
384
|
+
out[path + this.pathOf(meta, key)] = sortDirection(value);
|
|
385
|
+
continue;
|
|
369
386
|
}
|
|
370
|
-
|
|
371
|
-
|
|
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);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
/** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
|
|
396
|
+
sortsRelations(entity, sort) {
|
|
397
|
+
const meta = getMeta(entity);
|
|
398
|
+
return Object.keys(sort ?? {}).some((key) => Boolean(meta.relations[key]));
|
|
372
399
|
}
|
|
373
400
|
/**
|
|
374
401
|
* Aggregate results are keyed by `$group`/`$agg` alias rather than by column, so an aggregate
|
|
375
402
|
* `$sort` addresses those aliases as-is - the same reason the SQL dialects sort by alias there.
|
|
376
403
|
*/
|
|
377
404
|
aliasSort(sort) {
|
|
378
|
-
return this.sortBy(sort, (alias) => alias);
|
|
379
|
-
}
|
|
380
|
-
/** Shared direction normalization; `mapKey` decides whether keys are columns or aggregate aliases. */
|
|
381
|
-
sortBy(sort, mapKey) {
|
|
382
405
|
const normalized = {};
|
|
383
|
-
for (const [
|
|
384
|
-
normalized[
|
|
406
|
+
for (const [alias, dir] of Object.entries(sort ?? {})) {
|
|
407
|
+
normalized[alias] = sortDirection(dir);
|
|
385
408
|
}
|
|
386
409
|
return normalized;
|
|
387
410
|
}
|
|
@@ -396,39 +419,47 @@ export class MongoDialect extends AbstractDialect {
|
|
|
396
419
|
}
|
|
397
420
|
return this.columnOf(meta, key.slice(0, dot)) + key.slice(dot);
|
|
398
421
|
}
|
|
399
|
-
aggregationPipeline(entity, q,
|
|
400
|
-
const { stages, filter, unset } = this.whereWithRelations(entity, q.$where, opts);
|
|
401
|
-
const sort = this.sort(entity, q.$sort);
|
|
402
|
-
const match = {};
|
|
403
|
-
if (hasKeys(filter)) {
|
|
404
|
-
match.$match = filter;
|
|
405
|
-
}
|
|
406
|
-
if (hasKeys(sort)) {
|
|
407
|
-
match.$sort = sort;
|
|
408
|
-
}
|
|
422
|
+
aggregationPipeline(entity, q, opts) {
|
|
409
423
|
// Lookups that a relation condition needs come first, then the match that reads them, then the
|
|
410
424
|
// temporary fields are dropped so they never reach the caller.
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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 } }] : []),
|
|
422
462
|
];
|
|
423
|
-
// A `$required` relation drops parents when it unwinds, so paging has to come after it - as it
|
|
424
|
-
// does after an INNER JOIN. Otherwise paging first is equivalent and spares the lookups.
|
|
425
|
-
const dropsParents = relStages.some((stage) => stage.$unwind?.preserveNullAndEmptyArrays === false);
|
|
426
|
-
pipeline.push(...(dropsParents ? [...relStages, ...pager] : [...pager, ...relStages]));
|
|
427
|
-
const projection = this.pipelineProjection(entity, q, relationSummary);
|
|
428
|
-
if (projection) {
|
|
429
|
-
pipeline.push({ $project: projection });
|
|
430
|
-
}
|
|
431
|
-
return pipeline;
|
|
432
463
|
}
|
|
433
464
|
/**
|
|
434
465
|
* The scalar projection a narrowing query asks for, widened by what the pipeline itself produced:
|
|
@@ -436,12 +467,12 @@ export class MongoDialect extends AbstractDialect {
|
|
|
436
467
|
* lookups have read the join keys - projecting any earlier is what used to leave `$populate`
|
|
437
468
|
* empty, and is why the pipeline emitted no projection at all and returned every column.
|
|
438
469
|
*/
|
|
439
|
-
pipelineProjection(entity, q
|
|
470
|
+
pipelineProjection(entity, q) {
|
|
440
471
|
if (!q.$select && !q.$exclude) {
|
|
441
472
|
return undefined;
|
|
442
473
|
}
|
|
443
474
|
const projection = this.select(entity, q.$select, q.$exclude);
|
|
444
|
-
const summary =
|
|
475
|
+
const summary = getRelationRequestSummary(getMeta(entity), q.$populate);
|
|
445
476
|
for (const relKey of summary.joinableKeys) {
|
|
446
477
|
projection[relKey] = 1;
|
|
447
478
|
}
|
|
@@ -455,46 +486,51 @@ export class MongoDialect extends AbstractDialect {
|
|
|
455
486
|
* `$lookup`/`$unwind` stages for the joinable relations a query populates. Shared by the plain
|
|
456
487
|
* aggregation pipeline and the `$vectorSearch` one, so relations load the same way in both.
|
|
457
488
|
*/
|
|
458
|
-
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.
|
|
459
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) {
|
|
460
501
|
const pipeline = [];
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
if (
|
|
465
|
-
continue;
|
|
466
|
-
if (relOpts.cardinality === '1m' || relOpts.cardinality === 'mm') {
|
|
467
|
-
// '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) {
|
|
468
506
|
continue;
|
|
469
507
|
}
|
|
470
|
-
const relEntity = relOpts.entity();
|
|
471
|
-
const relMeta = getMeta(relEntity);
|
|
472
|
-
const { query: relQuery, required } = parseRelationAtKey(relKey, q.$populate);
|
|
473
508
|
// Unconditional, not gated by an explicit relation-level `$where`: the related entity's own
|
|
474
509
|
// filters (in particular `security: true` ones) must apply even to a bare
|
|
475
510
|
// `$populate: { rel: true }`, exactly like the SQL dialects' JOIN ON-clause filters.
|
|
476
|
-
const relationFilter = this.where(
|
|
511
|
+
const relationFilter = this.where(join.entity, join.query.$where ?? {}, opts);
|
|
477
512
|
// The relation's own projection runs inside the lookup, where its keys resolve against the
|
|
478
513
|
// related entity. Left out, `$populate: { rel: { $select } }` returned all of `rel`'s columns.
|
|
479
|
-
const relationProjection = this.pipelineProjection(
|
|
514
|
+
const relationProjection = this.pipelineProjection(join.entity, join.query);
|
|
480
515
|
// MongoDB returns `_id` unless a projection subtracts it, so dropping the key from the map is
|
|
481
516
|
// how a joined document keeps its own id - as it does on the SQL dialects, and as a nested
|
|
482
517
|
// to-many fill needs.
|
|
483
518
|
delete relationProjection?.[MongoDialect.ID_KEY];
|
|
484
519
|
const lookupPipeline = [
|
|
485
520
|
...(hasKeys(relationFilter) ? [{ $match: relationFilter }] : []),
|
|
521
|
+
...this.lookupStages(join.meta, joins, join, opts),
|
|
486
522
|
...(relationProjection ? [{ $project: relationProjection }] : []),
|
|
487
523
|
];
|
|
488
524
|
pipeline.push({
|
|
489
525
|
$lookup: {
|
|
490
|
-
from: this.resolveTableName(
|
|
491
|
-
...this.joinKeys(
|
|
526
|
+
from: this.resolveTableName(join.entity, join.meta),
|
|
527
|
+
...this.joinKeys(parentMeta, join.meta, join.relation),
|
|
492
528
|
...(lookupPipeline.length ? { pipeline: lookupPipeline } : {}),
|
|
493
|
-
as:
|
|
529
|
+
as: join.key,
|
|
494
530
|
},
|
|
495
531
|
});
|
|
496
532
|
// `$required` drops parents with no match, the aggregation equivalent of an INNER JOIN.
|
|
497
|
-
pipeline.push({ $unwind: { path: `$${
|
|
533
|
+
pipeline.push({ $unwind: { path: `$${join.key}`, preserveNullAndEmptyArrays: !join.required } });
|
|
498
534
|
}
|
|
499
535
|
return pipeline;
|
|
500
536
|
}
|
|
@@ -768,11 +804,10 @@ export class MongoDialect extends AbstractDialect {
|
|
|
768
804
|
extractVectorSort(sort) {
|
|
769
805
|
if (!sort)
|
|
770
806
|
return undefined;
|
|
771
|
-
const raw = buildSortMap(sort);
|
|
772
807
|
let vectorKey;
|
|
773
808
|
let vectorSearch;
|
|
774
809
|
const regularSort = {};
|
|
775
|
-
for (const [key, value] of Object.entries(
|
|
810
|
+
for (const [key, value] of Object.entries(sort)) {
|
|
776
811
|
if (isVectorSearch(value)) {
|
|
777
812
|
vectorKey = key;
|
|
778
813
|
vectorSearch = value;
|
|
@@ -820,3 +855,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
820
855
|
return { $vectorSearch: stage };
|
|
821
856
|
}
|
|
822
857
|
}
|
|
858
|
+
/** `-1` for the two descending spellings, `1` for everything else - MongoDB knows no other value. */
|
|
859
|
+
function sortDirection(value) {
|
|
860
|
+
return value === 'desc' || value === -1 ? -1 : 1;
|
|
861
|
+
}
|