uql-orm 0.68.0 → 0.69.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/dist/browser/http/http.js +5 -8
- package/dist/browser/querier/httpQuerier.d.ts +9 -9
- package/dist/browser/uql-browser.min.js +2 -2
- package/dist/browser/uql-browser.min.js.map +9 -8
- package/dist/d1/d1Querier.d.ts +5 -5
- package/dist/d1/d1QuerierPool.d.ts +3 -3
- package/dist/dialect/abstractSqlDialect.d.ts +31 -4
- package/dist/dialect/abstractSqlDialect.js +99 -21
- package/dist/dialect/aliases.d.ts +8 -0
- package/dist/dialect/aliases.js +8 -0
- package/dist/entity/decorator/members.d.ts +29 -1
- package/dist/entity/decorator/members.js +0 -5
- package/dist/entity/metadata/definition.js +10 -3
- package/dist/http/query.d.ts +10 -2
- package/dist/http/query.js +26 -1
- package/dist/migrate/introspection/postgresIntrospector.d.ts +6 -0
- package/dist/migrate/introspection/postgresIntrospector.js +7 -1
- package/dist/mongo/mongoDialect.d.ts +29 -3
- package/dist/mongo/mongoDialect.js +111 -14
- package/dist/mongo/mongodbQuerier.d.ts +2 -2
- package/dist/mongo/mongodbQuerier.js +4 -3
- package/dist/type/dialect.d.ts +29 -2
- package/dist/type/entity.d.ts +94 -7
- package/dist/type/query.d.ts +30 -34
- package/dist/type/queryRaw.d.ts +19 -1
- package/dist/type/queryRaw.js +18 -0
- package/dist/type/queryWhere.d.ts +20 -20
- package/dist/type/universalQuerier.d.ts +10 -10
- package/dist/type/wire.d.ts +9 -0
- package/dist/util/dialect.util.js +2 -2
- package/dist/util/field.util.d.ts +15 -1
- package/dist/util/field.util.js +18 -1
- package/dist/util/object.util.d.ts +1 -4
- package/dist/util/object.util.js +0 -3
- package/dist/util/raw.d.ts +2 -2
- package/dist/util/raw.js +29 -2
- package/package.json +2 -2
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { fieldOf, getMeta, relationOf, soleIdOf } from '../entity/index.js';
|
|
2
2
|
import { COUNT_RESULT_KEY, parseQueryLock, QueryRaw, RAW_ALIAS, VECTOR_QUERY_KEYS, } from '../type/index.js';
|
|
3
3
|
import { isInlinedExpression } from '../util/field.util.js';
|
|
4
|
-
import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, raw, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
4
|
+
import { asSelectMap, assertNonNegativeInteger, escapeSqlId, fillOnFields, filterFieldKeys, getInsertFieldKeys, getKeys, getRelationRequestSummary, getSoftDeleteValue, hasKeys, idOnlyQuery, columnFamily, countedRelations, isJsonObject, isJsonUpdateOp, isOperatorMap, isOperatorKey, isVectorSearch, normalizeScalarFieldSelection, parentJoins, targetKeyColumns, parseGroupMap, parseRelationAtKey, parseRelationSize, parseSortByCount, populatesRelations, aggregateOf, raw, refs, throwUnknownAggregateColumn, withoutSoftDeleteFilter, } from '../util/index.js';
|
|
5
5
|
import { escapeAnsiSqlLiteral } from '../util/sqlLiteral.js';
|
|
6
|
-
import { COUNT_ALIAS, COUNTED_ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn } from './aliases.js';
|
|
6
|
+
import { AGGREGATE_PAGE_ALIAS, AGGREGATE_VALUE_ALIAS, COUNT_ALIAS, COUNTED_ROWS_ALIAS, JSON_ELEM_ALIAS, JSON_PULL_ALIAS, relationSortColumn, } from './aliases.js';
|
|
7
7
|
import { holdsOperator, isJsonScalar, jsonCompareMode, jsonElemExists, jsonPath, } from './jsonSql.js';
|
|
8
8
|
import { SqlQueryContext } from './queryContext.js';
|
|
9
9
|
import { NO_JOINS, resolveQueryJoins, resolveSortableJoin, } from './queryJoins.js';
|
|
@@ -795,7 +795,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
795
795
|
}
|
|
796
796
|
columns.push({
|
|
797
797
|
key: keyPath,
|
|
798
|
-
expr: this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, key, relation, { prefix }, '
|
|
798
|
+
expr: this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, key, relation, { prefix }, { op: '$count' })),
|
|
799
799
|
direction: this.resolveSortDirection(countDirection),
|
|
800
800
|
output: false,
|
|
801
801
|
});
|
|
@@ -1296,7 +1296,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1296
1296
|
}
|
|
1297
1297
|
const decoded = [];
|
|
1298
1298
|
for (const [key, field] of Object.entries(meta.fields)) {
|
|
1299
|
-
const kind = this.hydrateKind(field);
|
|
1299
|
+
const kind = this.aggregateHydrateKind(meta, field) ?? this.hydrateKind(field);
|
|
1300
1300
|
if (kind) {
|
|
1301
1301
|
decoded.push([key, kind]);
|
|
1302
1302
|
}
|
|
@@ -1304,28 +1304,41 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1304
1304
|
this.hydratable.set(entity, [meta.revision, decoded]);
|
|
1305
1305
|
return decoded;
|
|
1306
1306
|
}
|
|
1307
|
-
/**
|
|
1308
|
-
* The same for an aggregate's row, per query: a count or total is a number however the engine widened
|
|
1309
|
-
* it, and `$min`/`$max` or a grouped column decodes as its field does.
|
|
1310
|
-
*/
|
|
1307
|
+
/** The same for an aggregate's row, per query: each alias decodes by {@link aggregateKind}. */
|
|
1311
1308
|
hydratableAggregates(entity, q) {
|
|
1312
1309
|
const { fields } = getMeta(entity);
|
|
1313
1310
|
const decoded = [];
|
|
1314
1311
|
for (const entry of parseGroupMap(q.$group, q.$select)) {
|
|
1315
|
-
|
|
1316
|
-
decoded.push([entry.alias, 'number']);
|
|
1317
|
-
continue;
|
|
1318
|
-
}
|
|
1319
|
-
// `$min`/`$max` read the field they aggregate; a `$group` column is that field. Only `$count`
|
|
1320
|
-
// takes `'*'`, and it went down the numeric path above, so there is always a field to look up.
|
|
1312
|
+
// A grouped column is the field itself; an aggregate reads the one it aggregates, `'*'` for a tally.
|
|
1321
1313
|
const key = entry.kind === 'fn' ? entry.fieldRef : entry.alias;
|
|
1322
|
-
const
|
|
1314
|
+
const field = fields[key];
|
|
1315
|
+
const kind = entry.kind === 'fn' ? this.aggregateKind(entry.op, field) : this.hydrateKind(field);
|
|
1323
1316
|
if (kind) {
|
|
1324
1317
|
decoded.push([entry.alias, kind]);
|
|
1325
1318
|
}
|
|
1326
1319
|
}
|
|
1327
1320
|
return decoded;
|
|
1328
1321
|
}
|
|
1322
|
+
/**
|
|
1323
|
+
* What an aggregate's value decodes as, which the engine widens beyond the column it read: a tally
|
|
1324
|
+
* (`COUNT` to Postgres's `bigint`) and a mean (`AVG` to `numeric`) are numbers whatever they counted,
|
|
1325
|
+
* and the rest read as the column they aggregate - so a `SUM` over a wide integer stays exact rather
|
|
1326
|
+
* than rounding through a float, which is what every driver's BIGINT decoding promises.
|
|
1327
|
+
*
|
|
1328
|
+
* One rule for both readers: a relation aggregate a field declares, and an aggregate a query names.
|
|
1329
|
+
*/
|
|
1330
|
+
aggregateKind(op, field) {
|
|
1331
|
+
return op === '$count' || op === '$avg' ? 'number' : this.hydrateKind(field);
|
|
1332
|
+
}
|
|
1333
|
+
/** What a relation aggregate a field declares decodes as: {@link aggregateKind} over the target's column. */
|
|
1334
|
+
aggregateHydrateKind(meta, field) {
|
|
1335
|
+
const spec = aggregateOf(field);
|
|
1336
|
+
if (!spec) {
|
|
1337
|
+
return undefined;
|
|
1338
|
+
}
|
|
1339
|
+
const target = getMeta(relationOf(meta, spec.relation).entity());
|
|
1340
|
+
return this.aggregateKind(spec.op, spec.field ? target.fields[spec.field] : undefined);
|
|
1341
|
+
}
|
|
1329
1342
|
/** What one column decodes as, the inverse of {@link persistKind}. `BigInt` first, since it shares the numeric family. */
|
|
1330
1343
|
hydrateKind(field) {
|
|
1331
1344
|
const type = field?.type;
|
|
@@ -1519,14 +1532,18 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1519
1532
|
* invisible here just as it is to a joined `$populate`. The caller's filter bypass is deliberately
|
|
1520
1533
|
* not propagated (`withDeleted()` does not reach into relations), matching `selectRelationJoins`.
|
|
1521
1534
|
*/
|
|
1522
|
-
appendRelationSubquery(ctx, meta, relKey, rel, opts,
|
|
1535
|
+
appendRelationSubquery(ctx, meta, relKey, rel, opts, read) {
|
|
1523
1536
|
const relatedEntity = rel.entity();
|
|
1524
1537
|
const relatedMeta = getMeta(relatedEntity);
|
|
1525
1538
|
const parent = opts.prefix ?? this.resolveTableAlias(meta);
|
|
1526
1539
|
// Resolved before any SQL is emitted: it also decides whether the junction form reaches the target.
|
|
1527
|
-
const targetWhere = this.scopedWhere(relatedMeta,
|
|
1528
|
-
ctx.append(`(SELECT ${projection} FROM `);
|
|
1540
|
+
const targetWhere = this.scopedWhere(relatedMeta, read.query?.$where ?? {});
|
|
1529
1541
|
if (rel.through) {
|
|
1542
|
+
// The rows here are the junction's own, so a column of the far side is read as a page instead.
|
|
1543
|
+
if (read.field) {
|
|
1544
|
+
throw new TypeError(`cannot read ${read.op}('${read.field}') over the many-to-many '${relKey}' without a page: its rows are the junction's, so name a '$sort' and a '$limit' to read the target's own`);
|
|
1545
|
+
}
|
|
1546
|
+
ctx.append(`(SELECT ${read.op === 'exists' ? '1' : 'COUNT(*)'} FROM `);
|
|
1530
1547
|
const junction = this.junctionRows(ctx, meta, rel, rel.through(), parent);
|
|
1531
1548
|
ctx.append(junction.from);
|
|
1532
1549
|
if (hasKeys(targetWhere)) {
|
|
@@ -1539,13 +1556,74 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1539
1556
|
}
|
|
1540
1557
|
}
|
|
1541
1558
|
else {
|
|
1559
|
+
// The alias is claimed before the SELECT is written, since an aggregate names a column of it.
|
|
1542
1560
|
const related = this.tableRef(relatedMeta, ctx.claimAlias(relKey, parent));
|
|
1561
|
+
ctx.append(`(SELECT ${this.aggregateProjection(read, related.alias, relatedMeta)} FROM `);
|
|
1543
1562
|
ctx.append(related.ref);
|
|
1544
1563
|
ctx.append(` WHERE ${this.correlation(meta, rel, parent, related.alias, relatedMeta)}`);
|
|
1545
1564
|
this.renderWhere(ctx, relatedEntity, targetWhere, { prefix: related.alias, clause: 'AND' });
|
|
1546
1565
|
}
|
|
1547
1566
|
ctx.append(')');
|
|
1548
1567
|
}
|
|
1568
|
+
/**
|
|
1569
|
+
* What a relation subquery selects: the literals a relation operator reads, or an aggregate over one
|
|
1570
|
+
* of the target's columns. `count` and `sum` answer `0` on a parent with no rows, which is what makes
|
|
1571
|
+
* them the two a trigger could keep; the rest answer `NULL`, and the field's type says so.
|
|
1572
|
+
*/
|
|
1573
|
+
aggregateProjection(projection, alias, relatedMeta) {
|
|
1574
|
+
if (projection.op === 'exists') {
|
|
1575
|
+
return '1';
|
|
1576
|
+
}
|
|
1577
|
+
return this.aggregateCall(projection.op, projection.field ? this.escapedColumn(alias, relatedMeta, projection.field) : '');
|
|
1578
|
+
}
|
|
1579
|
+
/**
|
|
1580
|
+
* A relation aggregate as the correlated subquery a `computed` field reads, the same one `$count`
|
|
1581
|
+
* emits: `(user) => user.resources.count()` renders here, wherever the field is named.
|
|
1582
|
+
*/
|
|
1583
|
+
appendRelationAggregate(ctx, entity, aggregate, prefix) {
|
|
1584
|
+
const meta = getMeta(entity);
|
|
1585
|
+
const rel = relationOf(meta, aggregate.relation);
|
|
1586
|
+
const parent = prefix || this.resolveTableAlias(meta);
|
|
1587
|
+
if (!aggregate.query?.$limit && aggregate.query?.$skip === undefined) {
|
|
1588
|
+
this.appendRelationSubquery(ctx, meta, aggregate.relation, rel, { prefix: parent }, aggregate);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
// Capped: the rows it reads are a page of the relation, so they are read first - ordered, since an
|
|
1592
|
+
// order is what picks them - and the aggregate runs over that page.
|
|
1593
|
+
const pageAlias = this.escapeId(AGGREGATE_PAGE_ALIAS);
|
|
1594
|
+
const value = this.escapeId(AGGREGATE_VALUE_ALIAS);
|
|
1595
|
+
ctx.append(`(SELECT ${this.aggregateCall(aggregate.op, `${pageAlias}.${value}`)} FROM (`);
|
|
1596
|
+
this.appendRelationPage(ctx, meta, rel, aggregate, parent);
|
|
1597
|
+
ctx.append(`) ${pageAlias})`);
|
|
1598
|
+
}
|
|
1599
|
+
/**
|
|
1600
|
+
* The page a capped aggregate reads: an ordinary read of the related entity under its own alias,
|
|
1601
|
+
* narrowed to the parent's rows, carrying out the one column the aggregate runs over. Its `$sort`,
|
|
1602
|
+
* `$limit` and `$skip` are the relation's own, and its filters apply as they do to any read.
|
|
1603
|
+
*/
|
|
1604
|
+
appendRelationPage(ctx, meta, rel, aggregate, parent) {
|
|
1605
|
+
const entity = rel.entity();
|
|
1606
|
+
const alias = ctx.claimAlias(aggregate.relation, parent);
|
|
1607
|
+
const correlation = raw(({ ctx: pageCtx }) => this.appendCorrelation(pageCtx, meta, rel, parent, alias));
|
|
1608
|
+
const { $where, ...page } = aggregate.query ?? {};
|
|
1609
|
+
// `1` where nothing is aggregated: a tally counts the rows the page holds, whatever they carry.
|
|
1610
|
+
const read = aggregate.field ? refs(entity)[aggregate.field] : raw `1`;
|
|
1611
|
+
const query = {
|
|
1612
|
+
...page,
|
|
1613
|
+
$select: [read.as(AGGREGATE_VALUE_ALIAS)],
|
|
1614
|
+
$where: { ...$where, $and: [...($where?.$and ?? []), correlation] },
|
|
1615
|
+
};
|
|
1616
|
+
const joins = resolveQueryJoins(getMeta(entity), query, (path) => ctx.claimAlias(path));
|
|
1617
|
+
this.read(ctx, entity, query, { alias }, joins);
|
|
1618
|
+
}
|
|
1619
|
+
/** One aggregate over an operand, `COALESCE`d where the aggregate answers `0` on no rows rather than null. */
|
|
1620
|
+
aggregateCall(op, operand) {
|
|
1621
|
+
if (op === '$count') {
|
|
1622
|
+
return 'COUNT(*)';
|
|
1623
|
+
}
|
|
1624
|
+
const call = `${AbstractSqlDialect.AGGREGATE_FN[op]}(${operand})`;
|
|
1625
|
+
return op === '$sum' ? `COALESCE(${call}, 0)` : call;
|
|
1626
|
+
}
|
|
1549
1627
|
/**
|
|
1550
1628
|
* One equality per key of the parent, anded: a composite correlates on every column, and matching on
|
|
1551
1629
|
* part of one would find the rows of a different parent. `parentJoins` keeps the two ends the right
|
|
@@ -1571,7 +1649,7 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1571
1649
|
/** Each relation a read's `$count` tallies, as a correlated count of its select list. */
|
|
1572
1650
|
selectRelationCounts(ctx, meta, count, parent) {
|
|
1573
1651
|
return countedRelations(meta, count).map(({ relKey, relation, where }) => {
|
|
1574
|
-
const sql = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relKey, relation, { prefix: parent }, '
|
|
1652
|
+
const sql = this.buildFragment(ctx, (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, meta, relKey, relation, { prefix: parent }, { op: '$count', query: { $where: where } }));
|
|
1575
1653
|
return { sql, key: `${COUNT_RESULT_KEY}.${relKey}` };
|
|
1576
1654
|
});
|
|
1577
1655
|
}
|
|
@@ -1670,11 +1748,11 @@ export class AbstractSqlDialect extends VectorSqlDialect {
|
|
|
1670
1748
|
/** Filter by relation: a parent matches when {@link appendRelationSubquery} finds one target row. */
|
|
1671
1749
|
compareRelation(ctx, entity, relKey, val, rel, opts) {
|
|
1672
1750
|
ctx.append('EXISTS ');
|
|
1673
|
-
this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, '
|
|
1751
|
+
this.appendRelationSubquery(ctx, getMeta(entity), relKey, rel, opts, { op: 'exists', query: { $where: val } });
|
|
1674
1752
|
}
|
|
1675
1753
|
/** Filter by relation size: the same subquery, counting instead of testing for existence. */
|
|
1676
1754
|
compareRelationSize(ctx, entity, relKey, sizeVal, rel, opts) {
|
|
1677
|
-
const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, '
|
|
1755
|
+
const count = (fragmentCtx) => this.appendRelationSubquery(fragmentCtx, getMeta(entity), relKey, rel, opts, { op: '$count' });
|
|
1678
1756
|
ctx.append(this.sizeCondition(ctx, count, sizeVal));
|
|
1679
1757
|
}
|
|
1680
1758
|
/**
|
|
@@ -4,6 +4,14 @@ export declare const COUNT_ALIAS = "_uql_count";
|
|
|
4
4
|
export declare const TOTAL_ALIAS = "_uql_total";
|
|
5
5
|
/** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
|
|
6
6
|
export declare const COUNTED_ROWS_ALIAS = "_uql_rows";
|
|
7
|
+
/** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
|
|
8
|
+
export declare const AGGREGATE_PAGE_ALIAS = "_uql_page";
|
|
9
|
+
/**
|
|
10
|
+
* What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
|
|
11
|
+
* and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
|
|
12
|
+
* are written and read here.
|
|
13
|
+
*/
|
|
14
|
+
export declare const AGGREGATE_VALUE_ALIAS = "_uql_value";
|
|
7
15
|
/** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
|
|
8
16
|
export declare const RELATION_ROW_ALIAS = "_uql_row";
|
|
9
17
|
/** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
|
package/dist/dialect/aliases.js
CHANGED
|
@@ -6,6 +6,14 @@ export const COUNT_ALIAS = '_uql_count';
|
|
|
6
6
|
export const TOTAL_ALIAS = '_uql_total';
|
|
7
7
|
/** The derived table a count wraps the rows it counts in: a page, or a `$distinct` set. MySQL requires the alias. */
|
|
8
8
|
export const COUNTED_ROWS_ALIAS = '_uql_rows';
|
|
9
|
+
/** The derived table a capped relation aggregate reads: the page is taken first, then aggregated over. */
|
|
10
|
+
export const AGGREGATE_PAGE_ALIAS = '_uql_page';
|
|
11
|
+
/**
|
|
12
|
+
* What an aggregate answers under: the column a capped page carries out for the aggregate wrapping it,
|
|
13
|
+
* and the field a MongoDB `$group` or `$count` leaves its value in. One name, since both ends of each
|
|
14
|
+
* are written and read here.
|
|
15
|
+
*/
|
|
16
|
+
export const AGGREGATE_VALUE_ALIAS = '_uql_value';
|
|
9
17
|
/** The row a Postgres relation aggregates whole: a LATERAL projection of the columns it answers under. */
|
|
10
18
|
export const RELATION_ROW_ALIAS = '_uql_row';
|
|
11
19
|
/** The alias an exploded JSON array element is read through, `_uql_elem_2` and on where one nests in another. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { EntityGetter, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, TsTypeOf } from '../../type/index.js';
|
|
1
|
+
import type { AggregateValue, ComputedRefs, EntityAggregate, EntityGetter, Except, FieldOptions, FieldType, HasCompositeKey, IdValue, NamedIdKey, RejectKeys, RelationManyToManyOptions, RelationManyToOneOptions, RelationOneToManyOptions, RelationOneToOneOptions, RelationAggregate, TsTypeOf } from '../../type/index.js';
|
|
2
2
|
import type { RejectIncompatible } from '../../util/index.js';
|
|
3
3
|
/** A member decorator that also constrains the property it may be applied to, on a class `O`. */
|
|
4
4
|
type MemberDecorator<V, O = unknown> = (value: undefined, context: ClassFieldDecoratorContext<O, V>) => void;
|
|
@@ -30,6 +30,34 @@ export declare function Field<This, O extends FieldOptions<DeclaredValue<O>, Thi
|
|
|
30
30
|
} | {
|
|
31
31
|
references: EntityGetter;
|
|
32
32
|
}) & RejectKeys<Exclude<keyof O, keyof FieldOptions>> & RejectIncompatible<O>>(opts: O): MemberDecorator<DeclaredValue<O> | undefined, This>;
|
|
33
|
+
/**
|
|
34
|
+
* Declares a field a relation aggregate computes, `@Field({ computed: (user) => user.resources.count() })`.
|
|
35
|
+
* The aggregate says what the field holds, so it takes no `type`, and only `count` and `sum` - the two a
|
|
36
|
+
* row change turns into a delta - may be `stored`.
|
|
37
|
+
* @example `@Field({ computed: (user) => user.resources.count() }) readonly resourceCount?: number;`
|
|
38
|
+
*/
|
|
39
|
+
export declare function Field<This, O extends AggregateOptions<This> & RejectKeys<Exclude<keyof O, keyof FieldOptions>>>(opts: O): AggregateDecorator<AggregateValue<O>, This>;
|
|
40
|
+
/**
|
|
41
|
+
* A field the aggregate itself types: `stored: true` only where a trigger could keep it, and every other
|
|
42
|
+
* option as a column takes it.
|
|
43
|
+
*/
|
|
44
|
+
type AggregateOptions<E> = (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
|
|
45
|
+
readonly computed: EntityAggregate<E>;
|
|
46
|
+
readonly stored?: false;
|
|
47
|
+
}) | (Except<FieldOptions<never, E>, 'computed' | 'stored' | 'type'> & {
|
|
48
|
+
readonly computed: {
|
|
49
|
+
agg(refs: ComputedRefs<E>): RelationAggregate<unknown, true>;
|
|
50
|
+
}['agg'];
|
|
51
|
+
readonly stored: true;
|
|
52
|
+
});
|
|
53
|
+
/**
|
|
54
|
+
* {@link MemberDecorator} for a field an aggregate types, which the property has to hold *exactly*: a
|
|
55
|
+
* decorator context takes a property narrower than its value, so nothing else would stop a `number`
|
|
56
|
+
* from holding what `max()` reads, which is `number | null` on a parent with no rows.
|
|
57
|
+
*/
|
|
58
|
+
type AggregateDecorator<V, O> = <P extends V | undefined>(value: undefined, context: ClassFieldDecoratorContext<O, P> & ([V] extends [P] ? unknown : {
|
|
59
|
+
readonly __propertyMustAdmit: V;
|
|
60
|
+
})) => void;
|
|
33
61
|
/**
|
|
34
62
|
* A key the type level cannot name, reported on each `@Id` that leaves it unnamed. Where no `idKey`
|
|
35
63
|
* brand and no conventional name applies, `IdKey` falls back to every field, and `IdValue`,
|
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
import { relationRegistration } from '../metadata/definition.js';
|
|
2
2
|
import { memberRegistrations } from './bag.js';
|
|
3
|
-
/**
|
|
4
|
-
* Declares a persisted field, its `type` checked against the property's.
|
|
5
|
-
* @example `@Field({ type: String }) name?: string;`
|
|
6
|
-
* @example `@Field({ references: () => User }) userId?: string;`
|
|
7
|
-
*/
|
|
8
3
|
export function Field(opts) {
|
|
9
4
|
return (_value, context) => {
|
|
10
5
|
memberRegistrations(context.metadata).fields[String(context.name)] = opts;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { SOFT_DELETE_FILTER } from '../../type/index.js';
|
|
1
|
+
import { RelationAggregate, SOFT_DELETE_FILTER } from '../../type/index.js';
|
|
2
2
|
import { isInlinedExpression } from '../../util/field.util.js';
|
|
3
3
|
import { entitySql, entityWhere, fieldOptionConflict, getKeys, hasKeys, isToManyRelation, memberRefs, normalizeIndexColumn, definedEntries, } from '../../util/index.js';
|
|
4
4
|
import { ownRegistrations } from '../decorator/bag.js';
|
|
@@ -17,6 +17,14 @@ function globalMap(key) {
|
|
|
17
17
|
const metas = globalMap('uql-orm/entity/metadata/v1');
|
|
18
18
|
export function defineField(entity, key, opts = {}) {
|
|
19
19
|
const meta = ensureWritableMeta(entity);
|
|
20
|
+
const { computed, ...rest } = opts;
|
|
21
|
+
const sql = computed === undefined ? undefined : entitySql(computed);
|
|
22
|
+
// A relation aggregate reads as a correlated subquery, which no engine accepts in a generated column:
|
|
23
|
+
// keeping one on the row takes the triggers a write fires, which are not built yet.
|
|
24
|
+
if (opts.stored && sql instanceof RelationAggregate) {
|
|
25
|
+
throw new TypeError(`'${entity.name}.${key}' cannot be 'stored': a relation aggregate reads as a subquery, which no ` +
|
|
26
|
+
"engine keeps in a generated column. Drop 'stored' to have it read on each query.");
|
|
27
|
+
}
|
|
20
28
|
// A stored computed column is a real column and still needs a type; only an inlined one is exempt,
|
|
21
29
|
// its expression being spliced in rather than declared.
|
|
22
30
|
if (!opts.type && !opts.references && !isInlinedExpression(opts)) {
|
|
@@ -31,13 +39,12 @@ export function defineField(entity, key, opts = {}) {
|
|
|
31
39
|
// Flagged when the author gave `references` but no `type`, so schema generation knows to resolve the
|
|
32
40
|
// column from the referenced primary key (picking up its `columnType`, length and chained keys)
|
|
33
41
|
// instead of treating whatever ends up in `type` as deliberate.
|
|
34
|
-
const { computed, ...rest } = opts;
|
|
35
42
|
const resolved = rest.type ? rest : { ...rest, typeFromReference: true };
|
|
36
43
|
meta.fields[fieldKey] = {
|
|
37
44
|
...meta.fields[fieldKey],
|
|
38
45
|
name: key,
|
|
39
46
|
...resolved,
|
|
40
|
-
...(
|
|
47
|
+
...(sql && { computed: sql }),
|
|
41
48
|
};
|
|
42
49
|
return meta;
|
|
43
50
|
}
|
package/dist/http/query.d.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { WireQuery } from '../type/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* Parse raw query-string entries (with JSON-stringified values) into a UQL query object.
|
|
4
4
|
* Symmetric counterpart of {@link stringifyQuery}. Only {@link ALLOWED_QUERY_KEYS} are honored.
|
|
5
5
|
*/
|
|
6
|
-
export declare function parseQueryParams(params?: Record<string, unknown>):
|
|
6
|
+
export declare function parseQueryParams(params?: Record<string, unknown>): WireQuery<unknown>;
|
|
7
7
|
/**
|
|
8
8
|
* Serialize a UQL query object into a percent-encoded query string where object values
|
|
9
9
|
* are JSON-stringified. Symmetric counterpart of {@link parseQueryParams}.
|
|
10
10
|
*/
|
|
11
11
|
export declare function stringifyQuery(query?: Record<string, unknown>): string;
|
|
12
|
+
/**
|
|
13
|
+
* What leaves the browser, as JSON, refusing what JSON keeps nothing of rather than letting the server
|
|
14
|
+
* build a statement around the remains. A `raw` fragment renders SQL against a dialect the client does not
|
|
15
|
+
* have and arrives as `{}`; binary arrives as an object keyed by index. A `Date` is not among them - it
|
|
16
|
+
* serializes to ISO 8601, which is what a date column reads. This is what a cast, or a JavaScript caller,
|
|
17
|
+
* hits where the client's types already refuse a fragment.
|
|
18
|
+
*/
|
|
19
|
+
export declare function wireJson(value: unknown): string;
|
package/dist/http/query.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// the clause lists themselves, not the barrel: this module is in the browser bundle's graph
|
|
2
2
|
import { QUERY_BOOLEAN_CLAUSES, QUERY_NUMBER_CLAUSES, QUERY_OBJECT_CLAUSES, QUERY_ROOT_NUMBER_CLAUSES, QUERY_ROOT_OBJECT_CLAUSES, } from '../type/query.js';
|
|
3
|
+
// the brand alone, not the class: importing `QueryRaw` for an `instanceof` kept it, and `ColumnRef`
|
|
4
|
+
// with it, in the browser bundle, which is on a size budget
|
|
5
|
+
import { RAW_VALUE } from '../type/queryRaw.js';
|
|
3
6
|
// the specific util module, not the barrel, so the browser bundle does not pull in entity metadata
|
|
4
7
|
import { getKeys, isWhereMap } from '../util/object.util.js';
|
|
5
8
|
/**
|
|
@@ -82,8 +85,30 @@ export function stringifyQuery(query) {
|
|
|
82
85
|
if (value === undefined) {
|
|
83
86
|
continue;
|
|
84
87
|
}
|
|
85
|
-
params.append(key, typeof value === 'object' && value !== null ?
|
|
88
|
+
params.append(key, typeof value === 'object' && value !== null ? wireJson(value) : String(value));
|
|
86
89
|
}
|
|
87
90
|
const qs = params.toString();
|
|
88
91
|
return qs ? `?${qs}` : '';
|
|
89
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* What leaves the browser, as JSON, refusing what JSON keeps nothing of rather than letting the server
|
|
95
|
+
* build a statement around the remains. A `raw` fragment renders SQL against a dialect the client does not
|
|
96
|
+
* have and arrives as `{}`; binary arrives as an object keyed by index. A `Date` is not among them - it
|
|
97
|
+
* serializes to ISO 8601, which is what a date column reads. This is what a cast, or a JavaScript caller,
|
|
98
|
+
* hits where the client's types already refuse a fragment.
|
|
99
|
+
*/
|
|
100
|
+
export function wireJson(value) {
|
|
101
|
+
return JSON.stringify(value, (_key, held) => {
|
|
102
|
+
if (typeof held !== 'object' || held === null) {
|
|
103
|
+
return held;
|
|
104
|
+
}
|
|
105
|
+
if (RAW_VALUE in held) {
|
|
106
|
+
throw new TypeError('raw SQL cannot travel over HTTP: what leaves the browser is JSON');
|
|
107
|
+
}
|
|
108
|
+
// A blob is a field value, so no type parameter reaches it: this is the only place it is caught.
|
|
109
|
+
if (held instanceof ArrayBuffer || ArrayBuffer.isView(held)) {
|
|
110
|
+
throw new TypeError('binary cannot travel over HTTP: what leaves the browser is JSON');
|
|
111
|
+
}
|
|
112
|
+
return held;
|
|
113
|
+
});
|
|
114
|
+
}
|
|
@@ -14,6 +14,12 @@ export declare class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrosp
|
|
|
14
14
|
protected getTableNamesQuery(): string;
|
|
15
15
|
protected tableExistsQuery(): string;
|
|
16
16
|
protected parseTableExistsResult([row]: RawRow[]): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* The comment reads through `to_regclass` rather than a `::regclass` cast: a name resolves against
|
|
19
|
+
* the live catalogue while `information_schema` answers from this statement's snapshot, so a table
|
|
20
|
+
* another connection has just dropped is still listed here and the cast would raise on it. Whole
|
|
21
|
+
* database scans meet that table every time something else is migrating.
|
|
22
|
+
*/
|
|
17
23
|
protected getColumnsQuery(_tableName: string): string;
|
|
18
24
|
/**
|
|
19
25
|
* `attname` where the entry is a column, `pg_get_indexdef` for that one position where it is an
|
|
@@ -37,6 +37,12 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
|
|
|
37
37
|
parseTableExistsResult([row]) {
|
|
38
38
|
return row['exists'] === true;
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* The comment reads through `to_regclass` rather than a `::regclass` cast: a name resolves against
|
|
42
|
+
* the live catalogue while `information_schema` answers from this statement's snapshot, so a table
|
|
43
|
+
* another connection has just dropped is still listed here and the cast would raise on it. Whole
|
|
44
|
+
* database scans meet that table every time something else is migrating.
|
|
45
|
+
*/
|
|
40
46
|
getColumnsQuery(_tableName) {
|
|
41
47
|
return /*sql*/ `
|
|
42
48
|
SELECT
|
|
@@ -68,7 +74,7 @@ export class PostgresSchemaIntrospector extends AbstractSqlSchemaIntrospector {
|
|
|
68
74
|
HAVING COUNT(*) = 1 AND MIN(kcu.column_name) = c.column_name
|
|
69
75
|
) AS is_unique,
|
|
70
76
|
pg_catalog.col_description(
|
|
71
|
-
(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name))
|
|
77
|
+
to_regclass(quote_ident(c.table_schema) || '.' || quote_ident(c.table_name)),
|
|
72
78
|
c.ordinal_position
|
|
73
79
|
) AS column_comment
|
|
74
80
|
FROM information_schema.columns c
|
|
@@ -139,9 +139,35 @@ export declare class MongoDialect extends AbstractDialect {
|
|
|
139
139
|
readonly stages: MongoAggregationPipelineEntry<Document>[];
|
|
140
140
|
readonly fields: string[];
|
|
141
141
|
};
|
|
142
|
-
/**
|
|
143
|
-
|
|
144
|
-
/**
|
|
142
|
+
/** Whether a read answers with a relation aggregate, which only the pipeline can build. */
|
|
143
|
+
readsAggregates<E extends Document>(entity: Type<E>, q: Query<E>): boolean;
|
|
144
|
+
/**
|
|
145
|
+
* The relation aggregates one query reads: the ones its projection carries, plus any its `$where` or
|
|
146
|
+
* `$sort` names, which a read materializes whether or not it answers with them.
|
|
147
|
+
*/
|
|
148
|
+
private aggregateKeys;
|
|
149
|
+
/**
|
|
150
|
+
* The stages a relation aggregate a query names needs: the correlated lookup that reads the related
|
|
151
|
+
* rows - narrowed, ordered and capped as the field declared - ending in the tally or total it wants,
|
|
152
|
+
* and the `$addFields` that puts the value on the document under the field's own name.
|
|
153
|
+
*
|
|
154
|
+
* The same spec the SQL dialects render as a correlated subquery: a relation aggregate is data, so a
|
|
155
|
+
* document engine builds it out of stages rather than being refused a language it does not speak.
|
|
156
|
+
*/
|
|
157
|
+
private aggregateFieldStages;
|
|
158
|
+
/**
|
|
159
|
+
* One relation aggregate on the document under `field`: the correlated lookup that reads the related
|
|
160
|
+
* rows - narrowed, ordered and capped as the spec says - ending in the tally or total it wants, and
|
|
161
|
+
* the `$addFields` reading that back, `0` or `null` where the lookup matched nothing.
|
|
162
|
+
*
|
|
163
|
+
* Every aggregate MongoDB answers is built here: a `$count` a query asks for, an ordering by one, and
|
|
164
|
+
* a field a `computed` declares, which is the same spec the SQL dialects render as one subquery.
|
|
165
|
+
*/
|
|
166
|
+
private aggregateStages;
|
|
167
|
+
/**
|
|
168
|
+
* The value a lookup left in `temp`, which holds no row at all where nothing matched: `0` for the
|
|
169
|
+
* aggregates that count something, and `null` for the ones with no value to report.
|
|
170
|
+
*/
|
|
145
171
|
private tally;
|
|
146
172
|
/**
|
|
147
173
|
* The lookups reading each to-many a query populates, and the tally of each `$count`, onto the fields
|