uql-orm 0.36.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/browser/http/http.d.ts +1 -1
- package/dist/browser/querier/httpQuerier.d.ts +12 -7
- package/dist/browser/querier/httpQuerier.js +6 -1
- package/dist/browser/type/clientQuerier.d.ts +10 -23
- package/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +7 -7
- package/dist/cockroachdb/cockroachDialect.d.ts +8 -1
- package/dist/cockroachdb/cockroachDialect.js +12 -0
- package/dist/dialect/abstractSqlDialect.d.ts +18 -2
- package/dist/dialect/abstractSqlDialect.js +45 -12
- package/dist/dialect/mysqlLikeSqlDialect.d.ts +6 -0
- package/dist/dialect/mysqlLikeSqlDialect.js +19 -1
- package/dist/dialect/queryJoins.d.ts +6 -0
- package/dist/dialect/queryJoins.js +13 -0
- package/dist/http/contract.d.ts +0 -7
- package/dist/http/handler.d.ts +2 -2
- package/dist/http/handler.js +1 -1
- package/dist/http/query.js +3 -2
- package/dist/mongo/mongoDialect.d.ts +21 -1
- package/dist/mongo/mongoDialect.js +70 -11
- package/dist/mongo/mongodbQuerier.d.ts +13 -2
- package/dist/mongo/mongodbQuerier.js +44 -3
- package/dist/postgres/postgresDialect.d.ts +7 -0
- package/dist/postgres/postgresDialect.js +13 -0
- package/dist/querier/abstractQuerier.d.ts +34 -18
- package/dist/querier/abstractQuerier.js +39 -25
- package/dist/querier/abstractQuerierPool.d.ts +9 -7
- package/dist/querier/abstractQuerierPool.js +6 -0
- package/dist/querier/abstractSqlQuerier.d.ts +20 -2
- package/dist/querier/abstractSqlQuerier.js +44 -8
- package/dist/querier/relationCount.d.ts +16 -0
- package/dist/querier/relationCount.js +111 -0
- package/dist/sqlite/sqliteDialect.d.ts +6 -1
- package/dist/sqlite/sqliteDialect.js +10 -0
- package/dist/type/dialect.d.ts +4 -3
- package/dist/type/index.d.ts +1 -0
- package/dist/type/index.js +1 -0
- package/dist/type/querier.d.ts +21 -14
- package/dist/type/query.d.ts +77 -16
- package/dist/type/query.js +17 -0
- package/dist/type/universalQuerier.d.ts +85 -48
- package/dist/type/wire.d.ts +37 -0
- package/dist/type/wire.js +1 -0
- package/dist/util/dialect.util.d.ts +16 -0
- package/dist/util/dialect.util.js +25 -0
- package/dist/util/relationQuery.util.d.ts +9 -0
- package/dist/util/relationQuery.util.js +13 -0
- package/package.json +4 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getMeta } from '../entity/index.js';
|
|
2
2
|
import { parseQueryLock, QueryRaw, RAW_ALIAS, RAW_VALUE, } from '../type/index.js';
|
|
3
|
-
import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, populatesRelations, raw, someValue, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
3
|
+
import { asSelectMap, assertNonNegativeInteger, buildQueryWhereAsMap, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getSoftDeleteValue, hasKeys, isBooleanType, isJsonType, isJsonUpdateOp, isNumericType, isOperatorMap, isOperatorObject, isOperatorOnlyObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, populatesRelations, raw, someValue, throwUnknownAggregateColumn, 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';
|
|
@@ -8,6 +8,11 @@ import { isJsonbOp, JSON_ELEM_ALIAS_PREFIX, jsonCompareMode, jsonElemExists } fr
|
|
|
8
8
|
import { SqlQueryContext } from './queryContext.js';
|
|
9
9
|
import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
|
|
10
10
|
import { isVectorFieldType, resolveVectorCast } from './vectorCast.js';
|
|
11
|
+
/**
|
|
12
|
+
* The column a counting statement answers in. Shared rather than spelled at each end: the dialects
|
|
13
|
+
* emit it and `runCount` reads it back, and a rename on one side alone would quietly count zero.
|
|
14
|
+
*/
|
|
15
|
+
export const COUNT_ALIAS = 'count';
|
|
11
16
|
export class AbstractSqlDialect extends IndexSqlDialect {
|
|
12
17
|
/**
|
|
13
18
|
* How this engine declares a namespace, so a generated migration creates the schemas its tables
|
|
@@ -171,7 +176,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
171
176
|
appendTextSearch(_ctx, _entity, _meta, _search) {
|
|
172
177
|
throw new TypeError(`${this.dialectName} does not support $text full-text search`);
|
|
173
178
|
}
|
|
174
|
-
select(ctx, entity, q, opts = {}, joins = NO_JOINS) {
|
|
179
|
+
select(ctx, entity, q, opts = {}, joins = NO_JOINS, totalAlias) {
|
|
175
180
|
const meta = getMeta(entity);
|
|
176
181
|
const { alias, ref } = this.tableRef(meta);
|
|
177
182
|
const prefix = this.resolveRelationAwarePrefix(alias, meta, opts, q.$populate, joins);
|
|
@@ -186,6 +191,9 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
186
191
|
this.appendVectorProjection(ctx, meta, key, val);
|
|
187
192
|
}
|
|
188
193
|
}
|
|
194
|
+
if (totalAlias) {
|
|
195
|
+
ctx.append(`, ${this.totalOverExpr} ${this.escapeId(totalAlias, true)}`);
|
|
196
|
+
}
|
|
189
197
|
ctx.append(` FROM ${ref}`);
|
|
190
198
|
// Add JOINs AFTER FROM clause
|
|
191
199
|
this.selectRelationJoins(ctx, meta, alias, joins);
|
|
@@ -691,6 +699,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
691
699
|
const relation = meta.relations[key];
|
|
692
700
|
if (relation) {
|
|
693
701
|
const relPath = path ? `${path}.${key}` : key;
|
|
702
|
+
const countDirection = parseSortByCount(value);
|
|
703
|
+
if (countDirection !== undefined) {
|
|
704
|
+
// A correlated count, not a join: a parent has many of these, so what is being ordered by
|
|
705
|
+
// is how many, and `SELECT DISTINCT` cannot order by an expression it did not select.
|
|
706
|
+
if (opts.distinct) {
|
|
707
|
+
throw new TypeError(`cannot $sort by '${relPath}.$count' with $distinct: it is not a selected column`);
|
|
708
|
+
}
|
|
709
|
+
columns.push(this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relation, { prefix }, 'COUNT(*)', {})) + this.resolveSortDirection(countDirection));
|
|
710
|
+
continue;
|
|
711
|
+
}
|
|
694
712
|
const { join, sort: relationSort } = resolveSortableJoin(relation, relPath, value, opts.joins ?? NO_JOINS, `cannot $sort by relation '${relPath}': this statement joins no relations`);
|
|
695
713
|
// `SELECT DISTINCT` can only order by what it selected, on every engine here, so a join
|
|
696
714
|
// brought in for the sort alone has nothing to order by. Populating it selects its columns.
|
|
@@ -767,11 +785,22 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
767
785
|
const suffix = wait === 'skip' ? ' SKIP LOCKED' : wait === 'nowait' ? ' NOWAIT' : '';
|
|
768
786
|
ctx.append(` FOR UPDATE${target}${suffix}`);
|
|
769
787
|
}
|
|
770
|
-
// `QueryFilter`, not `
|
|
771
|
-
// would push its single row out of the result set. The
|
|
788
|
+
// `QueryFilter`, not `QueryPage`: a count orders nothing and pages nothing, and an `OFFSET`
|
|
789
|
+
// would push its single row out of the result set. The type only stops a statically-checked
|
|
790
|
+
// caller though - the HTTP handler hands this `q` straight from the wire - so `$where` is read
|
|
791
|
+
// off it explicitly rather than forwarding `q` itself into `search()`, which would honor a
|
|
792
|
+
// `$sort`/`$skip`/`$limit` an untyped caller snuck in regardless of what TypeScript allowed them.
|
|
772
793
|
count(ctx, entity, q, opts) {
|
|
773
|
-
this.select(ctx, entity, { $select: [raw('COUNT(*)',
|
|
774
|
-
this.search(ctx, entity, q, opts);
|
|
794
|
+
this.select(ctx, entity, { $select: [raw('COUNT(*)', COUNT_ALIAS)] });
|
|
795
|
+
this.search(ctx, entity, { $where: q.$where }, opts);
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* The statistic the engine already keeps, as a `count` column. Overridden by the dialects that
|
|
799
|
+
* keep one; the rest throw, because falling back to `COUNT(*)` would run exactly the scan the
|
|
800
|
+
* caller reached for this to avoid, and only say so by taking a long time.
|
|
801
|
+
*/
|
|
802
|
+
estimatedCount(_ctx, _entity) {
|
|
803
|
+
throw new TypeError(`${this.dialectName} does not support estimatedCount`);
|
|
775
804
|
}
|
|
776
805
|
/** `$group` aggregate operator → SQL function name. An allowlist, not a formatter: the op key
|
|
777
806
|
* comes from query data, so anything outside this map must be rejected rather than passed
|
|
@@ -882,11 +911,16 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
882
911
|
}
|
|
883
912
|
});
|
|
884
913
|
}
|
|
885
|
-
|
|
914
|
+
/**
|
|
915
|
+
* How many rows the filter matched, on every row of the page. A window function runs before
|
|
916
|
+
* LIMIT/OFFSET, so what it counts is the whole match rather than the page cut out of it.
|
|
917
|
+
*/
|
|
918
|
+
totalOverExpr = 'COUNT(*) OVER ()';
|
|
919
|
+
find(ctx, entity, q = {}, opts, totalAlias) {
|
|
886
920
|
// The one statement that can join, so the one that resolves the join set; everything else renders
|
|
887
921
|
// against `NO_JOINS` and rejects a `$sort` that would need one.
|
|
888
922
|
const joins = resolveQueryJoins(getMeta(entity), q);
|
|
889
|
-
this.select(ctx, entity, q, opts, joins);
|
|
923
|
+
this.select(ctx, entity, q, opts, joins, totalAlias);
|
|
890
924
|
this.search(ctx, entity, q, opts, joins);
|
|
891
925
|
// Appended here rather than in `search`, which `count`/`update`/`delete` share: a lock belongs
|
|
892
926
|
// to a SELECT alone. Every engine spells it after LIMIT/OFFSET, so it goes last.
|
|
@@ -1388,8 +1422,7 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
1388
1422
|
* invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
|
|
1389
1423
|
* not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
|
|
1390
1424
|
*/
|
|
1391
|
-
appendRelationSubquery(ctx,
|
|
1392
|
-
const meta = getMeta(entity);
|
|
1425
|
+
appendRelationSubquery(ctx, meta, rel, opts, projection, val) {
|
|
1393
1426
|
// Aliases, not paths, everywhere a column is prefixed; `tableRef` declares them in the FROM.
|
|
1394
1427
|
const parentAlias = this.resolveTableAlias(meta);
|
|
1395
1428
|
const references = rel.references;
|
|
@@ -1429,11 +1462,11 @@ export class AbstractSqlDialect extends IndexSqlDialect {
|
|
|
1429
1462
|
/** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
|
|
1430
1463
|
compareRelation(ctx, entity, val, rel, opts) {
|
|
1431
1464
|
ctx.append('EXISTS ');
|
|
1432
|
-
this.appendRelationSubquery(ctx, entity, rel, opts, '1', val);
|
|
1465
|
+
this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, '1', val);
|
|
1433
1466
|
}
|
|
1434
1467
|
/** Filter by relation size: the same subquery, counting instead of testing for existence. */
|
|
1435
1468
|
compareRelationSize(ctx, entity, sizeVal, rel, opts) {
|
|
1436
|
-
this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, entity, rel, opts, 'COUNT(*)', {}), sizeVal);
|
|
1469
|
+
this.buildSizeComparison(ctx, () => this.appendRelationSubquery(ctx, getMeta(entity), rel, opts, 'COUNT(*)', {}), sizeVal);
|
|
1437
1470
|
}
|
|
1438
1471
|
/**
|
|
1439
1472
|
* Build a complete `$size` comparison expression.
|
|
@@ -13,6 +13,12 @@ import { AbstractSqlDialect } from './abstractSqlDialect.js';
|
|
|
13
13
|
export declare abstract class MysqlLikeSqlDialect extends AbstractSqlDialect {
|
|
14
14
|
/** Default {@link DialectFeatures} for MySQL-compatible SQL dialects. */
|
|
15
15
|
protected readonly featureDefaults: DialectFeatures;
|
|
16
|
+
/**
|
|
17
|
+
* `information_schema` keeps InnoDB's own row estimate, which is live enough to answer before
|
|
18
|
+
* anything has been analyzed. `DATABASE()` where the entity names no schema, so the estimate comes
|
|
19
|
+
* from the connection's own database rather than a same-named table in another one.
|
|
20
|
+
*/
|
|
21
|
+
estimatedCount<E>(ctx: QueryContext, entity: Type<E>): void;
|
|
16
22
|
/** `OFFSET` is only legal after a `LIMIT` here, so a bare `$skip` needs one. */
|
|
17
23
|
pager(ctx: QueryContext, opts: QueryPager): void;
|
|
18
24
|
readonly serialPrimaryKey = "BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getMeta } from '../entity/index.js';
|
|
2
2
|
import { getFieldKeys } from '../util/index.js';
|
|
3
3
|
import { escapeMysqlSqlLiteral, escapeSingleQuotes } from '../util/sqlLiteral.js';
|
|
4
|
-
import { AbstractSqlDialect } from './abstractSqlDialect.js';
|
|
4
|
+
import { AbstractSqlDialect, COUNT_ALIAS } from './abstractSqlDialect.js';
|
|
5
5
|
import { JSON_PULL_ALIAS, jsonAssignCall, jsonPath, jsonRemoveCall, jsonSetTarget } from './jsonSql.js';
|
|
6
6
|
/** The row count MySQL's manual gives for "all rows from the offset on": the largest `BIGINT UNSIGNED`. */
|
|
7
7
|
const MAX_LIMIT = BigInt.asUintN(64, -1n);
|
|
@@ -33,6 +33,24 @@ export class MysqlLikeSqlDialect extends AbstractSqlDialect {
|
|
|
33
33
|
supportsTimestamptz: false,
|
|
34
34
|
defaultStringAsText: false,
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* `information_schema` keeps InnoDB's own row estimate, which is live enough to answer before
|
|
38
|
+
* anything has been analyzed. `DATABASE()` where the entity names no schema, so the estimate comes
|
|
39
|
+
* from the connection's own database rather than a same-named table in another one.
|
|
40
|
+
*/
|
|
41
|
+
estimatedCount(ctx, entity) {
|
|
42
|
+
const meta = getMeta(entity);
|
|
43
|
+
const schema = this.resolveSchema(meta);
|
|
44
|
+
ctx.append(`SELECT TABLE_ROWS ${this.escapeId(COUNT_ALIAS, true)} FROM information_schema.TABLES WHERE TABLE_SCHEMA = `);
|
|
45
|
+
if (schema) {
|
|
46
|
+
ctx.addValue(schema);
|
|
47
|
+
}
|
|
48
|
+
else {
|
|
49
|
+
ctx.append('DATABASE()');
|
|
50
|
+
}
|
|
51
|
+
ctx.append(' AND TABLE_NAME = ');
|
|
52
|
+
ctx.addValue(this.resolveTableAlias(meta));
|
|
53
|
+
}
|
|
36
54
|
/** `OFFSET` is only legal after a `LIMIT` here, so a bare `$skip` needs one. */
|
|
37
55
|
pager(ctx, opts) {
|
|
38
56
|
if (opts.$limit === undefined && opts.$skip !== undefined) {
|
|
@@ -40,6 +40,12 @@ export type QuerySortOptions = {
|
|
|
40
40
|
* statement. `$sort` contributes to-one relations only; the rest is rejected where it is rendered.
|
|
41
41
|
*/
|
|
42
42
|
export declare function resolveQueryJoins<E>(meta: EntityMeta<E>, q: Query<E>): QueryJoins;
|
|
43
|
+
/**
|
|
44
|
+
* Whether a join drops parents that have no match, which is the one thing a join does to *how many*
|
|
45
|
+
* rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
|
|
46
|
+
* it counts the parents the read will never hand back.
|
|
47
|
+
*/
|
|
48
|
+
export declare function hasRequiredJoin<E>(meta: EntityMeta<E>, q: Query<E>): boolean;
|
|
43
49
|
/**
|
|
44
50
|
* The join an ordering may address at `path`, with the relation's own sort map, or why it may not.
|
|
45
51
|
* Every backend answers this the same way - a to-many has no single value to order by, a relation
|
|
@@ -16,6 +16,19 @@ export function resolveQueryJoins(meta, q) {
|
|
|
16
16
|
addSortJoins(joins, meta, q.$sort);
|
|
17
17
|
return joins;
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Whether a join drops parents that have no match, which is the one thing a join does to *how many*
|
|
21
|
+
* rows a read returns rather than how wide they are. A count that skips the joins has to be told, or
|
|
22
|
+
* it counts the parents the read will never hand back.
|
|
23
|
+
*/
|
|
24
|
+
export function hasRequiredJoin(meta, q) {
|
|
25
|
+
for (const join of resolveQueryJoins(meta, q).values()) {
|
|
26
|
+
if (join.required) {
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
19
32
|
function addJoin(joins, parent, key, relation, query, required, projected) {
|
|
20
33
|
const path = parent ? `${parent.path}.${key}` : key;
|
|
21
34
|
const existing = joins.get(path);
|
package/dist/http/contract.d.ts
CHANGED
|
@@ -78,13 +78,6 @@ export type RouteMatch = {
|
|
|
78
78
|
* Resolve a (method, sub-path) pair to a CRUD operation. Literal sub-paths win over `:id`.
|
|
79
79
|
*/
|
|
80
80
|
export declare function matchRoute(method: string, subPath: string | undefined): RouteMatch | undefined;
|
|
81
|
-
export type RequestSuccessResponse<E> = {
|
|
82
|
-
data: E;
|
|
83
|
-
count?: number;
|
|
84
|
-
};
|
|
85
|
-
export type RequestCountedSuccessResponse<E> = RequestSuccessResponse<E> & {
|
|
86
|
-
count: number;
|
|
87
|
-
};
|
|
88
81
|
export type RequestErrorResponse = {
|
|
89
82
|
readonly error: {
|
|
90
83
|
readonly message: string;
|
package/dist/http/handler.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { EntityMeta, QuerierPool, Query, Type, UqlContext } from '../type/index.js';
|
|
2
|
-
import { type CrudOperation, type HttpMethod
|
|
1
|
+
import type { EntityMeta, QuerierPool, Query, RequestSuccessResponse, Type, UqlContext } from '../type/index.js';
|
|
2
|
+
import { type CrudOperation, type HttpMethod } from './contract.js';
|
|
3
3
|
/**
|
|
4
4
|
* Framework-normalized request: adapters (express, fetch, ...) reduce their native
|
|
5
5
|
* request to this shape and get back a status + JSON body.
|
package/dist/http/handler.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withContext } from '../context/context.js';
|
|
2
2
|
import { getEntities, getMeta } from '../entity/index.js';
|
|
3
|
-
import { entityPath, matchRoute
|
|
3
|
+
import { entityPath, matchRoute } from './contract.js';
|
|
4
4
|
import { parseQueryParams } from './query.js';
|
|
5
5
|
/** `Company (crm.Company)`: the class, and the table it maps, which is what tells two apart. */
|
|
6
6
|
function tableOf(entity) {
|
package/dist/http/query.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// the clause lists themselves, not the barrel: this module is in the browser bundle's graph
|
|
2
|
-
import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES } from '../type/query.js';
|
|
2
|
+
import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_OBJECT_CLAUSES, } from '../type/query.js';
|
|
3
3
|
// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
|
|
4
4
|
import { getKeys } from '../util/object.util.js';
|
|
5
5
|
/**
|
|
@@ -10,6 +10,7 @@ import { getKeys } from '../util/object.util.js';
|
|
|
10
10
|
*/
|
|
11
11
|
const ALLOWED_QUERY_KEYS = new Set([
|
|
12
12
|
...QUERY_OBJECT_CLAUSES,
|
|
13
|
+
...QUERY_ROOT_OBJECT_CLAUSES,
|
|
13
14
|
...QUERY_NUMBER_CLAUSES,
|
|
14
15
|
...QUERY_BOOLEAN_CLAUSES,
|
|
15
16
|
'hardDelete',
|
|
@@ -36,7 +37,7 @@ export function parseQueryParams(params = {}) {
|
|
|
36
37
|
query[key] = params[key];
|
|
37
38
|
}
|
|
38
39
|
}
|
|
39
|
-
for (const key of QUERY_OBJECT_CLAUSES) {
|
|
40
|
+
for (const key of [...QUERY_OBJECT_CLAUSES, ...QUERY_ROOT_OBJECT_CLAUSES]) {
|
|
40
41
|
const value = query[key];
|
|
41
42
|
if (typeof value === 'string') {
|
|
42
43
|
try {
|
|
@@ -20,7 +20,12 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
20
20
|
private static readonly ID_KEY;
|
|
21
21
|
/** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
|
|
22
22
|
private static readonly REL_TEMP_PREFIX;
|
|
23
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Where a `$sort` by a relation's size parks its tally, until the ordering has run. One spelling
|
|
25
|
+
* for both ends: the `$sort` names this field and {@link sortCountStages} produces it, and MongoDB
|
|
26
|
+
* ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
|
|
27
|
+
*/
|
|
28
|
+
private static sortCountField;
|
|
24
29
|
private static readonly REL_NESTED_KEY;
|
|
25
30
|
private static readonly VECTOR_INDEX_TYPES;
|
|
26
31
|
/** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
|
|
@@ -60,6 +65,12 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
60
65
|
* relation subquery can no more read out-of-scope rows than a direct query on the target can.
|
|
61
66
|
*/
|
|
62
67
|
private appendRelationLookup;
|
|
68
|
+
/**
|
|
69
|
+
* The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
|
|
70
|
+
* straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
|
|
71
|
+
* for existence, or a `$count` - so a filter and an ordering build the same stage.
|
|
72
|
+
*/
|
|
73
|
+
private relationLookup;
|
|
63
74
|
/**
|
|
64
75
|
* ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
|
|
65
76
|
* lookup - the junction's filters apply too, since a soft-deleted link is not a link.
|
|
@@ -112,6 +123,15 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
112
123
|
sort<E extends Document>(entity: Type<E>, sort?: QuerySortMap<E>, populate?: QueryPopulate<E>): Sort;
|
|
113
124
|
/** Walks `$sort` against the metadata of the entity each level addresses, as the SQL dialects do. */
|
|
114
125
|
private collectSort;
|
|
126
|
+
/**
|
|
127
|
+
* The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
|
|
128
|
+
* per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
|
|
129
|
+
* orders by. A parent with no related row gets no lookup result at all, which is a zero.
|
|
130
|
+
*/
|
|
131
|
+
sortCountStages<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined, opts?: QueryOptions): {
|
|
132
|
+
readonly stages: MongoAggregationPipelineEntry<Document>[];
|
|
133
|
+
readonly fields: string[];
|
|
134
|
+
};
|
|
115
135
|
/** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
|
|
116
136
|
sortsRelations<E extends Document>(entity: Type<E>, sort: QuerySortMap<E> | undefined): boolean;
|
|
117
137
|
/**
|
|
@@ -3,7 +3,7 @@ import { AbstractDialect } from '../dialect/abstractDialect.js';
|
|
|
3
3
|
import { resolveQueryJoins, resolveSortableJoin } from '../dialect/queryJoins.js';
|
|
4
4
|
import { getMeta } from '../entity/index.js';
|
|
5
5
|
import { QueryRaw } from '../type/queryRaw.js';
|
|
6
|
-
import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, someKey, } from '../util/index.js';
|
|
6
|
+
import { asSelectMap, assertAggregateColumns, assertNonNegativeInteger, buildQueryWhereAsMap, COUNT_AGG_ALIAS, fillOnFields, filterFieldKeys, getKeys, getRelationRequestSummary, hasKeys, isJsonUpdateOp, isOperatorMap, isOperatorObject, isVectorSearch, normalizeScalarFieldSelection, parseGroupMap, parseRelationSize, parseSortByCount, someKey, } from '../util/index.js';
|
|
7
7
|
/** Default {@link DialectFeatures} for MongoDB; shared by {@link MongoDialect} and its schema generator. */
|
|
8
8
|
export const mongoDialectFeatures = {
|
|
9
9
|
explicitJsonCast: false,
|
|
@@ -29,7 +29,14 @@ export class MongoDialect extends AbstractDialect {
|
|
|
29
29
|
static ID_KEY = '_id';
|
|
30
30
|
/** Temporary lookup fields for relation conditions, dropped with `$unset` after the `$match`. */
|
|
31
31
|
static REL_TEMP_PREFIX = '__uql_rel_';
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Where a `$sort` by a relation's size parks its tally, until the ordering has run. One spelling
|
|
34
|
+
* for both ends: the `$sort` names this field and {@link sortCountStages} produces it, and MongoDB
|
|
35
|
+
* ranks a field that is not there as all-equal rather than failing, so a drift would go unnoticed.
|
|
36
|
+
*/
|
|
37
|
+
static sortCountField(relKey) {
|
|
38
|
+
return `__uql_sort_count_${relKey}`;
|
|
39
|
+
}
|
|
33
40
|
static REL_NESTED_KEY = '__uql_target';
|
|
34
41
|
static VECTOR_INDEX_TYPES = new Set(['vectorSearch', 'hnsw', 'ivfflat', 'vector']);
|
|
35
42
|
/** Atlas rejects a `$vectorSearch` asking for more candidates than this. */
|
|
@@ -145,7 +152,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
145
152
|
const temp = `${MongoDialect.REL_TEMP_PREFIX}${lookups.temps.length}`;
|
|
146
153
|
const sizeVal = parseRelationSize(val);
|
|
147
154
|
// `$count` for a size test, `$limit: 1` for existence: neither returns the matched documents.
|
|
148
|
-
const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count:
|
|
155
|
+
const tail = sizeVal === undefined ? [{ $limit: 1 }] : [{ $count: COUNT_AGG_ALIAS }];
|
|
149
156
|
// Scope first, render once - merging the target's filters into an already-rendered filter would
|
|
150
157
|
// leave their own keys unmapped. The caller's filter bypass is deliberately *not* passed down:
|
|
151
158
|
// `withDeleted()` or `hardDelete` on the parent must not un-hide trashed rows of the target, the
|
|
@@ -153,7 +160,18 @@ export class MongoDialect extends AbstractDialect {
|
|
|
153
160
|
const targetCondition = (sizeVal === undefined ? val : {});
|
|
154
161
|
const targetScope = this.renderFilter(relEntity, this.scopedWhereMap(relMeta, targetCondition), opts);
|
|
155
162
|
lookups.temps.push(temp);
|
|
156
|
-
lookups.stages.push(
|
|
163
|
+
lookups.stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts));
|
|
164
|
+
return sizeVal === undefined
|
|
165
|
+
? { [`${temp}.0`]: { $exists: true } }
|
|
166
|
+
: { $expr: this.compareRelationCount(temp, sizeVal) };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* The correlated `$lookup` for one relation, as `temp`: through its junction for a ManyToMany, or
|
|
170
|
+
* straight at the target otherwise. `tail` decides what the lookup leaves behind - a row to test
|
|
171
|
+
* for existence, or a `$count` - so a filter and an ordering build the same stage.
|
|
172
|
+
*/
|
|
173
|
+
relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts) {
|
|
174
|
+
return relOpts.cardinality === 'mm' && relOpts.through
|
|
157
175
|
? this.junctionLookup(relOpts, relMeta, relEntity, targetScope, temp, tail, opts)
|
|
158
176
|
: {
|
|
159
177
|
$lookup: {
|
|
@@ -162,10 +180,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
162
180
|
pipeline: [...(hasKeys(targetScope) ? [{ $match: targetScope }] : []), ...tail],
|
|
163
181
|
as: temp,
|
|
164
182
|
},
|
|
165
|
-
}
|
|
166
|
-
return sizeVal === undefined
|
|
167
|
-
? { [`${temp}.0`]: { $exists: true } }
|
|
168
|
-
: { $expr: this.compareRelationCount(temp, sizeVal) };
|
|
183
|
+
};
|
|
169
184
|
}
|
|
170
185
|
/**
|
|
171
186
|
* ManyToMany counts/tests junction rows, so the target is reached from inside the junction's own
|
|
@@ -204,7 +219,7 @@ export class MongoDialect extends AbstractDialect {
|
|
|
204
219
|
* the `$ifNull` fallback to 0, so `{ $size: 0 }` matches parents with no related row at all.
|
|
205
220
|
*/
|
|
206
221
|
compareRelationCount(temp, sizeVal) {
|
|
207
|
-
const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${
|
|
222
|
+
const count = { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] };
|
|
208
223
|
if (typeof sizeVal === 'number') {
|
|
209
224
|
return { $eq: [count, sizeVal] };
|
|
210
225
|
}
|
|
@@ -397,10 +412,47 @@ export class MongoDialect extends AbstractDialect {
|
|
|
397
412
|
// one: ordering by a relation nothing looked up reads a field that is not there, which MongoDB
|
|
398
413
|
// ranks as all-equal rather than rejecting. The SQL dialects can add the join themselves.
|
|
399
414
|
const relPath = `${path}${key}`;
|
|
415
|
+
const countDirection = parseSortByCount(value);
|
|
416
|
+
if (countDirection !== undefined) {
|
|
417
|
+
// The tally rides on a field {@link sortCountStages} adds, which only the queried entity's
|
|
418
|
+
// own pipeline has: a nested one is built inside its parent's `$lookup`, where there is no
|
|
419
|
+
// parent document left to hang it off.
|
|
420
|
+
if (path) {
|
|
421
|
+
throw new TypeError(`$sort by '${relPath}.$count' is only supported on the queried entity`);
|
|
422
|
+
}
|
|
423
|
+
out[MongoDialect.sortCountField(key)] = sortDirection(countDirection);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
400
426
|
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`);
|
|
401
427
|
this.collectSort(join.meta, relationSort, joins, `${relPath}.`, out);
|
|
402
428
|
}
|
|
403
429
|
}
|
|
430
|
+
/**
|
|
431
|
+
* The stages a `$sort` by a relation's size needs: one correlated `$lookup` tallying the relation
|
|
432
|
+
* per parent, and the `$set` that lifts the tally onto the document as the field the `$sort` then
|
|
433
|
+
* orders by. A parent with no related row gets no lookup result at all, which is a zero.
|
|
434
|
+
*/
|
|
435
|
+
sortCountStages(entity, sort, opts) {
|
|
436
|
+
const meta = getMeta(entity);
|
|
437
|
+
const stages = [];
|
|
438
|
+
const fields = [];
|
|
439
|
+
for (const [key, value] of Object.entries(sort ?? {})) {
|
|
440
|
+
const relOpts = meta.relations[key];
|
|
441
|
+
if (!relOpts || parseSortByCount(value) === undefined) {
|
|
442
|
+
continue;
|
|
443
|
+
}
|
|
444
|
+
const relEntity = relOpts.entity();
|
|
445
|
+
const relMeta = getMeta(relEntity);
|
|
446
|
+
const temp = MongoDialect.sortCountField(key);
|
|
447
|
+
const targetScope = this.renderFilter(relEntity, this.scopedWhereMap(relMeta, {}), opts);
|
|
448
|
+
const tail = [{ $count: COUNT_AGG_ALIAS }];
|
|
449
|
+
stages.push(this.relationLookup(meta, relOpts, relMeta, relEntity, targetScope, temp, tail, opts), {
|
|
450
|
+
$addFields: { [temp]: { $ifNull: [{ $arrayElemAt: [`$${temp}.${COUNT_AGG_ALIAS}`, 0] }, 0] } },
|
|
451
|
+
});
|
|
452
|
+
fields.push(temp);
|
|
453
|
+
}
|
|
454
|
+
return { stages, fields };
|
|
455
|
+
}
|
|
404
456
|
/** Whether a `$sort` reads a relation, which is what forces the lookups to run before it. */
|
|
405
457
|
sortsRelations(entity, sort) {
|
|
406
458
|
if (!sort) {
|
|
@@ -459,7 +511,10 @@ export class MongoDialect extends AbstractDialect {
|
|
|
459
511
|
readStages(entity, q, opts, extra = {}) {
|
|
460
512
|
const meta = getMeta(entity);
|
|
461
513
|
const joins = resolveQueryJoins(meta, q);
|
|
462
|
-
|
|
514
|
+
// The tally an ordering by a relation's size reads, and the field it parks it on: both belong
|
|
515
|
+
// with the lookups, since the `$sort` right after them is what they exist for.
|
|
516
|
+
const counted = this.sortCountStages(entity, q.$sort, opts);
|
|
517
|
+
const lookups = [...this.lookupStages(meta, joins, undefined, opts), ...counted.stages];
|
|
463
518
|
const sort = hasKeys(extra.sort) ? [{ $sort: extra.sort }] : [];
|
|
464
519
|
const pager = extra.pager ?? [];
|
|
465
520
|
// Merged into the query's own projection rather than standing in for one: a query that asked
|
|
@@ -471,10 +526,14 @@ export class MongoDialect extends AbstractDialect {
|
|
|
471
526
|
// which is the one way this differs from a SQL join. Taken back out once the `$sort` that needed
|
|
472
527
|
// it has run, so ordering by an unpopulated relation costs the same nothing it does there.
|
|
473
528
|
const sortOnly = [...joins.values()].filter((join) => !join.projected).map((join) => join.path);
|
|
474
|
-
const
|
|
529
|
+
const dropped = [...sortOnly, ...counted.fields];
|
|
530
|
+
const unset = dropped.length ? [{ $unset: dropped }] : [];
|
|
475
531
|
// The grouping collapses rows onto the columns it projects, which leaves nothing for an ordering
|
|
476
532
|
// that reads a lookup those columns do not carry. Refused rather than answered all-equal, and in
|
|
477
533
|
// the same terms the SQL dialects refuse `SELECT DISTINCT` ordered by an unselected column.
|
|
534
|
+
if (q.$distinct && counted.fields.length) {
|
|
535
|
+
throw new TypeError(`cannot $sort by a relation's $count with $distinct: the grouping keeps only the columns it projects`);
|
|
536
|
+
}
|
|
478
537
|
if (q.$distinct && sortOnly.length) {
|
|
479
538
|
throw new TypeError(`cannot $sort by relation '${sortOnly[0]}' with $distinct unless '${sortOnly[0]}' is populated: the grouping keeps only the columns it projects`);
|
|
480
539
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Document, MongoClient } from 'mongodb';
|
|
2
2
|
import { AbstractQuerier } from '../querier/index.js';
|
|
3
|
-
import type { EntityData, ExtraOptions, IdValue, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
|
|
3
|
+
import type { EntityData, ExtraOptions, IdValue, PrimaryKey, Query, QueryAggMap, QueryAggregate, QueryAggregateResult, QueryConflictPaths, QueryFilter, QueryGroupMap, QueryOptions, QuerySearch, TransactionOptions, Type, UpdatePayload } from '../type/index.js';
|
|
4
4
|
import type { MongoDialect } from './mongoDialect.js';
|
|
5
5
|
export declare class MongodbQuerier extends AbstractQuerier {
|
|
6
6
|
readonly dialect: MongoDialect;
|
|
@@ -22,7 +22,18 @@ export declare class MongodbQuerier extends AbstractQuerier {
|
|
|
22
22
|
*/
|
|
23
23
|
private buildVectorPipeline;
|
|
24
24
|
protected internalAggregate<E extends Document, G extends QueryGroupMap<E>, A extends QueryAggMap<E>>(entity: Type<E>, q: QueryAggregate<E, G, A>, opts?: QueryOptions): Promise<QueryAggregateResult<E, G, A>[]>;
|
|
25
|
-
|
|
25
|
+
/**
|
|
26
|
+
* A `$required` relation drops parents that have no match, so the total has to be taken after the
|
|
27
|
+
* `$unwind` that drops them - which only the read pipeline builds. Every other query counts through
|
|
28
|
+
* {@link internalCount}, which needs no pipeline of its own.
|
|
29
|
+
*/
|
|
30
|
+
protected internalFindManyAndCount<E extends Document>(entity: Type<E>, q: Query<E>, opts?: QueryOptions): Promise<[E[], number]>;
|
|
31
|
+
protected internalCount<E extends Document>(entity: Type<E>, qm?: QueryFilter<E>, opts?: QueryOptions): Promise<number>;
|
|
32
|
+
/**
|
|
33
|
+
* The collection's metadata count, which the driver exposes as its own call and which takes no
|
|
34
|
+
* filter - the reason {@link UniversalQuerier.estimatedCount} takes none either.
|
|
35
|
+
*/
|
|
36
|
+
estimatedCount<E extends Document>(entity: Type<E>): Promise<number>;
|
|
26
37
|
/**
|
|
27
38
|
* The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
|
|
28
39
|
* Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
|
|
@@ -1,6 +1,15 @@
|
|
|
1
|
+
import { hasRequiredJoin } from '../dialect/queryJoins.js';
|
|
1
2
|
import { getMeta } from '../entity/index.js';
|
|
2
3
|
import { AbstractQuerier, enrichError } from '../querier/index.js';
|
|
3
|
-
import { clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
4
|
+
import { COUNT_AGG_ALIAS, clone, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, isPagedQuery, populatesRelations, throwNoPendingTransaction, throwPendingTransaction, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
5
|
+
/**
|
|
6
|
+
* `$limit: 0` asks for no rows, the way it does on every SQL dialect - but MongoDB reads `limit(0)`
|
|
7
|
+
* as *unlimited*, so a read that passed it straight to the driver came back with the whole
|
|
8
|
+
* collection. The reads answer it here instead, since no cursor can express it.
|
|
9
|
+
*/
|
|
10
|
+
function asksForNoRows(q) {
|
|
11
|
+
return q.$limit === 0;
|
|
12
|
+
}
|
|
4
13
|
export class MongodbQuerier extends AbstractQuerier {
|
|
5
14
|
dialect;
|
|
6
15
|
conn;
|
|
@@ -19,6 +28,9 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
19
28
|
}
|
|
20
29
|
async internalFindMany(entity, q, opts) {
|
|
21
30
|
this.dialect.assertNoLock(q);
|
|
31
|
+
if (asksForNoRows(q)) {
|
|
32
|
+
return [];
|
|
33
|
+
}
|
|
22
34
|
return this.timed('internalFindMany', undefined, async () => {
|
|
23
35
|
const meta = getMeta(entity);
|
|
24
36
|
const vectorSort = this.dialect.extractVectorSort(q.$sort);
|
|
@@ -51,6 +63,9 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
51
63
|
});
|
|
52
64
|
}
|
|
53
65
|
async *internalFindManyStream(entity, q, opts) {
|
|
66
|
+
if (asksForNoRows(q)) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
54
69
|
const meta = getMeta(entity);
|
|
55
70
|
const { joinableKeys, toManyKeys } = getRelationRequestSummary(meta, q.$populate);
|
|
56
71
|
if (joinableKeys.length || toManyKeys.length) {
|
|
@@ -99,6 +114,7 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
99
114
|
if (q.$skip) {
|
|
100
115
|
cursor.skip(q.$skip);
|
|
101
116
|
}
|
|
117
|
+
// Only a positive limit reaches the driver; `asksForNoRows` took the zero.
|
|
102
118
|
if (q.$limit) {
|
|
103
119
|
cursor.limit(q.$limit);
|
|
104
120
|
}
|
|
@@ -134,14 +150,32 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
134
150
|
return this.execute((session) => this.collection(entity).aggregate(pipeline, { session }).toArray());
|
|
135
151
|
});
|
|
136
152
|
}
|
|
153
|
+
/**
|
|
154
|
+
* A `$required` relation drops parents that have no match, so the total has to be taken after the
|
|
155
|
+
* `$unwind` that drops them - which only the read pipeline builds. Every other query counts through
|
|
156
|
+
* {@link internalCount}, which needs no pipeline of its own.
|
|
157
|
+
*/
|
|
158
|
+
async internalFindManyAndCount(entity, q, opts) {
|
|
159
|
+
if (!hasRequiredJoin(getMeta(entity), q)) {
|
|
160
|
+
return super.internalFindManyAndCount(entity, q, opts);
|
|
161
|
+
}
|
|
162
|
+
const { $sort: _sort, $skip: _skip, $limit: _limit, ...unpaged } = q;
|
|
163
|
+
const [founds, counted] = await Promise.all([
|
|
164
|
+
this.internalFindMany(entity, q, opts),
|
|
165
|
+
this.execute((session) => this.collection(entity)
|
|
166
|
+
.aggregate([...this.dialect.aggregationPipeline(entity, unpaged, opts), { $count: COUNT_AGG_ALIAS }], { session })
|
|
167
|
+
.toArray()),
|
|
168
|
+
]);
|
|
169
|
+
return [founds, counted[0]?.[COUNT_AGG_ALIAS] ?? 0];
|
|
170
|
+
}
|
|
137
171
|
async internalCount(entity, qm = {}, opts) {
|
|
138
172
|
return this.timed('internalCount', undefined, async () => {
|
|
139
173
|
if (this.dialect.constrainsRelations(entity, qm.$where)) {
|
|
140
174
|
const { stages, filter } = this.dialect.whereWithRelations(entity, qm.$where, opts);
|
|
141
175
|
const [counted] = await this.execute((session) => this.collection(entity)
|
|
142
|
-
.aggregate([...stages, { $match: filter }, { $count:
|
|
176
|
+
.aggregate([...stages, { $match: filter }, { $count: COUNT_AGG_ALIAS }], { session })
|
|
143
177
|
.toArray());
|
|
144
|
-
return counted?.
|
|
178
|
+
return counted?.[COUNT_AGG_ALIAS] ?? 0;
|
|
145
179
|
}
|
|
146
180
|
const filter = this.dialect.where(entity, qm.$where, opts);
|
|
147
181
|
return this.execute((session) => this.collection(entity).countDocuments(filter, {
|
|
@@ -149,6 +183,13 @@ export class MongodbQuerier extends AbstractQuerier {
|
|
|
149
183
|
}));
|
|
150
184
|
});
|
|
151
185
|
}
|
|
186
|
+
/**
|
|
187
|
+
* The collection's metadata count, which the driver exposes as its own call and which takes no
|
|
188
|
+
* filter - the reason {@link UniversalQuerier.estimatedCount} takes none either.
|
|
189
|
+
*/
|
|
190
|
+
async estimatedCount(entity) {
|
|
191
|
+
return this.timed('estimatedCount', undefined, async () => this.execute((session) => this.collection(entity).estimatedDocumentCount({ session })));
|
|
192
|
+
}
|
|
152
193
|
/**
|
|
153
194
|
* The ids matching `q`, in `q`'s own order and page, so a write can name the rows it settled on.
|
|
154
195
|
* Built from the read pipeline rather than stages assembled here: that dropped `$sort`/`$limit` on
|
|
@@ -11,4 +11,11 @@ export declare class PostgresDialect extends PgLikeSqlDialect {
|
|
|
11
11
|
readonly dialectName: SqlDialectName;
|
|
12
12
|
readonly vectorExtension: string | undefined;
|
|
13
13
|
upsert<E>(ctx: QueryContext, entity: Type<E>, conflictPaths: QueryConflictPaths<E>, payload: E | E[]): void;
|
|
14
|
+
/**
|
|
15
|
+
* `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
|
|
16
|
+
* (mid-migration, say) where the cast throws. `GREATEST` because a table nothing has analyzed yet
|
|
17
|
+
* carries `reltuples = -1`, Postgres' "no statistic" (not `NULL`, and not `0`) since PG 14 - handed
|
|
18
|
+
* back raw it would read as a negative row count.
|
|
19
|
+
*/
|
|
20
|
+
estimatedCount<E>(ctx: QueryContext, entity: Type<E>): void;
|
|
14
21
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { COUNT_ALIAS } from '../dialect/abstractSqlDialect.js';
|
|
1
2
|
import { PgLikeSqlDialect } from '../dialect/pgLikeSqlDialect.js';
|
|
3
|
+
import { getMeta } from '../entity/index.js';
|
|
2
4
|
/**
|
|
3
5
|
* PostgreSQL dialect. For node-pg use PgDialect. Neon, Bun SQL, and Cockroach use driver-specific
|
|
4
6
|
* subclasses. Shared Postgres-wire AST/quoting/JSONB/full-text-search/vector-search logic
|
|
@@ -13,4 +15,15 @@ export class PostgresDialect extends PgLikeSqlDialect {
|
|
|
13
15
|
// The xmax system column is 0 for a newly inserted row and non-zero for an updated one (MVCC).
|
|
14
16
|
super.upsert(ctx, entity, conflictPaths, payload, `, (xmax = 0) AS ${this.escapeId('_created')}`);
|
|
15
17
|
}
|
|
18
|
+
/**
|
|
19
|
+
* `to_regclass` rather than a `::regclass` cast: it answers `NULL` for a table that does not exist
|
|
20
|
+
* (mid-migration, say) where the cast throws. `GREATEST` because a table nothing has analyzed yet
|
|
21
|
+
* carries `reltuples = -1`, Postgres' "no statistic" (not `NULL`, and not `0`) since PG 14 - handed
|
|
22
|
+
* back raw it would read as a negative row count.
|
|
23
|
+
*/
|
|
24
|
+
estimatedCount(ctx, entity) {
|
|
25
|
+
ctx.append(`SELECT GREATEST(reltuples, 0)::bigint ${this.escapeId(COUNT_ALIAS, true)} FROM pg_class WHERE oid = to_regclass(`);
|
|
26
|
+
ctx.addValue(this.escapedTableName(getMeta(entity)));
|
|
27
|
+
ctx.append(')');
|
|
28
|
+
}
|
|
16
29
|
}
|