@atscript/db 0.1.130 → 0.1.132
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/agg.cjs +19 -5
- package/dist/agg.d.cts +3 -4
- package/dist/agg.d.mts +3 -4
- package/dist/agg.mjs +2 -5
- package/dist/{db-readable-B7eYWS5q.d.cts → buckets-BvoYYQ2Q.d.cts} +1348 -1142
- package/dist/{db-readable-Bn1bV_eC.d.mts → buckets-D2XPrwSC.d.mts} +1348 -1142
- package/dist/{db-error-COrO58t5.mjs → db-error-D5uilS_A.mjs} +13 -1
- package/dist/{db-error-C4JuLcvb.cjs → db-error-DTkkeu5b.cjs} +18 -0
- package/dist/{db-space-C2UCnGHd.d.cts → db-space-BQQEgB3d.d.mts} +2 -2
- package/dist/{db-space-DdIPYD0Q.d.mts → db-space-Cslq5Dux.d.cts} +2 -2
- package/dist/{db-view-hkwQ-fHJ.cjs → db-view-CuFAhzwF.cjs} +487 -123
- package/dist/{db-view-BkF2xEGd.mjs → db-view-DVGWWhA3.mjs} +439 -123
- package/dist/index.cjs +13 -4
- package/dist/index.d.cts +105 -38
- package/dist/index.d.mts +105 -38
- package/dist/index.mjs +5 -5
- package/dist/{nested-writer-CkDo-ZfH.mjs → nested-writer-CqL24ojl.mjs} +1 -1
- package/dist/{nested-writer-DxPhmWFz.cjs → nested-writer-wk1EFUNY.cjs} +1 -1
- package/dist/ops.cjs +1 -1
- package/dist/ops.mjs +1 -1
- package/dist/rel.cjs +2 -2
- package/dist/rel.d.cts +1 -1
- package/dist/rel.d.mts +1 -1
- package/dist/rel.mjs +2 -2
- package/dist/{relation-loader-CUGcxJ18.mjs → relation-loader-B68R1LET.mjs} +1 -1
- package/dist/{relation-loader-C8GOpNYJ.cjs → relation-loader-BD4xANQJ.cjs} +1 -1
- package/dist/sync.cjs +1 -1
- package/dist/sync.d.cts +2 -2
- package/dist/sync.d.mts +2 -2
- package/dist/sync.mjs +1 -1
- package/dist/{validator-lkCJKuoo.cjs → validator-BtZbcLN2.cjs} +1 -1
- package/dist/{validator-wBARmD68.d.cts → validator-CewfnGZj.d.cts} +9 -2
- package/dist/{validator-wBARmD68.d.mts → validator-CewfnGZj.d.mts} +9 -2
- package/dist/{validator-CeD_fqyW.mjs → validator-Ch7UIQl9.mjs} +1 -1
- package/dist/validator.cjs +2 -2
- package/dist/validator.d.cts +1 -1
- package/dist/validator.d.mts +1 -1
- package/dist/validator.mjs +2 -2
- package/package.json +3 -3
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { n as CasMismatchError, r as DbError } from "./db-error-
|
|
2
|
-
import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, l as isEmptyObject, n as buildPatchPartial, t as buildDbValidator, u as isPlainObject } from "./validator-CeD_fqyW.mjs";
|
|
1
|
+
import { n as CasMismatchError, r as DbError } from "./db-error-D5uilS_A.mjs";
|
|
3
2
|
import { resolveAlias } from "./agg.mjs";
|
|
4
|
-
import { a as
|
|
3
|
+
import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, l as isEmptyObject, n as buildPatchPartial, t as buildDbValidator, u as isPlainObject } from "./validator-Ch7UIQl9.mjs";
|
|
4
|
+
import { a as batchPatchNestedTo, c as batchReplaceNestedTo, d as preValidateNestedFrom, f as validateBatch, g as findRemoteFK, h as findFKForRelation, i as batchPatchNestedFrom, l as batchReplaceNestedVia, m as remapDeleteFkViolation, n as batchInsertNestedTo, o as batchPatchNestedVia, p as enrichFkViolation, r as batchInsertNestedVia, s as batchReplaceNestedFrom, t as batchInsertNestedFrom, u as checkDepthOverflow } from "./nested-writer-CqL24ojl.mjs";
|
|
5
5
|
import { separateCas, separateFieldOps } from "./ops.mjs";
|
|
6
6
|
import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
|
|
7
|
+
import { BUCKET_UNITS, isAggregateExpr, isBucketExpr, isPrimitive, resolveBuckets } from "@uniqu/core";
|
|
7
8
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
8
9
|
//#region src/logger.ts
|
|
9
10
|
const NoopLogger = {
|
|
@@ -14,6 +15,78 @@ const NoopLogger = {
|
|
|
14
15
|
debug: () => {}
|
|
15
16
|
};
|
|
16
17
|
//#endregion
|
|
18
|
+
//#region src/query/buckets.ts
|
|
19
|
+
/**
|
|
20
|
+
* The one normalizer of `$select` computed entries (since 0.1.132) — uniqu's
|
|
21
|
+
* `resolveBuckets` (entry shapes, unit, time zone canonicalization, week
|
|
22
|
+
* start, alias syntax and uniqueness, "grouped queries only", "must also
|
|
23
|
+
* appear in $groupBy", string `$groupBy` entries) with the table's names as
|
|
24
|
+
* the collision set: a bucket alias may not equal a logical path, a physical
|
|
25
|
+
* column or a navigation field, so a label is never reverse-mapped as a
|
|
26
|
+
* column.
|
|
27
|
+
*
|
|
28
|
+
* Which layer validates what:
|
|
29
|
+
* - **Shapes** (this normalizer) run FIRST at every entry point — the core's
|
|
30
|
+
* read path (`guardQuery`), its aggregate path (`AtscriptDbReadable.aggregate`,
|
|
31
|
+
* which hands the result on to `guardAggregate` and the field mapper), and
|
|
32
|
+
* moost-db's HTTP gate — so both layers answer with the same wording.
|
|
33
|
+
* Everything downstream (`collectQueryPaths`, the field mappers,
|
|
34
|
+
* `UniquSelect`, adapters) assumes normalized input and does not re-check.
|
|
35
|
+
* - **Schema** (timestamp-typed source, JSON ancestor, encryption, physical
|
|
36
|
+
* filterability) is the path guard's (`guardPath` op `bucket`), mirrored by
|
|
37
|
+
* moost-db's capability index; dimensions are the aggregate rules'.
|
|
38
|
+
* - **Adapter capability** (`calendarBucketUnits()`) is `guardAggregate`'s
|
|
39
|
+
* (`BUCKET_NOT_SUPPORTED`); SQL builders only re-assert the inlined
|
|
40
|
+
* literals (defense in depth).
|
|
41
|
+
*
|
|
42
|
+
* `aggregate` defaults to "`$groupBy` is non-empty".
|
|
43
|
+
*
|
|
44
|
+
* @throws DbError `INVALID_QUERY` carrying every issue (`path` `$select` / `$groupBy`).
|
|
45
|
+
*/
|
|
46
|
+
function resolveCalendarBuckets(controls, fields, aggregate) {
|
|
47
|
+
const res = resolveBuckets(controls, {
|
|
48
|
+
aggregate,
|
|
49
|
+
isField: (name) => fields.flatMap.has(name) || fields.navFields.has(name) || fields.physicalNames.has(name)
|
|
50
|
+
});
|
|
51
|
+
if (!res.ok) throw new DbError("INVALID_QUERY", res.issues);
|
|
52
|
+
return res.buckets;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Whether a field can be the source of a calendar bucket: a `number` /
|
|
56
|
+
* `integer` leaf carrying the `timestamp` tag (`number.timestamp`,
|
|
57
|
+
* `.created`, `.updated`) that is not `@db.encrypted`. The type is the
|
|
58
|
+
* declaration — no annotation opts a field in. Physical filterability is the
|
|
59
|
+
* caller's (the core path guard and moost-db's capability index both add it).
|
|
60
|
+
*/
|
|
61
|
+
function isBucketableField(fd) {
|
|
62
|
+
if (fd.encrypted) return false;
|
|
63
|
+
if (fd.designType !== "number" && fd.designType !== "integer") return false;
|
|
64
|
+
return ((fd.type?.type)?.tags)?.has("timestamp") === true;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether a field holds a JSON value — a `@db.json` object / JSON-stored
|
|
68
|
+
* column or an array. The members of `TableMetadata.jsonValueParents` (and
|
|
69
|
+
* moost-db's equivalent set) — see {@link jsonValueAncestor}.
|
|
70
|
+
*/
|
|
71
|
+
function isJsonValueField(fd) {
|
|
72
|
+
return fd.storage === "json" || fd.designType === "json" || fd.designType === "array";
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The outermost ancestor of `path` in `jsonValueParents` (the paths of the
|
|
76
|
+
* {@link isJsonValueField} descriptors), or `undefined`. A timestamp inside a
|
|
77
|
+
* JSON value is never a bucket source — relational adapters cannot address
|
|
78
|
+
* it and nested-object adapters (which can) must not diverge from them.
|
|
79
|
+
*/
|
|
80
|
+
function jsonValueAncestor(path, jsonValueParents) {
|
|
81
|
+
if (jsonValueParents.size === 0) return void 0;
|
|
82
|
+
let pos = path.indexOf(".");
|
|
83
|
+
while (pos !== -1) {
|
|
84
|
+
const ancestor = path.slice(0, pos);
|
|
85
|
+
if (jsonValueParents.has(ancestor)) return ancestor;
|
|
86
|
+
pos = path.indexOf(".", pos + 1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
//#endregion
|
|
17
90
|
//#region src/table/table-metadata.ts
|
|
18
91
|
const INDEX_PREFIX = "atscript__";
|
|
19
92
|
function indexKey(type, name) {
|
|
@@ -124,6 +197,14 @@ var TableMetadata = class {
|
|
|
124
197
|
* dotted paths as descriptors).
|
|
125
198
|
*/
|
|
126
199
|
jsonParents = /* @__PURE__ */ new Set();
|
|
200
|
+
/**
|
|
201
|
+
* Logical paths holding a JSON value (`isJsonValueField`: JSON-stored, `json`
|
|
202
|
+
* or `array` design type; non-ignored, nav-free descriptors) — a timestamp
|
|
203
|
+
* beneath one is never a calendar-bucket source (`jsonValueAncestor`).
|
|
204
|
+
*/
|
|
205
|
+
jsonValueParents = /* @__PURE__ */ new Set();
|
|
206
|
+
/** Every field descriptor's `physicalName` — reserved names a bucket alias may not take. */
|
|
207
|
+
physicalNames = /* @__PURE__ */ new Set();
|
|
127
208
|
_built = false;
|
|
128
209
|
_identifications;
|
|
129
210
|
_collateMap = /* @__PURE__ */ new Map();
|
|
@@ -135,6 +216,21 @@ var TableMetadata = class {
|
|
|
135
216
|
return this._built;
|
|
136
217
|
}
|
|
137
218
|
/**
|
|
219
|
+
* Logical field path → its physical path in document storage (nested
|
|
220
|
+
* objects kept inline). `@db.column` renames apply to the annotated key,
|
|
221
|
+
* and a document renames the TOP-LEVEL key only — nested keys are stored
|
|
222
|
+
* as-is — so a dotted path under a renamed top-level object renames its
|
|
223
|
+
* first segment: `profile.bio` under `@db.column 'prof'` → `prof.bio`.
|
|
224
|
+
*/
|
|
225
|
+
documentPath(path) {
|
|
226
|
+
const direct = this.columnMap.get(path);
|
|
227
|
+
if (direct !== void 0) return direct;
|
|
228
|
+
const dot = path.indexOf(".");
|
|
229
|
+
if (dot === -1) return path;
|
|
230
|
+
const top = this.columnMap.get(path.slice(0, dot));
|
|
231
|
+
return top === void 0 ? path : top + path.slice(dot);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
138
234
|
* Runs the full metadata compilation pipeline. Called once by
|
|
139
235
|
* `AtscriptDbReadable._ensureBuilt()` on first metadata access.
|
|
140
236
|
*
|
|
@@ -187,7 +283,7 @@ var TableMetadata = class {
|
|
|
187
283
|
this._built = true;
|
|
188
284
|
adapter.onAfterFlatten?.();
|
|
189
285
|
if (this.nestedObjects && this.flatMap) {
|
|
190
|
-
for (const path of this.flatMap.keys()) if (path && !this.ignoredFields.has(path) && !this.navFields.has(path) && findAncestorInSet(path, this.navFields) === void 0) this.allPhysicalFields.push(path);
|
|
286
|
+
for (const path of this.flatMap.keys()) if (path && !this.ignoredFields.has(path) && !this.navFields.has(path) && findAncestorInSet(path, this.navFields) === void 0) this.allPhysicalFields.push(this.documentPath(path));
|
|
191
287
|
} else for (const [path, physical] of this.pathToPhysical) {
|
|
192
288
|
if (this.navFields.has(path)) continue;
|
|
193
289
|
if (findAncestorInSet(path, this.navFields) !== void 0) continue;
|
|
@@ -419,19 +515,25 @@ var TableMetadata = class {
|
|
|
419
515
|
/** Nearest `@db.encrypted` ancestor of `path` (exclusive), or `undefined`. */
|
|
420
516
|
/**
|
|
421
517
|
* Indexes non-ignored descriptors by logical path and retains the JSON-parent
|
|
422
|
-
*
|
|
518
|
+
* sets (plus every descriptor's physical name). Navigation relations and their descendants are skipped even when the
|
|
423
519
|
* adapter keeps them as descriptors (nested-object adapters do) — they are
|
|
424
520
|
* loaded with `$with`, never addressed as columns of this table.
|
|
425
521
|
*/
|
|
426
522
|
_buildGuardIndexes() {
|
|
427
523
|
const jsonParents = /* @__PURE__ */ new Set();
|
|
524
|
+
const jsonValueParents = /* @__PURE__ */ new Set();
|
|
525
|
+
const physicalNames = /* @__PURE__ */ new Set();
|
|
428
526
|
for (const fd of this.fieldDescriptors) {
|
|
527
|
+
physicalNames.add(fd.physicalName);
|
|
429
528
|
if (fd.ignored) continue;
|
|
430
529
|
if (this.navFields.has(fd.path) || findAncestorInSet(fd.path, this.navFields) !== void 0) continue;
|
|
431
530
|
this.descriptorByPath.set(fd.path, fd);
|
|
432
531
|
if (fd.storage === "json") jsonParents.add(fd.path);
|
|
532
|
+
if (isJsonValueField(fd)) jsonValueParents.add(fd.path);
|
|
433
533
|
}
|
|
434
534
|
this.jsonParents = jsonParents;
|
|
535
|
+
this.jsonValueParents = jsonValueParents;
|
|
536
|
+
this.physicalNames = physicalNames;
|
|
435
537
|
}
|
|
436
538
|
/**
|
|
437
539
|
* Indexes `fieldDescriptors` into two lookup maps for unified
|
|
@@ -653,6 +755,11 @@ var TableMetadata = class {
|
|
|
653
755
|
* `controls.$select` is `UniquSelect | undefined`.
|
|
654
756
|
*
|
|
655
757
|
* For exclusion → inclusion inversion, pass `allFields` (physical field names).
|
|
758
|
+
*
|
|
759
|
+
* An array `$select` holds plain field names and computed entries —
|
|
760
|
+
* aggregates (`{ $fn, $field }`, {@link aggregates}) and calendar buckets
|
|
761
|
+
* (`{ $bucket, $field }`, {@link buckets}). Entries arrive normalized
|
|
762
|
+
* (`resolveCalendarBuckets` rejects any other shape before translation).
|
|
656
763
|
*/
|
|
657
764
|
var UniquSelect = class UniquSelect {
|
|
658
765
|
static UNRESOLVED = Symbol("unresolved");
|
|
@@ -661,17 +768,29 @@ var UniquSelect = class UniquSelect {
|
|
|
661
768
|
_array = UniquSelect.UNRESOLVED;
|
|
662
769
|
_projection = UniquSelect.UNRESOLVED;
|
|
663
770
|
_aggregates = UniquSelect.UNRESOLVED;
|
|
664
|
-
|
|
771
|
+
/**
|
|
772
|
+
* The calendar buckets of an aggregate `$select`, normalized (canonical
|
|
773
|
+
* zone, week start, alias) with the PHYSICAL source `field` and its
|
|
774
|
+
* descriptor `fd`. `undefined` when there are none. A `$groupBy` key equal
|
|
775
|
+
* to a bucket's `alias` groups by that bucket (aliases never collide with
|
|
776
|
+
* columns). Since 0.1.132.
|
|
777
|
+
*/
|
|
778
|
+
buckets;
|
|
779
|
+
/**
|
|
780
|
+
* @param raw - the `$select` value (field paths already physical).
|
|
781
|
+
* @param allFields - physical field names, for exclusion-form inversion.
|
|
782
|
+
* @param buckets - the resolved calendar buckets of the raw `$select`'s
|
|
783
|
+
* `{ $bucket }` entries (the field mappers supply them — physical `field`,
|
|
784
|
+
* source `fd`).
|
|
785
|
+
*/
|
|
786
|
+
constructor(raw, allFields, buckets) {
|
|
665
787
|
this._raw = raw;
|
|
666
788
|
this._allFields = allFields;
|
|
667
|
-
|
|
668
|
-
/** Type guard: checks if a value is an AggregateExpr ({$fn, $field}). */
|
|
669
|
-
static _isAggregateExpr(v) {
|
|
670
|
-
return typeof v === "object" && v !== null && "$fn" in v && "$field" in v;
|
|
789
|
+
this.buckets = buckets?.length ? buckets : void 0;
|
|
671
790
|
}
|
|
672
791
|
/**
|
|
673
792
|
* Resolved inclusion array of plain field names (strings only).
|
|
674
|
-
*
|
|
793
|
+
* Computed entries (aggregates, calendar buckets) are filtered out.
|
|
675
794
|
* For exclusion form, inverts using `allFields` from constructor.
|
|
676
795
|
*/
|
|
677
796
|
get asArray() {
|
|
@@ -726,7 +845,7 @@ var UniquSelect = class UniquSelect {
|
|
|
726
845
|
this._aggregates = void 0;
|
|
727
846
|
return;
|
|
728
847
|
}
|
|
729
|
-
const aggs = this._raw.filter(
|
|
848
|
+
const aggs = this._raw.filter(isAggregateExpr);
|
|
730
849
|
this._aggregates = aggs.length > 0 ? aggs : void 0;
|
|
731
850
|
return this._aggregates;
|
|
732
851
|
}
|
|
@@ -734,6 +853,10 @@ var UniquSelect = class UniquSelect {
|
|
|
734
853
|
get hasAggregates() {
|
|
735
854
|
return !!this.aggregates?.length;
|
|
736
855
|
}
|
|
856
|
+
/** The calendar bucket whose alias is `key`, if any — how adapters resolve a `$groupBy` / `$having` key. */
|
|
857
|
+
bucketByAlias(key) {
|
|
858
|
+
return this.buckets?.find((b) => b.alias === key);
|
|
859
|
+
}
|
|
737
860
|
};
|
|
738
861
|
//#endregion
|
|
739
862
|
//#region src/strategies/field-mapping.ts
|
|
@@ -754,9 +877,83 @@ function toDecimalString(value) {
|
|
|
754
877
|
* and `RelationalFieldMapper` (flattened columns, SQL).
|
|
755
878
|
*/
|
|
756
879
|
var FieldMappingStrategy = class {
|
|
880
|
+
/**
|
|
881
|
+
* Whether {@link physicalPath} can differ from the logical path for this
|
|
882
|
+
* table; `false` lets the path translations hand their input back as-is.
|
|
883
|
+
*/
|
|
884
|
+
renamesPaths(_meta) {
|
|
885
|
+
return true;
|
|
886
|
+
}
|
|
887
|
+
/**
|
|
888
|
+
* Translates a grouped query to physical names: the filter and `$having`
|
|
889
|
+
* through {@link translateFilter}, and every field path in `$groupBy`,
|
|
890
|
+
* `$select` (plain and computed `$field`s) and `$sort` through
|
|
891
|
+
* {@link physicalPath}. Computed aliases pass through (a bucket alias never
|
|
892
|
+
* equals a field name).
|
|
893
|
+
*
|
|
894
|
+
* `buckets` are the query's calendar buckets as the core's normalizer
|
|
895
|
+
* resolved them (`resolveCalendarBuckets` — `AtscriptDbReadable.aggregate`
|
|
896
|
+
* runs it before the guards); they reach adapters with `field` made
|
|
897
|
+
* physical and the source descriptor as `fd`.
|
|
898
|
+
*/
|
|
899
|
+
translateAggregateQuery(query, meta, buckets) {
|
|
900
|
+
const controls = query.controls;
|
|
901
|
+
const aliases = this.computedAliasSet(controls.$select);
|
|
902
|
+
const physicalBuckets = buckets.map((b) => ({
|
|
903
|
+
...b,
|
|
904
|
+
field: this.physicalPath(b.field, meta),
|
|
905
|
+
fd: meta.descriptorByPath.get(b.field)
|
|
906
|
+
}));
|
|
907
|
+
const select = controls.$select && this.physicalSelect(controls.$select, meta);
|
|
908
|
+
return {
|
|
909
|
+
filter: this.translateFilter(query.filter ?? {}, meta),
|
|
910
|
+
controls: {
|
|
911
|
+
...controls,
|
|
912
|
+
$with: void 0,
|
|
913
|
+
$groupBy: this.renamesPaths(meta) ? controls.$groupBy.map((key) => aliases.has(key) ? key : this.physicalPath(key, meta)) : controls.$groupBy,
|
|
914
|
+
$select: select ? new UniquSelect(select, meta.allPhysicalFields, physicalBuckets) : void 0,
|
|
915
|
+
$sort: controls.$sort && this.physicalSort(controls.$sort, meta, aliases),
|
|
916
|
+
$having: controls.$having ? this.translateFilter(controls.$having, meta) : void 0
|
|
917
|
+
},
|
|
918
|
+
insights: query.insights
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
/** Output aliases of the computed `$select` entries (aggregates and calendar buckets). */
|
|
922
|
+
computedAliasSet(select) {
|
|
923
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
924
|
+
for (const item of select ?? []) if (isAggregateExpr(item) || isBucketExpr(item)) aliases.add(resolveAlias(item));
|
|
925
|
+
return aliases;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* `$select` with its field paths made physical: array-form names and
|
|
929
|
+
* computed `$field`s (`'*'` kept), or the keys of the object
|
|
930
|
+
* (inclusion / exclusion) form.
|
|
931
|
+
*/
|
|
932
|
+
physicalSelect(select, meta) {
|
|
933
|
+
if (!this.renamesPaths(meta)) return select;
|
|
934
|
+
if (Array.isArray(select)) return select.map((item) => {
|
|
935
|
+
if (typeof item === "string") return this.physicalPath(item, meta);
|
|
936
|
+
if (isAggregateExpr(item) || isBucketExpr(item)) return item.$field === "*" ? item : {
|
|
937
|
+
...item,
|
|
938
|
+
$field: this.physicalPath(item.$field, meta)
|
|
939
|
+
};
|
|
940
|
+
return item;
|
|
941
|
+
});
|
|
942
|
+
const translated = {};
|
|
943
|
+
for (const [key, flag] of Object.entries(select)) translated[this.physicalPath(key, meta)] = flag;
|
|
944
|
+
return translated;
|
|
945
|
+
}
|
|
946
|
+
/** `$sort` with physical keys; computed `aliases` (grouped queries) pass through. */
|
|
947
|
+
physicalSort(sort, meta, aliases) {
|
|
948
|
+
if (!this.renamesPaths(meta)) return sort;
|
|
949
|
+
const translated = {};
|
|
950
|
+
for (const [key, dir] of Object.entries(sort)) translated[aliases?.has(key) ? key : this.physicalPath(key, meta)] = dir;
|
|
951
|
+
return translated;
|
|
952
|
+
}
|
|
757
953
|
/**
|
|
758
954
|
* Recursively walks a filter expression, applying `@db.column` key renames
|
|
759
|
-
*
|
|
955
|
+
* (document paths — {@link TableMetadata.documentPath}) and adapter-specific
|
|
956
|
+
* value formatting via `formatFilterValue`.
|
|
760
957
|
*
|
|
761
958
|
* The relational mapper overrides this to use `leafByLogical` for deeper
|
|
762
959
|
* key resolution (flattened nested paths).
|
|
@@ -769,8 +966,8 @@ var FieldMappingStrategy = class {
|
|
|
769
966
|
else if (key === "$not") result[key] = this.translateFilter(value, meta);
|
|
770
967
|
else if (key.startsWith("$")) result[key] = value;
|
|
771
968
|
else {
|
|
772
|
-
const
|
|
773
|
-
result[
|
|
969
|
+
const formatKey = meta.columnMap.get(key) ?? key;
|
|
970
|
+
result[meta.documentPath(key)] = this.formatFilterValue(formatKey, value, meta);
|
|
774
971
|
}
|
|
775
972
|
return result;
|
|
776
973
|
}
|
|
@@ -898,29 +1095,31 @@ var DocumentFieldMapper = class extends FieldMappingStrategy {
|
|
|
898
1095
|
if (meta.columnMap.size > 0) this.reverseColumnRenames(row, meta);
|
|
899
1096
|
return row;
|
|
900
1097
|
}
|
|
1098
|
+
/**
|
|
1099
|
+
* Every field-path position goes through `@db.column` renames
|
|
1100
|
+
* ({@link TableMetadata.documentPath}): filter keys, `$select` fields
|
|
1101
|
+
* (array, inclusion and exclusion forms) and `$sort` keys.
|
|
1102
|
+
*/
|
|
901
1103
|
translateQuery(query, meta) {
|
|
902
1104
|
const controls = query.controls;
|
|
1105
|
+
const select = controls?.$select && this.physicalSelect(controls.$select, meta);
|
|
903
1106
|
return {
|
|
904
1107
|
filter: this.translateFilter(query.filter, meta),
|
|
905
1108
|
controls: {
|
|
906
1109
|
...controls,
|
|
907
1110
|
$with: void 0,
|
|
908
|
-
$select:
|
|
1111
|
+
$select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
|
|
1112
|
+
$sort: controls?.$sort && this.physicalSort(controls.$sort, meta)
|
|
909
1113
|
},
|
|
910
1114
|
insights: query.insights
|
|
911
1115
|
};
|
|
912
1116
|
}
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
return
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
$with: void 0,
|
|
920
|
-
$select: controls.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
|
|
921
|
-
},
|
|
922
|
-
insights: query.insights
|
|
923
|
-
};
|
|
1117
|
+
/** A document path with `@db.column` renames ({@link TableMetadata.documentPath}). */
|
|
1118
|
+
physicalPath(logical, meta) {
|
|
1119
|
+
return meta.documentPath(logical);
|
|
1120
|
+
}
|
|
1121
|
+
renamesPaths(meta) {
|
|
1122
|
+
return meta.columnMap.size > 0;
|
|
924
1123
|
}
|
|
925
1124
|
prepareForWrite(payload, meta, adapter) {
|
|
926
1125
|
const data = { ...payload };
|
|
@@ -995,46 +1194,9 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
|
|
|
995
1194
|
insights: query.insights
|
|
996
1195
|
};
|
|
997
1196
|
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
const groupBy = controls.$groupBy.map((field) => meta.leafByLogical.get(field)?.physicalName ?? field);
|
|
1002
|
-
let select;
|
|
1003
|
-
if (controls.$select) select = controls.$select.map((item) => {
|
|
1004
|
-
if (typeof item === "string") return meta.leafByLogical.get(item)?.physicalName ?? item;
|
|
1005
|
-
if (item.$field === "*") return item;
|
|
1006
|
-
return {
|
|
1007
|
-
...item,
|
|
1008
|
-
$field: meta.leafByLogical.get(item.$field)?.physicalName ?? item.$field
|
|
1009
|
-
};
|
|
1010
|
-
});
|
|
1011
|
-
const aliases = /* @__PURE__ */ new Set();
|
|
1012
|
-
if (controls.$select) {
|
|
1013
|
-
for (const item of controls.$select) if (typeof item !== "string") aliases.add(resolveAlias(item));
|
|
1014
|
-
}
|
|
1015
|
-
let sort;
|
|
1016
|
-
if (controls.$sort) {
|
|
1017
|
-
const translated = {};
|
|
1018
|
-
for (const [key, dir] of Object.entries(controls.$sort)) if (aliases.has(key)) translated[key] = dir;
|
|
1019
|
-
else {
|
|
1020
|
-
const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
|
|
1021
|
-
translated[physical] = dir;
|
|
1022
|
-
}
|
|
1023
|
-
sort = translated;
|
|
1024
|
-
}
|
|
1025
|
-
let having;
|
|
1026
|
-
if (controls.$having) having = meta.requiresMappings ? this.translateFilterWithRename(controls.$having, meta) : meta.toStorageFormatters ? this.translateFilter(controls.$having, meta) : controls.$having;
|
|
1027
|
-
return {
|
|
1028
|
-
filter,
|
|
1029
|
-
controls: {
|
|
1030
|
-
...controls,
|
|
1031
|
-
$groupBy: groupBy,
|
|
1032
|
-
$select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
|
|
1033
|
-
$sort: sort,
|
|
1034
|
-
$having: having
|
|
1035
|
-
},
|
|
1036
|
-
insights: query.insights
|
|
1037
|
-
};
|
|
1197
|
+
/** The flattened column of a logical path (`contact.email` → `contact__email`). */
|
|
1198
|
+
physicalPath(logical, meta) {
|
|
1199
|
+
return meta.leafByLogical.get(logical)?.physicalName ?? logical;
|
|
1038
1200
|
}
|
|
1039
1201
|
/**
|
|
1040
1202
|
* Overrides the base `translateFilter` to use `leafByLogical` for key resolution
|
|
@@ -1196,11 +1358,18 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
|
|
|
1196
1358
|
* - `$geoWithin` on a non-geoPoint field → `FILTER_TYPE_MISMATCH`
|
|
1197
1359
|
* - `$geoWithin` with a malformed circle → `INVALID_QUERY`
|
|
1198
1360
|
* - `$geoWithin` on an adapter without geo support → `GEO_NOT_SUPPORTED`
|
|
1361
|
+
* - `$exists` with a non-boolean operand → `INVALID_QUERY`
|
|
1362
|
+
* - malformed `$select` computed entries / `$groupBy` entries, calendar
|
|
1363
|
+
* buckets outside grouped queries or with a bad unit / zone / alias →
|
|
1364
|
+
* `INVALID_QUERY` (the shared normalizer, `resolveCalendarBuckets`)
|
|
1365
|
+
* - a calendar bucket over a non-timestamp field → `INVALID_QUERY`; a unit
|
|
1366
|
+
* the adapter's `calendarBucketUnits()` lacks → `BUCKET_NOT_SUPPORTED`
|
|
1199
1367
|
* - every filter / `$sort` / `$select` / `$groupBy` / `$having` / aggregate
|
|
1200
1368
|
* path must resolve to physical storage on THIS adapter and pass the
|
|
1201
|
-
*
|
|
1202
|
-
*
|
|
1203
|
-
* keep firing first for
|
|
1369
|
+
* capability its position (for a filter entry: its predicate class, see
|
|
1370
|
+
* {@link canFilterLeaf}) needs → `INVALID_QUERY` (see {@link guardPaths}).
|
|
1371
|
+
* Runs after the checks above so `ENC_*` codes keep firing first for
|
|
1372
|
+
* encrypted subtrees.
|
|
1204
1373
|
*/
|
|
1205
1374
|
/** Validates a `[lng, lat]` tuple (GeoJSON coordinate order). */
|
|
1206
1375
|
function assertGeoPoint(point, path) {
|
|
@@ -1241,7 +1410,8 @@ function guardGeoWithin(meta, adapter, field, value) {
|
|
|
1241
1410
|
}
|
|
1242
1411
|
/**
|
|
1243
1412
|
* Walks a filter expression, rejecting encrypted-field references and
|
|
1244
|
-
* validating `$geoWithin`
|
|
1413
|
+
* validating operator operands: `$geoWithin` shapes and the boolean
|
|
1414
|
+
* `$exists` operand (this is the one owner of that rule).
|
|
1245
1415
|
*/
|
|
1246
1416
|
function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
|
|
1247
1417
|
if (!filter || typeof filter !== "object") return;
|
|
@@ -1257,8 +1427,12 @@ function guardFilter(meta, adapter, filter, encCode = "ENC_FIELD_FILTER") {
|
|
|
1257
1427
|
}
|
|
1258
1428
|
if (key.startsWith("$")) continue;
|
|
1259
1429
|
if (hasEncrypted && isEncryptedRef(meta, key)) throw encryptedRefError(encCode, key, "filter on");
|
|
1260
|
-
if (
|
|
1430
|
+
if (!isPrimitive(value)) {
|
|
1261
1431
|
for (const [op, opValue] of Object.entries(value)) if (op === "$geoWithin") guardGeoWithin(meta, adapter, key, opValue);
|
|
1432
|
+
else if (op === "$exists" && typeof opValue !== "boolean") throw new DbError("INVALID_QUERY", [{
|
|
1433
|
+
path: key,
|
|
1434
|
+
message: `$exists on "${key}" expects true or false`
|
|
1435
|
+
}]);
|
|
1262
1436
|
}
|
|
1263
1437
|
}
|
|
1264
1438
|
}
|
|
@@ -1273,7 +1447,8 @@ const OP_VERB = {
|
|
|
1273
1447
|
select: "select",
|
|
1274
1448
|
groupBy: "group by",
|
|
1275
1449
|
having: "filter ($having) on",
|
|
1276
|
-
aggregate: "aggregate over"
|
|
1450
|
+
aggregate: "aggregate over",
|
|
1451
|
+
bucket: "bucket"
|
|
1277
1452
|
};
|
|
1278
1453
|
function pathError(path, message) {
|
|
1279
1454
|
return new DbError("INVALID_QUERY", [{
|
|
@@ -1313,16 +1488,64 @@ function sortFieldNames(sort) {
|
|
|
1313
1488
|
if (typeof sort === "object") return Object.keys(sort);
|
|
1314
1489
|
return [];
|
|
1315
1490
|
}
|
|
1316
|
-
|
|
1491
|
+
/** The operator each non-`compare` predicate class stands for, in `filterOps` order. */
|
|
1492
|
+
const FILTER_PREDICATE_OPS = {
|
|
1493
|
+
exists: "$exists",
|
|
1494
|
+
geo: "$geoWithin"
|
|
1495
|
+
};
|
|
1496
|
+
/** Classifies one filter entry's value (`{ key: value }`); operand validity is `guardFilter`'s. */
|
|
1497
|
+
function filterPredicateOf(value) {
|
|
1498
|
+
if (isPrimitive(value)) return "compare";
|
|
1499
|
+
const ops = value;
|
|
1500
|
+
if ("$geoWithin" in ops) return "geo";
|
|
1501
|
+
const keys = Object.keys(ops);
|
|
1502
|
+
return keys.length === 1 && keys[0] === "$exists" ? "exists" : "compare";
|
|
1503
|
+
}
|
|
1504
|
+
/**
|
|
1505
|
+
* Whether a stored leaf physically supports a filter predicate of this class
|
|
1506
|
+
* — the one rule the core path guard and moost-db's HTTP capability index
|
|
1507
|
+
* both apply:
|
|
1508
|
+
*
|
|
1509
|
+
* - `compare` → `adapter.canFilterField(fd)`;
|
|
1510
|
+
* - `exists` → any stored column: it tests whether the column holds a value
|
|
1511
|
+
* (SQL `IS [NOT] NULL`), never its content, so the scalar veto (JSON /
|
|
1512
|
+
* array storage on relational adapters) does not apply;
|
|
1513
|
+
* - `geo` → a `db.geoPoint` leaf on a geo-searchable adapter.
|
|
1514
|
+
*
|
|
1515
|
+
* `@db.encrypted` vetoes every class.
|
|
1516
|
+
*/
|
|
1517
|
+
function canFilterLeaf(fd, predicate, adapter) {
|
|
1518
|
+
if (fd.encrypted) return false;
|
|
1519
|
+
switch (predicate) {
|
|
1520
|
+
case "exists": return true;
|
|
1521
|
+
case "geo": return fd.isGeoPoint === true && adapter.isGeoSearchable();
|
|
1522
|
+
default: return adapter.canFilterField(fd);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
/**
|
|
1526
|
+
* The operators of the non-`compare` predicate classes a leaf physically
|
|
1527
|
+
* accepts — named in a value-comparison rejection by the core guard, and
|
|
1528
|
+
* listed (under the HTTP policy) as `/meta.fields[P].filterOps`.
|
|
1529
|
+
*/
|
|
1530
|
+
function narrowerFilterOps(fd, adapter) {
|
|
1531
|
+
const ops = [];
|
|
1532
|
+
for (const [predicate, op] of Object.entries(FILTER_PREDICATE_OPS)) if (canFilterLeaf(fd, predicate, adapter)) ops.push(op);
|
|
1533
|
+
return ops;
|
|
1534
|
+
}
|
|
1535
|
+
/** The rejection suffix naming {@link narrowerFilterOps} — `""` when there are none. */
|
|
1536
|
+
function acceptedOperatorsHint(ops) {
|
|
1537
|
+
return ops.length > 0 ? ` (accepted operators: ${ops.join(", ")})` : "";
|
|
1538
|
+
}
|
|
1539
|
+
function collectFilterKeys(filter, push, skip, refs) {
|
|
1317
1540
|
if (!filter || typeof filter !== "object" || Array.isArray(filter)) return;
|
|
1318
1541
|
for (const [key, value] of Object.entries(filter)) {
|
|
1319
1542
|
if (refs.unsupportedOperator) return;
|
|
1320
1543
|
if (key === "$and" || key === "$or") {
|
|
1321
|
-
if (Array.isArray(value)) for (const child of value) collectFilterKeys(child,
|
|
1544
|
+
if (Array.isArray(value)) for (const child of value) collectFilterKeys(child, push, skip, refs);
|
|
1322
1545
|
continue;
|
|
1323
1546
|
}
|
|
1324
1547
|
if (key === "$not") {
|
|
1325
|
-
collectFilterKeys(value,
|
|
1548
|
+
collectFilterKeys(value, push, skip, refs);
|
|
1326
1549
|
continue;
|
|
1327
1550
|
}
|
|
1328
1551
|
if (key.startsWith("$")) {
|
|
@@ -1330,7 +1553,7 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
|
|
|
1330
1553
|
return;
|
|
1331
1554
|
}
|
|
1332
1555
|
if (skip?.has(key)) continue;
|
|
1333
|
-
(
|
|
1556
|
+
push(key, value);
|
|
1334
1557
|
}
|
|
1335
1558
|
}
|
|
1336
1559
|
/**
|
|
@@ -1340,40 +1563,52 @@ function collectFilterKeys(filter, out, geo, skip, refs) {
|
|
|
1340
1563
|
* visited — they are validated against their target relation separately.
|
|
1341
1564
|
*
|
|
1342
1565
|
* Aggregate mode is `aggregate` when given, else the presence of `$groupBy`.
|
|
1343
|
-
* In aggregate mode `$select` entries are
|
|
1344
|
-
* `$field`
|
|
1345
|
-
* `resolveAlias`)
|
|
1346
|
-
* `$
|
|
1566
|
+
* In aggregate mode `$select` computed entries are collected by kind — an
|
|
1567
|
+
* aggregate's `$field` into `aggregate`, a calendar bucket's into `bucket` —
|
|
1568
|
+
* and their aliases (`$as`, else uniqu's `resolveAlias`) are exempted from
|
|
1569
|
+
* `$sort` / `$having`; a bucket alias is also dropped from `groupBy`, which
|
|
1570
|
+
* lists grouped fields only.
|
|
1571
|
+
*
|
|
1572
|
+
* Entry shapes are not checked here — see `resolveCalendarBuckets`.
|
|
1347
1573
|
*/
|
|
1348
1574
|
function collectQueryPaths(query, aggregate) {
|
|
1349
1575
|
const refs = {
|
|
1350
1576
|
filter: [],
|
|
1351
|
-
geoFilter: [],
|
|
1352
1577
|
sort: [],
|
|
1353
1578
|
select: [],
|
|
1354
1579
|
groupBy: [],
|
|
1355
1580
|
having: [],
|
|
1356
1581
|
aggregate: [],
|
|
1582
|
+
bucket: [],
|
|
1357
1583
|
aggregateMode: false
|
|
1358
1584
|
};
|
|
1359
|
-
collectFilterKeys(query.filter,
|
|
1585
|
+
collectFilterKeys(query.filter, (path, value) => refs.filter.push({
|
|
1586
|
+
path,
|
|
1587
|
+
predicate: filterPredicateOf(value)
|
|
1588
|
+
}), void 0, refs);
|
|
1360
1589
|
const controls = query.controls ?? {};
|
|
1361
1590
|
const rawGroupBy = controls.$groupBy;
|
|
1362
1591
|
const groupBy = Array.isArray(rawGroupBy) ? rawGroupBy.filter((f) => typeof f === "string") : typeof rawGroupBy === "string" ? [rawGroupBy] : [];
|
|
1363
1592
|
refs.aggregateMode = aggregate ?? groupBy.length > 0;
|
|
1364
|
-
refs.groupBy = groupBy;
|
|
1365
1593
|
const aliases = /* @__PURE__ */ new Set();
|
|
1594
|
+
const bucketAliases = /* @__PURE__ */ new Set();
|
|
1366
1595
|
const select = controls.$select;
|
|
1367
1596
|
if (Array.isArray(select)) {
|
|
1368
1597
|
for (const item of select) if (typeof item === "string") refs.select.push(item);
|
|
1369
|
-
else if (refs.aggregateMode
|
|
1370
|
-
|
|
1371
|
-
aliases.add(resolveAlias(
|
|
1372
|
-
if (
|
|
1598
|
+
else if (!refs.aggregateMode) continue;
|
|
1599
|
+
else if (isAggregateExpr(item)) {
|
|
1600
|
+
aliases.add(resolveAlias(item));
|
|
1601
|
+
if (item.$field !== "*") refs.aggregate.push(item.$field);
|
|
1602
|
+
} else if (isBucketExpr(item)) {
|
|
1603
|
+
const alias = resolveAlias(item);
|
|
1604
|
+
aliases.add(alias);
|
|
1605
|
+
bucketAliases.add(alias);
|
|
1606
|
+
refs.bucket.push(item.$field);
|
|
1373
1607
|
}
|
|
1374
1608
|
} else if (select && typeof select === "object") refs.select.push(...Object.keys(select));
|
|
1609
|
+
refs.groupBy = bucketAliases.size > 0 ? groupBy.filter((name) => !bucketAliases.has(name)) : groupBy;
|
|
1375
1610
|
for (const name of sortFieldNames(controls.$sort)) if (!aliases.has(name)) refs.sort.push(name);
|
|
1376
|
-
if (refs.aggregateMode) collectFilterKeys(controls.$having, refs.having,
|
|
1611
|
+
if (refs.aggregateMode) collectFilterKeys(controls.$having, (path) => refs.having.push(path), aliases, refs);
|
|
1377
1612
|
return refs;
|
|
1378
1613
|
}
|
|
1379
1614
|
/**
|
|
@@ -1424,21 +1659,22 @@ function pathSourceOf(meta) {
|
|
|
1424
1659
|
* metadata and adapter capability — the classification of
|
|
1425
1660
|
* {@link classifyQueryPath} plus the position's physical requirement:
|
|
1426
1661
|
*
|
|
1427
|
-
* - a leaf → physical capability (`
|
|
1428
|
-
*
|
|
1662
|
+
* - a leaf → physical capability (`canSortField` for `$sort`; for a filter
|
|
1663
|
+
* entry {@link canFilterLeaf} of its `predicate` class; `canFilterField`
|
|
1664
|
+
* for `$groupBy` / `$having` / aggregate `$field`s; `$select` always passes);
|
|
1665
|
+
* a calendar-bucket source must also be a timestamp field
|
|
1666
|
+
* (`isBucketableField`);
|
|
1429
1667
|
* - a nested-object parent → only `$select`, and only when it expands to
|
|
1430
1668
|
* leaf columns (`selectExpansion`);
|
|
1431
1669
|
* - everything else is rejected.
|
|
1432
1670
|
*
|
|
1433
|
-
* `
|
|
1434
|
-
* shape and index support were already validated by {@link guardFilter}, so
|
|
1435
|
-
* the adapter's scalar `canFilterField` veto does not apply.
|
|
1671
|
+
* `predicate` is a filter entry's class; other positions leave the default.
|
|
1436
1672
|
*
|
|
1437
1673
|
* Messages are the short programmatic forms; the HTTP wording (moost-db's
|
|
1438
1674
|
* `FieldCapabilityIndex`, with `$with` hints and leaf lists) is what clients
|
|
1439
1675
|
* see and is authoritative — the HTTP gate always answers first.
|
|
1440
1676
|
*/
|
|
1441
|
-
function guardPath(meta, adapter, path, op,
|
|
1677
|
+
function guardPath(meta, adapter, path, op, predicate = "compare") {
|
|
1442
1678
|
const verb = OP_VERB[op];
|
|
1443
1679
|
const { kind, parent } = classifyQueryPath(pathSourceOf(meta), path);
|
|
1444
1680
|
switch (kind) {
|
|
@@ -1446,12 +1682,18 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
|
|
|
1446
1682
|
case "leaf": {
|
|
1447
1683
|
if (op === "select") return;
|
|
1448
1684
|
const fd = meta.descriptorByPath.get(path);
|
|
1685
|
+
if (op === "bucket") {
|
|
1686
|
+
const jsonAncestor = jsonValueAncestor(path, meta.jsonValueParents);
|
|
1687
|
+
if (jsonAncestor !== void 0) throw pathError(path, `Cannot bucket "${path}" — inside JSON-stored column "${jsonAncestor}"`);
|
|
1688
|
+
if (!isBucketableField(fd)) throw pathError(path, notTimestampMessage(path));
|
|
1689
|
+
if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot bucket "${path}" — adapter cannot filter on this storage type`);
|
|
1690
|
+
return;
|
|
1691
|
+
}
|
|
1449
1692
|
if (op === "sort") {
|
|
1450
1693
|
if (!adapter.canSortField(fd)) throw pathError(path, `Cannot sort by "${path}" — adapter cannot sort on this storage type`);
|
|
1451
1694
|
return;
|
|
1452
1695
|
}
|
|
1453
|
-
if (
|
|
1454
|
-
if (!adapter.canFilterField(fd)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type`);
|
|
1696
|
+
if (!canFilterLeaf(fd, predicate, adapter)) throw pathError(path, `Cannot ${verb} "${path}" — adapter cannot filter on this storage type${op === "filter" ? acceptedOperatorsHint(narrowerFilterOps(fd, adapter)) : ""}`);
|
|
1455
1697
|
return;
|
|
1456
1698
|
}
|
|
1457
1699
|
case "objectParent":
|
|
@@ -1469,9 +1711,13 @@ function guardPath(meta, adapter, path, op, geoPredicate = false) {
|
|
|
1469
1711
|
* {@link guardPath}). Adapters may therefore assume every path they receive
|
|
1470
1712
|
* is physical.
|
|
1471
1713
|
*
|
|
1472
|
-
*
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1714
|
+
* Entry shapes are the caller's to normalize first (`resolveCalendarBuckets`
|
|
1715
|
+
* — {@link guardQuery} / {@link guardAggregate} do).
|
|
1716
|
+
*
|
|
1717
|
+
* In aggregate mode (`aggregate = true`) `$select` computed entries have
|
|
1718
|
+
* their `$field` checked (aggregates as `aggregate`, calendar buckets as
|
|
1719
|
+
* `bucket`), `$groupBy` fields are checked, and computed aliases (`$as` or
|
|
1720
|
+
* the default) are exempt in `$sort` / `$having`.
|
|
1475
1721
|
*
|
|
1476
1722
|
* Returns the collected refs so callers can run further structural rules
|
|
1477
1723
|
* (see {@link checkHavingKeys}) without walking the query again.
|
|
@@ -1480,27 +1726,32 @@ function guardPaths(meta, adapter, query, aggregate = false) {
|
|
|
1480
1726
|
if (!query) return;
|
|
1481
1727
|
const refs = collectQueryPaths(query, aggregate);
|
|
1482
1728
|
if (refs.unsupportedOperator !== void 0) throw pathError(refs.unsupportedOperator, unsupportedOperatorMessage(refs.unsupportedOperator));
|
|
1483
|
-
for (const
|
|
1484
|
-
for (const path of refs.geoFilter) guardPath(meta, adapter, path, "filter", true);
|
|
1729
|
+
for (const ref of refs.filter) guardPath(meta, adapter, ref.path, "filter", ref.predicate);
|
|
1485
1730
|
for (const path of refs.sort) guardPath(meta, adapter, path, "sort");
|
|
1486
1731
|
for (const path of refs.select) guardPath(meta, adapter, path, "select");
|
|
1487
1732
|
for (const path of refs.aggregate) guardPath(meta, adapter, path, "aggregate");
|
|
1733
|
+
for (const path of refs.bucket) guardPath(meta, adapter, path, "bucket");
|
|
1488
1734
|
for (const path of refs.groupBy) guardPath(meta, adapter, path, "groupBy");
|
|
1489
1735
|
for (const path of refs.having) guardPath(meta, adapter, path, "having");
|
|
1490
1736
|
return refs;
|
|
1491
1737
|
}
|
|
1492
|
-
/**
|
|
1738
|
+
/**
|
|
1739
|
+
* Shared read-path guard: filter + $sort encryption checks, the `$select`
|
|
1740
|
+
* normalizer (a calendar bucket is invalid outside a grouped query), then
|
|
1741
|
+
* the path guard.
|
|
1742
|
+
*/
|
|
1493
1743
|
function guardQuery(meta, adapter, query) {
|
|
1494
1744
|
if (!query) return;
|
|
1495
1745
|
guardFilter(meta, adapter, query.filter);
|
|
1496
1746
|
guardSort(meta, query.controls?.$sort);
|
|
1747
|
+
resolveCalendarBuckets(query.controls, meta, false);
|
|
1497
1748
|
guardPaths(meta, adapter, query);
|
|
1498
1749
|
}
|
|
1499
1750
|
/**
|
|
1500
|
-
* `$having` is a post-aggregation filter, so a key is either
|
|
1501
|
-
* alias (
|
|
1502
|
-
* or a `$groupBy` field (exact logical-path
|
|
1503
|
-
* stays valid). Any other key — a real but non-grouped column included — is
|
|
1751
|
+
* `$having` is a post-aggregation filter, so a key is either a computed
|
|
1752
|
+
* alias (aggregate or calendar bucket — already exempt in
|
|
1753
|
+
* {@link collectQueryPaths}) or a `$groupBy` field (exact logical-path
|
|
1754
|
+
* match: `metadata.clicks` grouped stays valid). Any other key — a real but non-grouped column included — is
|
|
1504
1755
|
* rejected here, once, for SDK and HTTP callers alike, instead of by the
|
|
1505
1756
|
* engine (PostgreSQL / MySQL error, SQLite tolerance, Mongo `[]`). Returns
|
|
1506
1757
|
* the first offending key as an error entry (`path` = the bare key, as the
|
|
@@ -1516,11 +1767,16 @@ function checkHavingKeys(refs) {
|
|
|
1516
1767
|
}
|
|
1517
1768
|
/**
|
|
1518
1769
|
* Aggregate-path guard: $groupBy / $select / $having encryption refs + filter
|
|
1519
|
-
* + $sort, then the path guard, then the
|
|
1770
|
+
* + $sort, then the path guard, then the adapter's calendar-bucket units
|
|
1771
|
+
* (`BUCKET_NOT_SUPPORTED`), then the `$having` key rule
|
|
1520
1772
|
* ({@link checkHavingKeys} — after the path guard so an unknown key still
|
|
1521
1773
|
* reads `Unknown field`).
|
|
1774
|
+
*
|
|
1775
|
+
* `buckets` are the query's resolved calendar buckets when the caller already
|
|
1776
|
+
* ran `resolveCalendarBuckets` (resolved here otherwise).
|
|
1522
1777
|
*/
|
|
1523
|
-
function guardAggregate(meta, adapter, query) {
|
|
1778
|
+
function guardAggregate(meta, adapter, query, resolved) {
|
|
1779
|
+
const buckets = resolved ?? resolveCalendarBuckets(query.controls, meta, true);
|
|
1524
1780
|
guardFilter(meta, adapter, query.filter);
|
|
1525
1781
|
const controls = query.controls;
|
|
1526
1782
|
if (meta.encryptedFields.size > 0) {
|
|
@@ -1533,9 +1789,27 @@ function guardAggregate(meta, adapter, query) {
|
|
|
1533
1789
|
guardSort(meta, controls.$sort);
|
|
1534
1790
|
}
|
|
1535
1791
|
const refs = guardPaths(meta, adapter, query, true);
|
|
1792
|
+
guardBucketUnits(adapter, buckets);
|
|
1536
1793
|
const having = refs ? checkHavingKeys(refs) : void 0;
|
|
1537
1794
|
if (having) throw new DbError("INVALID_QUERY", [having]);
|
|
1538
1795
|
}
|
|
1796
|
+
/** The core wording for a bucket over a field that is not `number.timestamp`. */
|
|
1797
|
+
function notTimestampMessage(path) {
|
|
1798
|
+
return `Cannot bucket "${path}" — not a timestamp field (declare it number.timestamp)`;
|
|
1799
|
+
}
|
|
1800
|
+
/**
|
|
1801
|
+
* Rejects a calendar bucket whose unit this adapter cannot group by
|
|
1802
|
+
* (`calendarBucketUnits()`; empty by default) with `BUCKET_NOT_SUPPORTED`,
|
|
1803
|
+
* before anything is translated — third-party adapters get a clean 400,
|
|
1804
|
+
* never an engine error or a silent fallback.
|
|
1805
|
+
*/
|
|
1806
|
+
function guardBucketUnits(adapter, buckets) {
|
|
1807
|
+
const units = adapter.calendarBucketUnits();
|
|
1808
|
+
for (const b of buckets) if (!units.has(b.unit)) throw new DbError("BUCKET_NOT_SUPPORTED", [{
|
|
1809
|
+
path: "$select",
|
|
1810
|
+
message: `Calendar bucket "${b.unit}" is not supported by this adapter`
|
|
1811
|
+
}]);
|
|
1812
|
+
}
|
|
1539
1813
|
//#endregion
|
|
1540
1814
|
//#region src/table/db-readable.ts
|
|
1541
1815
|
/**
|
|
@@ -1680,9 +1954,11 @@ var AtscriptDbReadable = class {
|
|
|
1680
1954
|
return this._meta;
|
|
1681
1955
|
}
|
|
1682
1956
|
_ensureSearchable() {
|
|
1683
|
-
if (
|
|
1957
|
+
if (this.adapter.isSearchable()) return;
|
|
1958
|
+
const hasVectorIndex = this.adapter.getSearchIndexes().some((i) => i.type === "vector");
|
|
1959
|
+
throw new DbError("INVALID_QUERY", [{
|
|
1684
1960
|
path: "$search",
|
|
1685
|
-
message: `Table "${this.tableName}" has no search
|
|
1961
|
+
message: `Table "${this.tableName}" has no text search index` + (hasVectorIndex ? " — a @db.search.vector index only answers vectorSearch()" : "")
|
|
1686
1962
|
}]);
|
|
1687
1963
|
}
|
|
1688
1964
|
/** Engine-agnostic query-time guards (encrypted-field refs, $geoWithin shape). */
|
|
@@ -2000,9 +2276,14 @@ var AtscriptDbReadable = class {
|
|
|
2000
2276
|
* Executes an aggregate query with GROUP BY and aggregate functions.
|
|
2001
2277
|
*
|
|
2002
2278
|
* Validates:
|
|
2279
|
+
* - `$select` computed entries and calendar buckets (the shared normalizer,
|
|
2280
|
+
* `resolveCalendarBuckets`: shapes, unit, zone, alias, grouping)
|
|
2003
2281
|
* - Plain fields in $select are a subset of $groupBy
|
|
2004
2282
|
* - When dimensions/measures are defined (strict mode): $groupBy fields
|
|
2005
|
-
* must be dimensions
|
|
2283
|
+
* must be dimensions (a calendar bucket's source field included),
|
|
2284
|
+
* aggregate $field values must be measures (or '*')
|
|
2285
|
+
* - the path guard (a bucket source must be a timestamp field) and the
|
|
2286
|
+
* adapter's calendar-bucket units (`BUCKET_NOT_SUPPORTED`)
|
|
2006
2287
|
*
|
|
2007
2288
|
* Translates field names, delegates to adapter.aggregate(),
|
|
2008
2289
|
* then reverse-maps and applies fromStorage formatters on results.
|
|
@@ -2010,6 +2291,7 @@ var AtscriptDbReadable = class {
|
|
|
2010
2291
|
async aggregate(query) {
|
|
2011
2292
|
this._ensureBuilt();
|
|
2012
2293
|
const { $groupBy, $select } = query.controls;
|
|
2294
|
+
const buckets = resolveCalendarBuckets(query.controls, this._meta, true);
|
|
2013
2295
|
if ($select) {
|
|
2014
2296
|
const groupBySet = new Set($groupBy);
|
|
2015
2297
|
for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new DbError("INVALID_QUERY", [{
|
|
@@ -2021,12 +2303,13 @@ var AtscriptDbReadable = class {
|
|
|
2021
2303
|
if (dimensions.length > 0 || measures.length > 0) {
|
|
2022
2304
|
const dimSet = new Set(dimensions);
|
|
2023
2305
|
const measSet = new Set(measures);
|
|
2024
|
-
|
|
2306
|
+
const bucketSource = new Map(buckets.map((b) => [b.alias, b.field]));
|
|
2307
|
+
for (const field of $groupBy.map((key) => bucketSource.get(key) ?? key)) if (!dimSet.has(field)) throw new DbError("INVALID_QUERY", [{
|
|
2025
2308
|
path: "$groupBy",
|
|
2026
2309
|
message: `Field "${field}" is not a dimension`
|
|
2027
2310
|
}]);
|
|
2028
2311
|
if ($select) {
|
|
2029
|
-
for (const item of $select) if (
|
|
2312
|
+
for (const item of $select) if (isAggregateExpr(item) && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
|
|
2030
2313
|
path: "$select",
|
|
2031
2314
|
message: `Aggregate field "${item.$field}" is not a measure`
|
|
2032
2315
|
}]);
|
|
@@ -2036,8 +2319,7 @@ var AtscriptDbReadable = class {
|
|
|
2036
2319
|
if ($select && quantityRefByField.size > 0) {
|
|
2037
2320
|
const groupBySet = new Set($groupBy);
|
|
2038
2321
|
for (const item of $select) {
|
|
2039
|
-
if (
|
|
2040
|
-
if (item.$field === "*") continue;
|
|
2322
|
+
if (!isAggregateExpr(item) || item.$field === "*") continue;
|
|
2041
2323
|
const refField = quantityRefByField.get(item.$field);
|
|
2042
2324
|
if (refField && !groupBySet.has(refField)) throw new DbError("INVALID_QUERY", [{
|
|
2043
2325
|
path: "$select",
|
|
@@ -2050,8 +2332,8 @@ var AtscriptDbReadable = class {
|
|
|
2050
2332
|
this._ensureSearchable();
|
|
2051
2333
|
if (!searchTerm.trim()) return query.controls.$count ? [{ count: 0 }] : [];
|
|
2052
2334
|
}
|
|
2053
|
-
guardAggregate(this._meta, this.adapter, query);
|
|
2054
|
-
const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
|
|
2335
|
+
guardAggregate(this._meta, this.adapter, query, buckets);
|
|
2336
|
+
const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta, buckets);
|
|
2055
2337
|
return (await this.adapter.aggregate(dbQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
2056
2338
|
}
|
|
2057
2339
|
/** Whether the underlying adapter supports text search. */
|
|
@@ -2062,6 +2344,10 @@ var AtscriptDbReadable = class {
|
|
|
2062
2344
|
canFilterField(fd) {
|
|
2063
2345
|
return this.adapter.canFilterField(fd);
|
|
2064
2346
|
}
|
|
2347
|
+
/** Calendar-bucket units the adapter can group by (proxies adapter capability; empty = none). */
|
|
2348
|
+
calendarBucketUnits() {
|
|
2349
|
+
return this.adapter.calendarBucketUnits();
|
|
2350
|
+
}
|
|
2065
2351
|
/** Whether the adapter can sort by a given field (proxies adapter capability). */
|
|
2066
2352
|
canSortField(fd) {
|
|
2067
2353
|
return this.adapter.canSortField(fd);
|
|
@@ -2348,7 +2634,7 @@ var AtscriptDbReadable = class {
|
|
|
2348
2634
|
* Public entry point for relation loading. Used by adapters for nested $with delegation.
|
|
2349
2635
|
*/
|
|
2350
2636
|
async loadRelations(rows, withRelations) {
|
|
2351
|
-
const { loadRelationsImpl } = await import("./relation-loader-
|
|
2637
|
+
const { loadRelationsImpl } = await import("./relation-loader-B68R1LET.mjs").then((n) => n.n);
|
|
2352
2638
|
return loadRelationsImpl(rows, withRelations, this);
|
|
2353
2639
|
}
|
|
2354
2640
|
/**
|
|
@@ -2413,6 +2699,9 @@ function createFailureCollector(what) {
|
|
|
2413
2699
|
//#endregion
|
|
2414
2700
|
//#region src/base-adapter.ts
|
|
2415
2701
|
const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
|
|
2702
|
+
const EMPTY_BUCKET_UNITS = /* @__PURE__ */ new Set();
|
|
2703
|
+
/** Every calendar-bucket unit — what an adapter that renders them all returns from `calendarBucketUnits()`. */
|
|
2704
|
+
const ALL_BUCKET_UNITS = new Set(BUCKET_UNITS);
|
|
2416
2705
|
const txStorage = new AsyncLocalStorage();
|
|
2417
2706
|
/** The innermost open transaction of `owner` in the current async chain. */
|
|
2418
2707
|
function findTxContext(owner) {
|
|
@@ -2627,6 +2916,23 @@ var BaseDbAdapter = class {
|
|
|
2627
2916
|
return EMPTY_DEFAULT_FNS;
|
|
2628
2917
|
}
|
|
2629
2918
|
/**
|
|
2919
|
+
* Calendar-bucket units (`{ $bucket, $field }` in an aggregate `$select`)
|
|
2920
|
+
* this adapter can group by, over IANA time zones. Empty (the default) =
|
|
2921
|
+
* calendar buckets unsupported: the core rejects them with
|
|
2922
|
+
* `BUCKET_NOT_SUPPORTED` before dispatch, and moost-db's `/meta` advertises
|
|
2923
|
+
* no bucketable field.
|
|
2924
|
+
*
|
|
2925
|
+
* A set rather than a boolean (like {@link nativeDefaultFns}) so a unit can
|
|
2926
|
+
* be adopted adapter by adapter. An adapter that returns a unit must group
|
|
2927
|
+
* by the bucket alias in `$groupBy` — see `controls.$select.buckets`
|
|
2928
|
+
* (`TResolvedBucket`: physical `field`, source `fd`) — and return the
|
|
2929
|
+
* `YYYY-MM-DD` label of the bucket's first local day (null for a null or
|
|
2930
|
+
* out-of-range source, uniqu's `bucketLabel` semantics). Since 0.1.132.
|
|
2931
|
+
*/
|
|
2932
|
+
calendarBucketUnits() {
|
|
2933
|
+
return EMPTY_BUCKET_UNITS;
|
|
2934
|
+
}
|
|
2935
|
+
/**
|
|
2630
2936
|
* Whether this adapter enforces foreign key constraints natively.
|
|
2631
2937
|
* When `true`, the generic layer skips application-level cascade/setNull
|
|
2632
2938
|
* on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
|
|
@@ -2648,6 +2954,9 @@ var BaseDbAdapter = class {
|
|
|
2648
2954
|
* Used by `AsDbReadableController.buildMetaResponse()` to gate the
|
|
2649
2955
|
* `filterable` flag exposed to UIs — the adapter's answer is a hard gate
|
|
2650
2956
|
* even when the field carries `@db.column.filterable`.
|
|
2957
|
+
*
|
|
2958
|
+
* Vetoes value comparison only — a sole-`$exists` entry needs just a stored
|
|
2959
|
+
* column (`canFilterLeaf`).
|
|
2651
2960
|
*/
|
|
2652
2961
|
canFilterField(fd) {
|
|
2653
2962
|
if (fd.encrypted) return false;
|
|
@@ -2774,11 +3083,18 @@ var BaseDbAdapter = class {
|
|
|
2774
3083
|
return [];
|
|
2775
3084
|
}
|
|
2776
3085
|
/**
|
|
2777
|
-
* Whether this adapter
|
|
2778
|
-
*
|
|
3086
|
+
* Whether this adapter can run TEXT search — `search()`, `searchWithCount()`
|
|
3087
|
+
* and the grouped `$search` path all gate on it. Vector capability is a
|
|
3088
|
+
* separate predicate, {@link isVectorSearchable}.
|
|
3089
|
+
*
|
|
3090
|
+
* Default: `true` when {@link getSearchIndexes} lists at least one non-vector
|
|
3091
|
+
* index. Vector entries are published there for the index picker, but a
|
|
3092
|
+
* vector index answers {@link vectorSearch} and nothing else, so counting one
|
|
3093
|
+
* here would claim a capability the adapter does not have. An entry without
|
|
3094
|
+
* `type` counts as text — adapters predating the field only listed text.
|
|
2779
3095
|
*/
|
|
2780
3096
|
isSearchable() {
|
|
2781
|
-
return this.getSearchIndexes().
|
|
3097
|
+
return this.getSearchIndexes().some((index) => index.type !== "vector");
|
|
2782
3098
|
}
|
|
2783
3099
|
/**
|
|
2784
3100
|
* Whether this adapter supports vector similarity search.
|
|
@@ -4336,4 +4652,4 @@ function isAtscriptDbView(readable) {
|
|
|
4336
4652
|
return readable.isView;
|
|
4337
4653
|
}
|
|
4338
4654
|
//#endregion
|
|
4339
|
-
export {
|
|
4655
|
+
export { FieldMappingStrategy as A, NoopLogger as B, guardPaths as C, unsupportedOperatorMessage as D, sortFieldNames as E, isGeoPointType as F, isBucketableField as I, isJsonValueField as L, TableMetadata as M, findAncestorInSet as N, RelationalFieldMapper as O, isGeoIndexableType as P, jsonValueAncestor as R, guardPath as S, narrowerFilterOps as T, checkHavingKeys as _, decomposePatch as a, guardAggregate as b, BaseDbAdapter as c, NativeIntegrity as d, AtscriptDbReadable as f, canFilterLeaf as g, assertGeoPoint as h, assertNoVersionWrites as i, UniquSelect as j, DocumentFieldMapper as k, createFailureCollector as l, acceptedOperatorsHint as m, isAtscriptDbView as n, ApplicationIntegrity as o, resolveDesignType as p, AtscriptDbTable as r, ALL_BUCKET_UNITS as s, AtscriptDbView as t, IntegrityStrategy as u, classifyQueryPath as v, guardQuery as w, guardFilter as x, collectQueryPaths as y, resolveCalendarBuckets as z };
|