@atscript/db 0.1.148 → 0.1.150
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.d.cts +1 -1
- package/dist/agg.d.mts +1 -1
- package/dist/{buckets-DJiYlMXc.d.cts → buckets-24Ac4sIS.d.cts} +80 -5
- package/dist/{buckets-CNdTOnei.d.mts → buckets-XOz0Bw5x.d.mts} +80 -5
- package/dist/{column-diff-CUU4GvYg.cjs → column-diff-CKKBVDvx.cjs} +646 -341
- package/dist/{column-diff-Cp6ZoyRE.mjs → column-diff-CKLDWBbo.mjs} +522 -253
- package/dist/{fk-diff-BQ4krij8.d.cts → fk-diff-CCLiJ9sJ.d.cts} +2 -1
- package/dist/{fk-diff-DsaIijVX.d.mts → fk-diff-R8eU7gq-.d.mts} +2 -1
- package/dist/index.cjs +10 -3
- package/dist/index.d.cts +66 -4
- package/dist/index.d.mts +66 -4
- package/dist/index.mjs +3 -3
- package/dist/plugin.cjs +143 -36
- package/dist/plugin.mjs +138 -31
- package/dist/rel.d.cts +2 -2
- package/dist/rel.d.mts +2 -2
- package/dist/{relation-helpers-kX7jjgME.d.cts → relation-helpers-DjVJN2ng.d.cts} +1 -1
- package/dist/{relation-helpers-Ba0v49sn.d.mts → relation-helpers-H7UEVkHs.d.mts} +1 -1
- package/dist/search-fields-DBIZKwyb.mjs +117 -0
- package/dist/search-fields-DwrsJUC1.cjs +158 -0
- package/dist/shared.cjs +3 -1
- package/dist/shared.d.cts +19 -1
- package/dist/shared.d.mts +19 -1
- package/dist/shared.mjs +2 -2
- 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/{validation-utils-Dq0uZ7ef.mjs → validation-utils-DqZAH6aT.mjs} +44 -2
- package/dist/{validation-utils-Da2GjobR.cjs → validation-utils-debIYpq0.cjs} +56 -2
- package/package.json +6 -6
- package/dist/derived-rules-0sKn4f5C.mjs +0 -44
- package/dist/derived-rules-YstgIxG-.cjs +0 -67
- package/dist/numeric-operand-B1jKH7x5.mjs +0 -21
- package/dist/numeric-operand-DKfiRLYp.cjs +0 -26
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
import { a as aggregateExpressionsNotSupported, l as spaceClosedError, n as CasMismatchError, r as DbError } from "./db-error-Az85UhTX.mjs";
|
|
2
2
|
import { a as SOURCE_VALUE_FNS, c as isFirstLast, o as SUPPORTED_AGGREGATE_FNS, r as BASE_AGGREGATE_FNS, t as AGG_ANNOTATIONS } from "./aggregate-fns-CyaZyb9I.mjs";
|
|
3
3
|
import { B as slicePerGroup, D as createRelationFilterHost, E as containsRelationFilter, F as compositeKey, H as computeOf, I as findFKEntryForRelation, J as walkViewExpr, L as findFKForRelation, M as relGuardState, P as resolveRelationFilterTree, R as findRemoteFK, U as computedOperands, V as tableNameOf, _ as remapDeleteFkViolation, a as batchPatchNestedFrom, b as sameKey, c as batchReplaceNestedFrom, d as checkDepthOverflow, f as planNestedFromVia, g as enrichFkViolation, h as validateBatch, i as batchInsertNestedVia, j as noteRelationFilter, k as hasRelationOp, l as batchReplaceNestedTo, m as preValidateNestedFrom, n as batchInsertNestedFrom, p as planPatchNestedTo, q as viewExprNullable, r as batchInsertNestedTo, s as batchPatchNestedVia, t as applyPatchNestedTo, u as batchReplaceNestedVia, v as pkTupleKey, x as uniqueKeyTuple, y as rowMatchesKey } from "./nested-writer-CUBoq1ZO.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { a as DERIVED_INCOMPATIBLE, n as searchMemberKind, r as numericTypeProblem, s as isJsonLeafType } from "./search-fields-DBIZKwyb.mjs";
|
|
5
5
|
import { a as isPlainObject, i as isEmptyObject, n as findAncestorInSet, o as selfOrAncestor, r as getPath, t as deletePath } from "./object-TkiJQ-Dp.mjs";
|
|
6
6
|
import { resolveAlias as resolveAlias$1 } from "./agg.mjs";
|
|
7
|
-
import { t as numericTypeProblem } from "./numeric-operand-B1jKH7x5.mjs";
|
|
8
7
|
import { t as NOT_DIMENSION_REASON } from "./aggregate-rules-jdPrxqWa.mjs";
|
|
9
|
-
import { separateCas, separateFieldOps } from "./ops.mjs";
|
|
8
|
+
import { isDbFieldOp, separateCas, separateFieldOps } from "./ops.mjs";
|
|
10
9
|
import { a as forceNavNonOptional, c as getKeyProps, i as dbPlugin, n as buildPatchPartial, t as buildDbValidator } from "./validator-BTiIOTKP.mjs";
|
|
11
10
|
import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
|
|
12
11
|
import { ARITH_MAX_NODES, BUCKET_UNITS, isAggregateExpr, isAggregateOfExpr, isBucketExpr, isPrimitive, isRelationOp, isSelectArithExpr, resolveAlias, resolveBuckets } from "@uniqu/core";
|
|
@@ -396,6 +395,10 @@ function isGeoIndexableType(fieldType) {
|
|
|
396
395
|
}
|
|
397
396
|
return false;
|
|
398
397
|
}
|
|
398
|
+
/** Runtime mirror of the `@db.column.version.exempt` placement errors. */
|
|
399
|
+
function rejectVersionExempt(path, why) {
|
|
400
|
+
throw new Error(`@db.column.version.exempt on "${path}": ${why}`);
|
|
401
|
+
}
|
|
399
402
|
/**
|
|
400
403
|
* Computed metadata for a database table or view.
|
|
401
404
|
*
|
|
@@ -443,6 +446,15 @@ var TableMetadata = class {
|
|
|
443
446
|
measures = [];
|
|
444
447
|
/** Logical field name annotated with `@db.column.version`, if any. */
|
|
445
448
|
versionField;
|
|
449
|
+
/** Paths annotated with `@db.column.version.exempt`, as declared (since 0.1.150). */
|
|
450
|
+
_versionExemptDeclared = /* @__PURE__ */ new Set();
|
|
451
|
+
/**
|
|
452
|
+
* Version-exempt paths (since 0.1.150): the declared ones plus every object
|
|
453
|
+
* whose direct children are all exempt (upward closure). A path is exempt
|
|
454
|
+
* when it, or an ancestor, is in this set — see {@link isVersionExemptPath}.
|
|
455
|
+
* Empty when the table declares no `@db.column.version`.
|
|
456
|
+
*/
|
|
457
|
+
versionExemptPaths = /* @__PURE__ */ new Set();
|
|
446
458
|
/** path → sibling-ref path for `@db.amount.currency.ref` / `@db.unit.ref`. */
|
|
447
459
|
quantityRefByField = /* @__PURE__ */ new Map();
|
|
448
460
|
/** Logical paths annotated with `@db.encrypted` — stored as one opaque ciphertext column. */
|
|
@@ -599,6 +611,7 @@ var TableMetadata = class {
|
|
|
599
611
|
...this.originalMetaIdFields
|
|
600
612
|
]);
|
|
601
613
|
this._finalizeIndexes();
|
|
614
|
+
this._finalizeVersionExempt();
|
|
602
615
|
this._collateMap.clear();
|
|
603
616
|
this._columnFromMap.clear();
|
|
604
617
|
this.jsonFields.clear();
|
|
@@ -700,6 +713,15 @@ var TableMetadata = class {
|
|
|
700
713
|
const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
|
|
701
714
|
this._addIndexField("unique", name, fieldName);
|
|
702
715
|
}
|
|
716
|
+
if (metadata.has("db.writeOnly") && (metadata.has("db.index.fulltext") || metadata.has("db.column.searchable"))) {
|
|
717
|
+
const which = metadata.has("db.index.fulltext") ? "@db.index.fulltext" : "@db.column.searchable";
|
|
718
|
+
throw new Error(`@db.writeOnly cannot coexist with ${which} on "${fieldName}" — search results would reveal the sealed value`);
|
|
719
|
+
}
|
|
720
|
+
const searchKind = searchMemberKind(fieldType);
|
|
721
|
+
if (typeof searchKind === "object") {
|
|
722
|
+
if (metadata.has("db.index.fulltext")) throw new Error(`@db.index.fulltext on "${fieldName}" ${searchKind.problem} — a fulltext member must be a string or an integer`);
|
|
723
|
+
if (metadata.has("db.column.searchable")) throw new Error(`@db.column.searchable on "${fieldName}" ${searchKind.problem} — a searchable column must be a string or an integer`);
|
|
724
|
+
}
|
|
703
725
|
for (const index of metadata.get("db.index.fulltext") || []) {
|
|
704
726
|
const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
|
|
705
727
|
const weight = index !== true && typeof index === "object" ? index?.weight : void 0;
|
|
@@ -727,6 +749,7 @@ var TableMetadata = class {
|
|
|
727
749
|
if (!hasExplicitIndex) this._addIndexField("plain", fieldName, fieldName);
|
|
728
750
|
}
|
|
729
751
|
if (metadata.has("db.column.measure")) this.measures.push(fieldName);
|
|
752
|
+
if (metadata.has("db.column.version.exempt")) this._versionExemptDeclared.add(fieldName);
|
|
730
753
|
if (metadata.has("db.column.version")) if (this.versionField !== void 0) logger.warn(`@db.column.version declared on multiple fields ("${this.versionField}" and "${fieldName}") — only one is allowed; using "${this.versionField}"`);
|
|
731
754
|
else {
|
|
732
755
|
this.versionField = fieldName;
|
|
@@ -737,6 +760,55 @@ var TableMetadata = class {
|
|
|
737
760
|
}
|
|
738
761
|
}
|
|
739
762
|
/**
|
|
763
|
+
* Validates the `@db.column.version.exempt` placements (E1–E5, the runtime
|
|
764
|
+
* mirror of the compile-time check, so pre-compiled models fail fast) and
|
|
765
|
+
* computes {@link versionExemptPaths} with its upward closure. Runs after
|
|
766
|
+
* `_applyOverrides` (the primary keys are final) and before `jsonFields` is
|
|
767
|
+
* released. A table without a version column ignores the annotation.
|
|
768
|
+
*/
|
|
769
|
+
_finalizeVersionExempt() {
|
|
770
|
+
if (this._versionExemptDeclared.size === 0) return;
|
|
771
|
+
for (const path of this._versionExemptDeclared) {
|
|
772
|
+
if (path === this.versionField) rejectVersionExempt(path, "cannot mark the version column itself");
|
|
773
|
+
if (this.primaryKeys.includes(path)) rejectVersionExempt(path, "a primary key identifies the row and is never patched");
|
|
774
|
+
if (this.navFields.has(path)) rejectVersionExempt(path, "a navigation field has no column here");
|
|
775
|
+
let pos = path.length;
|
|
776
|
+
while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
|
|
777
|
+
const ancestor = path.slice(0, pos);
|
|
778
|
+
const node = this.flatMap.get(ancestor);
|
|
779
|
+
if (node?.metadata.has("db.json")) rejectVersionExempt(path, `mark the @db.json field "${ancestor}" itself — a JSON column is written as one value`);
|
|
780
|
+
if (node?.type.kind === "array") rejectVersionExempt(path, `mark the array field "${ancestor}" itself — array elements are not separate columns`);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if (this.versionField === void 0) return;
|
|
784
|
+
const exempt = new Set(this._versionExemptDeclared);
|
|
785
|
+
const objects = [...this.flatMap.keys()].filter((p) => {
|
|
786
|
+
const node = this.flatMap.get(p);
|
|
787
|
+
return node.type.kind === "object" && !node.metadata.has("db.json");
|
|
788
|
+
}).toSorted((a, b) => b.split(".").length - a.split(".").length);
|
|
789
|
+
for (const obj of objects) {
|
|
790
|
+
if (exempt.has(obj)) continue;
|
|
791
|
+
const prefix = `${obj}.`;
|
|
792
|
+
let children = 0;
|
|
793
|
+
let all = true;
|
|
794
|
+
for (const key of this.flatMap.keys()) {
|
|
795
|
+
if (!key.startsWith(prefix) || key.indexOf(".", prefix.length) !== -1) continue;
|
|
796
|
+
if (this.navFields.has(key) || this.ignoredFields.has(key)) continue;
|
|
797
|
+
children++;
|
|
798
|
+
if (!exempt.has(key)) {
|
|
799
|
+
all = false;
|
|
800
|
+
break;
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
if (children > 0 && all) exempt.add(obj);
|
|
804
|
+
}
|
|
805
|
+
this.versionExemptPaths = exempt;
|
|
806
|
+
}
|
|
807
|
+
/** Whether `path` — or an ancestor of it — is version-exempt (since 0.1.150). */
|
|
808
|
+
isVersionExemptPath(path) {
|
|
809
|
+
return selfOrAncestor(path, this.versionExemptPaths) !== void 0;
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
740
812
|
* Build-time diagnostics for `@db.encrypted` (§6 of the field-encryption
|
|
741
813
|
* spec). Mirrors the compile-time AnnotationSpec validation so models built
|
|
742
814
|
* from pre-compiled types still fail fast.
|
|
@@ -1070,8 +1142,28 @@ var TableMetadata = class {
|
|
|
1070
1142
|
};
|
|
1071
1143
|
}
|
|
1072
1144
|
}
|
|
1145
|
+
/**
|
|
1146
|
+
* Flags the integer members of fulltext indexes (matched by exact number,
|
|
1147
|
+
* never part of the physical text index) and enforces that each is
|
|
1148
|
+
* index-backed — the equality branch must not scan. Runs while index field
|
|
1149
|
+
* names are still logical.
|
|
1150
|
+
*/
|
|
1151
|
+
_resolveIntegerFulltextMembers() {
|
|
1152
|
+
for (const index of this.indexes.values()) {
|
|
1153
|
+
if (index.type !== "fulltext") continue;
|
|
1154
|
+
for (const field of index.fields) {
|
|
1155
|
+
const ftype = this.flatMap.get(field.name);
|
|
1156
|
+
if (!ftype || searchMemberKind(ftype) !== "integer") continue;
|
|
1157
|
+
const where = `@db.index.fulltext on the integer field "${field.name}"`;
|
|
1158
|
+
if (findAncestorInSet(field.name, this.jsonFields) !== void 0) throw new Error(`${where} inside a JSON value is not supported`);
|
|
1159
|
+
if (!(field.name === "_id" || this.originalMetaIdFields[0] === field.name || this.uniqueProps.has(field.name) || [...this.indexes.values()].some((other) => (other.type === "plain" || other.type === "unique") && other.fields[0]?.name === field.name))) throw new Error(`${where} needs an index for its exact-number match — make it the primary key (first @meta.id) or the first field of a @db.index.plain / @db.index.unique`);
|
|
1160
|
+
field.integer = true;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1073
1164
|
_finalizeIndexes() {
|
|
1074
1165
|
for (const index of this.indexes.values()) if (index.type === "unique" && index.fields.length === 1) this.uniqueProps.add(index.fields[0].name);
|
|
1166
|
+
this._resolveIntegerFulltextMembers();
|
|
1075
1167
|
for (const index of this.indexes.values()) for (const field of index.fields) {
|
|
1076
1168
|
const ftype = this.flatMap.get(field.name);
|
|
1077
1169
|
if (ftype) {
|
|
@@ -1143,15 +1235,343 @@ var TableMetadata = class {
|
|
|
1143
1235
|
const selected = this.getIdentifications().find((id) => id.source !== "primaryKey" && (requestedName === void 0 || id.source === requestedName));
|
|
1144
1236
|
this.preferredId = selected ? [...selected.fields] : [...this.primaryKeys];
|
|
1145
1237
|
}
|
|
1146
|
-
};
|
|
1147
|
-
/** `TDbFieldMeta.computed` of a top-level `@db.compute` view field (since 0.1.147). */
|
|
1148
|
-
function computedMeta(rootType, path) {
|
|
1149
|
-
if (path.includes(".")) return void 0;
|
|
1150
|
-
const computed = computedOperands(rootType, path);
|
|
1151
|
-
return computed ? {
|
|
1152
|
-
operands: Object.freeze(computed.operands),
|
|
1153
|
-
via: Object.freeze(computed.via)
|
|
1154
|
-
} : void 0;
|
|
1238
|
+
};
|
|
1239
|
+
/** `TDbFieldMeta.computed` of a top-level `@db.compute` view field (since 0.1.147). */
|
|
1240
|
+
function computedMeta(rootType, path) {
|
|
1241
|
+
if (path.includes(".")) return void 0;
|
|
1242
|
+
const computed = computedOperands(rootType, path);
|
|
1243
|
+
return computed ? {
|
|
1244
|
+
operands: Object.freeze(computed.operands),
|
|
1245
|
+
via: Object.freeze(computed.via)
|
|
1246
|
+
} : void 0;
|
|
1247
|
+
}
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/shared/search-term.ts
|
|
1250
|
+
/**
|
|
1251
|
+
* Internal filter operator (since 0.1.150): a regular expression over the
|
|
1252
|
+
* DECIMAL TEXT of an integer column. The core rewrites a caller's `$regex`
|
|
1253
|
+
* (or bare `RegExp`) on an integer field into it at translation time;
|
|
1254
|
+
* callers can never send it (the filter value guard refuses it).
|
|
1255
|
+
*/
|
|
1256
|
+
const INTEGER_REGEX_OP = "$integerRegex";
|
|
1257
|
+
const INTEGER_TERM_RE = /^-?(?:0|[1-9][0-9]*)$/;
|
|
1258
|
+
/**
|
|
1259
|
+
* The whole number a `$search` term denotes, or `undefined` (since 0.1.150).
|
|
1260
|
+
* The trimmed term must be a plain integer literal — ASCII digits with an
|
|
1261
|
+
* optional leading `-`, no `+`, no leading zeros (except `0` itself), no
|
|
1262
|
+
* `-0`, no separators / decimals / exponent — within the safe-integer range.
|
|
1263
|
+
* Only the whole term counts (`"invoice 2946"` has no number).
|
|
1264
|
+
*/
|
|
1265
|
+
function searchTermInteger(term) {
|
|
1266
|
+
const t = term.trim();
|
|
1267
|
+
if (!INTEGER_TERM_RE.test(t) || t === "-0") return void 0;
|
|
1268
|
+
const n = Number(t);
|
|
1269
|
+
return Number.isSafeInteger(n) ? n : void 0;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Splits a fulltext index into the members that belong to the physical text
|
|
1273
|
+
* index and the integer members matched by exact number (since 0.1.150).
|
|
1274
|
+
*/
|
|
1275
|
+
function splitFulltextFields(index) {
|
|
1276
|
+
const text = [];
|
|
1277
|
+
const integer = [];
|
|
1278
|
+
for (const f of index.fields) (f.integer ? integer : text).push(f);
|
|
1279
|
+
return {
|
|
1280
|
+
text,
|
|
1281
|
+
integer
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* `/meta` description of a fulltext index: the adapter's text-index label over
|
|
1286
|
+
* the text members, then the exact-number members (since 0.1.150).
|
|
1287
|
+
*/
|
|
1288
|
+
function describeFulltext(index, textLabel) {
|
|
1289
|
+
const { text, integer } = splitFulltextFields(index);
|
|
1290
|
+
return [text.length > 0 ? textLabel(text.map((f) => f.name).join(", ")) : "", integer.map((f) => `exact number on ${f.name}`).join(" + ")].filter(Boolean).join(" + ");
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The fulltext index that answers a search naming none (since 0.1.150): the
|
|
1294
|
+
* first one with at least one TEXT member, else the first one (an index of
|
|
1295
|
+
* integer members only answers exact-number terms).
|
|
1296
|
+
*/
|
|
1297
|
+
function defaultFulltextIndex(indexes) {
|
|
1298
|
+
return indexes.find((i) => splitFulltextFields(i).text.length > 0) ?? indexes[0];
|
|
1299
|
+
}
|
|
1300
|
+
//#endregion
|
|
1301
|
+
//#region src/query/filter-values.ts
|
|
1302
|
+
const OPAQUE = {
|
|
1303
|
+
kinds: new Set(["any"]),
|
|
1304
|
+
timestamp: false
|
|
1305
|
+
};
|
|
1306
|
+
const NUMBER = {
|
|
1307
|
+
kinds: new Set(["number"]),
|
|
1308
|
+
timestamp: false
|
|
1309
|
+
};
|
|
1310
|
+
const INTEGER = {
|
|
1311
|
+
kinds: new Set(["integer"]),
|
|
1312
|
+
timestamp: false
|
|
1313
|
+
};
|
|
1314
|
+
const STRING = {
|
|
1315
|
+
kinds: new Set(["string"]),
|
|
1316
|
+
timestamp: false
|
|
1317
|
+
};
|
|
1318
|
+
function collectKinds(type, kinds, out, depth = 0) {
|
|
1319
|
+
const def = type?.type;
|
|
1320
|
+
const metadata = type?.metadata;
|
|
1321
|
+
if (!def || depth > 8) {
|
|
1322
|
+
kinds.add("any");
|
|
1323
|
+
return;
|
|
1324
|
+
}
|
|
1325
|
+
switch (def.kind) {
|
|
1326
|
+
case "": switch (def.designType) {
|
|
1327
|
+
case "string":
|
|
1328
|
+
kinds.add("string");
|
|
1329
|
+
return;
|
|
1330
|
+
case "number": {
|
|
1331
|
+
const timestamp = def.tags?.has("timestamp") === true;
|
|
1332
|
+
if (timestamp) out.timestamp = true;
|
|
1333
|
+
const integer = timestamp || def.tags?.has("int") === true || metadata?.has?.("expect.int") === true;
|
|
1334
|
+
kinds.add(integer ? "integer" : "number");
|
|
1335
|
+
return;
|
|
1336
|
+
}
|
|
1337
|
+
case "decimal":
|
|
1338
|
+
kinds.add("decimal");
|
|
1339
|
+
return;
|
|
1340
|
+
case "boolean":
|
|
1341
|
+
kinds.add("boolean");
|
|
1342
|
+
return;
|
|
1343
|
+
case "null":
|
|
1344
|
+
case "undefined":
|
|
1345
|
+
case "never": return;
|
|
1346
|
+
default:
|
|
1347
|
+
kinds.add("any");
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
case "union":
|
|
1351
|
+
for (const item of def.items ?? []) collectKinds(item, kinds, out, depth + 1);
|
|
1352
|
+
return;
|
|
1353
|
+
case "array":
|
|
1354
|
+
collectKinds(def.of, kinds, out, depth + 1);
|
|
1355
|
+
return;
|
|
1356
|
+
default: kinds.add("any");
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
const typeCache = /* @__PURE__ */ new WeakMap();
|
|
1360
|
+
/**
|
|
1361
|
+
* The value kinds a filter on `fd` accepts (cached per descriptor). A leaf
|
|
1362
|
+
* inside a JSON value (a `@db.json` object or an array, addressable on
|
|
1363
|
+
* nested-object adapters) is opaque: its contents are not schema-enforced.
|
|
1364
|
+
*/
|
|
1365
|
+
function valueTypeOf(meta, fd) {
|
|
1366
|
+
let vt = typeCache.get(fd);
|
|
1367
|
+
if (vt) return vt;
|
|
1368
|
+
if (jsonValueAncestor(fd.path, meta.jsonValueParents) !== void 0 || fd.encrypted || fd.isGeoPoint || fd.designType === "json" || fd.designType === "object" || !fd.type) vt = OPAQUE;
|
|
1369
|
+
else {
|
|
1370
|
+
const kinds = /* @__PURE__ */ new Set();
|
|
1371
|
+
const out = { timestamp: false };
|
|
1372
|
+
collectKinds(fd.type, kinds, out);
|
|
1373
|
+
const metadata = fd.type.metadata;
|
|
1374
|
+
if ((fd.defaultValue?.kind === "fn" && fd.defaultValue.fn !== "uuid" || metadata?.has?.("db.agg.count") === true || metadata?.has?.("db.agg.countDistinct") === true) && kinds.delete("number")) kinds.add("integer");
|
|
1375
|
+
vt = kinds.size === 0 ? OPAQUE : {
|
|
1376
|
+
kinds,
|
|
1377
|
+
timestamp: out.timestamp,
|
|
1378
|
+
integerText: searchMemberKind(fd.type) === "integer"
|
|
1379
|
+
};
|
|
1380
|
+
}
|
|
1381
|
+
typeCache.set(fd, vt);
|
|
1382
|
+
return vt;
|
|
1383
|
+
}
|
|
1384
|
+
/** Whether `$regex` on `fd` matches the decimal text of an integer (since 0.1.150). */
|
|
1385
|
+
function isIntegerTextField(meta, fd) {
|
|
1386
|
+
return valueTypeOf(meta, fd).integerText === true;
|
|
1387
|
+
}
|
|
1388
|
+
/** A decimal literal (`5`, `-1.5`, `.5`, `1e3`), surrounding blanks allowed — no hex, no `Infinity`. */
|
|
1389
|
+
const NUMERIC_RE = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
|
|
1390
|
+
/** An integer literal (`5`, `-12`), surrounding blanks allowed. */
|
|
1391
|
+
const INTEGER_RE = /^\s*[+-]?\d+\s*$/;
|
|
1392
|
+
function acceptsScalar(kind, value) {
|
|
1393
|
+
switch (kind) {
|
|
1394
|
+
case "any": return true;
|
|
1395
|
+
case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
|
|
1396
|
+
case "number":
|
|
1397
|
+
case "decimal": return typeof value === "number" && Number.isFinite(value) || typeof value === "bigint" || typeof value === "string" && NUMERIC_RE.test(value);
|
|
1398
|
+
case "integer": return typeof value === "number" && Number.isInteger(value) || typeof value === "bigint" || typeof value === "string" && INTEGER_RE.test(value);
|
|
1399
|
+
default: return typeof value === "boolean" || value === 0 || value === 1;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
/** The first element of `value` (itself when not an array) `vt` rejects, or `undefined`. */
|
|
1403
|
+
function rejectedValue(vt, value) {
|
|
1404
|
+
if (vt.kinds.has("any") || value === null || value === void 0) return void 0;
|
|
1405
|
+
if (Array.isArray(value)) {
|
|
1406
|
+
for (const item of value) {
|
|
1407
|
+
const bad = rejectedValue(vt, item);
|
|
1408
|
+
if (bad) return bad;
|
|
1409
|
+
}
|
|
1410
|
+
return;
|
|
1411
|
+
}
|
|
1412
|
+
if (typeof value === "object" && !isPlainObject(value)) return void 0;
|
|
1413
|
+
for (const kind of vt.kinds) if (acceptsScalar(kind, value)) return void 0;
|
|
1414
|
+
return { value };
|
|
1415
|
+
}
|
|
1416
|
+
const KIND_LABEL = {
|
|
1417
|
+
string: "a string",
|
|
1418
|
+
number: "a number",
|
|
1419
|
+
integer: "an integer",
|
|
1420
|
+
decimal: "a decimal (number or numeric string)",
|
|
1421
|
+
boolean: "a boolean"
|
|
1422
|
+
};
|
|
1423
|
+
function expectedOf(vt) {
|
|
1424
|
+
const labels = [];
|
|
1425
|
+
for (const kind of vt.kinds) {
|
|
1426
|
+
if (kind === "any") continue;
|
|
1427
|
+
labels.push(kind === "integer" && vt.timestamp ? "an integer (epoch milliseconds)" : KIND_LABEL[kind]);
|
|
1428
|
+
}
|
|
1429
|
+
return labels.join(" or ");
|
|
1430
|
+
}
|
|
1431
|
+
function describeValue(value) {
|
|
1432
|
+
if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
|
|
1433
|
+
if (isPlainObject(value)) return "an object";
|
|
1434
|
+
return String(value);
|
|
1435
|
+
}
|
|
1436
|
+
function valueError(path, op, message) {
|
|
1437
|
+
return new DbError("INVALID_QUERY", [{
|
|
1438
|
+
path,
|
|
1439
|
+
message: `Invalid filter value for "${path}"${op ? ` (${op})` : ""}: ${message}`
|
|
1440
|
+
}]);
|
|
1441
|
+
}
|
|
1442
|
+
function holdsStrings(vt) {
|
|
1443
|
+
return vt.kinds.has("string") || vt.kinds.has("any") || vt.integerText === true;
|
|
1444
|
+
}
|
|
1445
|
+
function checkRegex(path, vt, op, pattern) {
|
|
1446
|
+
if (!holdsStrings(vt)) throw valueError(path, op, `a pattern match needs a string or integer field, "${path}" holds ${expectedOf(vt)}`);
|
|
1447
|
+
if (typeof pattern !== "string" && !(pattern instanceof RegExp)) throw valueError(path, op, `expected a regular expression, got ${describeValue(pattern)}`);
|
|
1448
|
+
}
|
|
1449
|
+
function checkValue(path, vt, op, value) {
|
|
1450
|
+
if (value instanceof RegExp) {
|
|
1451
|
+
checkRegex(path, vt, op ?? "RegExp", value);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
const bad = rejectedValue(vt, value);
|
|
1455
|
+
if (bad) throw valueError(path, op, `expected ${expectedOf(vt)}, got ${describeValue(bad.value)}`);
|
|
1456
|
+
}
|
|
1457
|
+
/** Operators whose operand is compared with the field's values. */
|
|
1458
|
+
const COMPARE_OPS = new Set([
|
|
1459
|
+
"$eq",
|
|
1460
|
+
"$ne",
|
|
1461
|
+
"$gt",
|
|
1462
|
+
"$gte",
|
|
1463
|
+
"$lt",
|
|
1464
|
+
"$lte",
|
|
1465
|
+
"$in",
|
|
1466
|
+
"$nin"
|
|
1467
|
+
]);
|
|
1468
|
+
/** Checks one filter entry's value (a bare value or an operator map) against `vt`. */
|
|
1469
|
+
function checkEntry(path, vt, value) {
|
|
1470
|
+
if (vt.kinds.has("any")) return;
|
|
1471
|
+
if (!isPlainObject(value)) {
|
|
1472
|
+
checkValue(path, vt, void 0, value);
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
for (const [op, operand] of Object.entries(value)) if (op === "$regex") checkRegex(path, vt, op, operand);
|
|
1476
|
+
else if (COMPARE_OPS.has(op)) checkValue(path, vt, op, operand);
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Walks `filter` (through `$and` / `$or` / `$not`; relational predicates are
|
|
1480
|
+
* the related table's) and checks every entry whose key `typeOf` knows.
|
|
1481
|
+
*/
|
|
1482
|
+
function walkFilterValues(filter, typeOf) {
|
|
1483
|
+
if (!isPlainObject(filter)) return;
|
|
1484
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1485
|
+
if (key === "$and" || key === "$or") {
|
|
1486
|
+
if (Array.isArray(value)) for (const child of value) walkFilterValues(child, typeOf);
|
|
1487
|
+
continue;
|
|
1488
|
+
}
|
|
1489
|
+
if (key === "$not") {
|
|
1490
|
+
walkFilterValues(value, typeOf);
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
if (key.startsWith("$") || hasRelationOp(value)) continue;
|
|
1494
|
+
if (isPlainObject(value) && "$integerRegex" in value) throw valueError(key, INTEGER_REGEX_OP, "is internal — use $regex");
|
|
1495
|
+
const vt = typeOf(key);
|
|
1496
|
+
if (vt) checkEntry(key, vt, value);
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Rejects (`INVALID_QUERY`, `path` = the field) a filter value that cannot
|
|
1501
|
+
* denote its field's declared type — see the module notes for the accepted
|
|
1502
|
+
* forms. Unknown paths are skipped (the path guard owns them).
|
|
1503
|
+
*/
|
|
1504
|
+
function guardFilterValues(meta, filter) {
|
|
1505
|
+
if (!filter) return;
|
|
1506
|
+
walkFilterValues(filter, (key) => {
|
|
1507
|
+
const fd = meta.descriptorByPath.get(key);
|
|
1508
|
+
return fd ? valueTypeOf(meta, fd) : void 0;
|
|
1509
|
+
});
|
|
1510
|
+
}
|
|
1511
|
+
/**
|
|
1512
|
+
* `$having` values: an aggregate alias is a number (`count`,
|
|
1513
|
+
* `countDistinct`, `sum`, `avg`, an expression) or its source field's type
|
|
1514
|
+
* (`min` / `max`, `first` / `last`),
|
|
1515
|
+
* a calendar-bucket alias a string label, any other key a `$groupBy`
|
|
1516
|
+
* field's own type.
|
|
1517
|
+
*/
|
|
1518
|
+
function guardHavingValues(meta, controls) {
|
|
1519
|
+
if (!controls?.$having) return;
|
|
1520
|
+
const aliases = /* @__PURE__ */ new Map();
|
|
1521
|
+
if (Array.isArray(controls.$select)) {
|
|
1522
|
+
for (const item of controls.$select) if (isAggregateOfExpr(item) || isSelectArithExpr(item)) aliases.set(item.$as, NUMBER);
|
|
1523
|
+
else if (isAggregateExpr(item)) {
|
|
1524
|
+
const fd = SOURCE_VALUE_FNS.has(item.$fn) ? meta.descriptorByPath.get(item.$field) : void 0;
|
|
1525
|
+
const counts = item.$fn === "count" || item.$fn === "countDistinct";
|
|
1526
|
+
aliases.set(resolveAlias$1(item), fd ? valueTypeOf(meta, fd) : counts ? INTEGER : NUMBER);
|
|
1527
|
+
} else if (isBucketExpr(item)) aliases.set(resolveAlias$1(item), STRING);
|
|
1528
|
+
}
|
|
1529
|
+
walkFilterValues(controls.$having, (key) => {
|
|
1530
|
+
const alias = aliases.get(key);
|
|
1531
|
+
if (alias) return alias;
|
|
1532
|
+
const fd = meta.descriptorByPath.get(key);
|
|
1533
|
+
return fd ? valueTypeOf(meta, fd) : void 0;
|
|
1534
|
+
});
|
|
1535
|
+
}
|
|
1536
|
+
//#endregion
|
|
1537
|
+
//#region src/query/integer-regex.ts
|
|
1538
|
+
/** Rewrites one field entry; returns the same value when it has no pattern match. */
|
|
1539
|
+
function rewriteEntry(value) {
|
|
1540
|
+
if (value instanceof RegExp) return { [INTEGER_REGEX_OP]: value };
|
|
1541
|
+
if (isPlainObject(value) && "$regex" in value) {
|
|
1542
|
+
const out = {};
|
|
1543
|
+
for (const [op, operand] of Object.entries(value)) out[op === "$regex" ? INTEGER_REGEX_OP : op] = operand;
|
|
1544
|
+
return out;
|
|
1545
|
+
}
|
|
1546
|
+
return value;
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* `$regex` (and a bare `RegExp`) on integer search members →
|
|
1550
|
+
* {@link INTEGER_REGEX_OP}, on the LOGICAL filter. Copy-on-write: the very
|
|
1551
|
+
* same object comes back when nothing changed. Relational predicates are
|
|
1552
|
+
* rewritten by the related table when it translates them.
|
|
1553
|
+
*/
|
|
1554
|
+
function rewriteIntegerRegex(filter, meta) {
|
|
1555
|
+
if (!isPlainObject(filter)) return filter;
|
|
1556
|
+
let out;
|
|
1557
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
1558
|
+
let next = value;
|
|
1559
|
+
if (key === "$and" || key === "$or") {
|
|
1560
|
+
if (Array.isArray(value)) {
|
|
1561
|
+
const mapped = value.map((f) => rewriteIntegerRegex(f, meta));
|
|
1562
|
+
if (mapped.some((m, i) => m !== value[i])) next = mapped;
|
|
1563
|
+
}
|
|
1564
|
+
} else if (key === "$not") next = rewriteIntegerRegex(value, meta);
|
|
1565
|
+
else if (!key.startsWith("$") && !meta.navFields.has(key)) {
|
|
1566
|
+
const fd = meta.descriptorByPath.get(key);
|
|
1567
|
+
if (fd && isIntegerTextField(meta, fd)) next = rewriteEntry(value);
|
|
1568
|
+
}
|
|
1569
|
+
if (next !== value) {
|
|
1570
|
+
out ??= { ...filter };
|
|
1571
|
+
out[key] = next;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
return out ?? filter;
|
|
1155
1575
|
}
|
|
1156
1576
|
//#endregion
|
|
1157
1577
|
//#region src/query/aggregate-expr.ts
|
|
@@ -1787,6 +2207,7 @@ var FieldMappingStrategy = class {
|
|
|
1787
2207
|
* translate predicate operands at deeper levels).
|
|
1788
2208
|
*/
|
|
1789
2209
|
translateFilter(filter, meta, depth = 0) {
|
|
2210
|
+
filter = rewriteIntegerRegex(filter, meta);
|
|
1790
2211
|
const has = containsRelationFilter(filter);
|
|
1791
2212
|
const resolved = has ? resolveRelationFilterTree(filter, meta, depth) : filter;
|
|
1792
2213
|
return this.noteTranslated(filter, this.translateResolvedFilter(resolved, meta), has);
|
|
@@ -1921,7 +2342,8 @@ var FieldMappingStrategy = class {
|
|
|
1921
2342
|
if (!isPlainObject(value)) return fmt(value);
|
|
1922
2343
|
const ops = value;
|
|
1923
2344
|
const formatted = {};
|
|
1924
|
-
for (const [op, opVal] of Object.entries(ops)) if (
|
|
2345
|
+
for (const [op, opVal] of Object.entries(ops)) if (op === "$integerRegex") formatted[op] = opVal;
|
|
2346
|
+
else if ((op === "$in" || op === "$nin") && Array.isArray(opVal)) formatted[op] = opVal.map((v) => v === null || v === void 0 ? v : fmt(v));
|
|
1925
2347
|
else if (op.startsWith("$") && opVal !== null && opVal !== void 0) formatted[op] = fmt(opVal);
|
|
1926
2348
|
else formatted[op] = opVal;
|
|
1927
2349
|
return formatted;
|
|
@@ -2118,7 +2540,7 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
|
|
|
2118
2540
|
return result;
|
|
2119
2541
|
}
|
|
2120
2542
|
translateQuery(query, meta) {
|
|
2121
|
-
const logical = query.filter;
|
|
2543
|
+
const logical = rewriteIntegerRegex(query.filter, meta);
|
|
2122
2544
|
const has = containsRelationFilter(logical);
|
|
2123
2545
|
const filter = has ? resolveRelationFilterTree(logical, meta, 0) : logical;
|
|
2124
2546
|
if (!meta.requiresMappings) {
|
|
@@ -2294,236 +2716,6 @@ var RelationalFieldMapper = class extends FieldMappingStrategy {
|
|
|
2294
2716
|
}
|
|
2295
2717
|
};
|
|
2296
2718
|
//#endregion
|
|
2297
|
-
//#region src/query/filter-values.ts
|
|
2298
|
-
const OPAQUE = {
|
|
2299
|
-
kinds: new Set(["any"]),
|
|
2300
|
-
timestamp: false
|
|
2301
|
-
};
|
|
2302
|
-
const NUMBER = {
|
|
2303
|
-
kinds: new Set(["number"]),
|
|
2304
|
-
timestamp: false
|
|
2305
|
-
};
|
|
2306
|
-
const INTEGER = {
|
|
2307
|
-
kinds: new Set(["integer"]),
|
|
2308
|
-
timestamp: false
|
|
2309
|
-
};
|
|
2310
|
-
const STRING = {
|
|
2311
|
-
kinds: new Set(["string"]),
|
|
2312
|
-
timestamp: false
|
|
2313
|
-
};
|
|
2314
|
-
function collectKinds(type, kinds, out, depth = 0) {
|
|
2315
|
-
const def = type?.type;
|
|
2316
|
-
const metadata = type?.metadata;
|
|
2317
|
-
if (!def || depth > 8) {
|
|
2318
|
-
kinds.add("any");
|
|
2319
|
-
return;
|
|
2320
|
-
}
|
|
2321
|
-
switch (def.kind) {
|
|
2322
|
-
case "": switch (def.designType) {
|
|
2323
|
-
case "string":
|
|
2324
|
-
kinds.add("string");
|
|
2325
|
-
return;
|
|
2326
|
-
case "number": {
|
|
2327
|
-
const timestamp = def.tags?.has("timestamp") === true;
|
|
2328
|
-
if (timestamp) out.timestamp = true;
|
|
2329
|
-
const integer = timestamp || def.tags?.has("int") === true || metadata?.has?.("expect.int") === true;
|
|
2330
|
-
kinds.add(integer ? "integer" : "number");
|
|
2331
|
-
return;
|
|
2332
|
-
}
|
|
2333
|
-
case "decimal":
|
|
2334
|
-
kinds.add("decimal");
|
|
2335
|
-
return;
|
|
2336
|
-
case "boolean":
|
|
2337
|
-
kinds.add("boolean");
|
|
2338
|
-
return;
|
|
2339
|
-
case "null":
|
|
2340
|
-
case "undefined":
|
|
2341
|
-
case "never": return;
|
|
2342
|
-
default:
|
|
2343
|
-
kinds.add("any");
|
|
2344
|
-
return;
|
|
2345
|
-
}
|
|
2346
|
-
case "union":
|
|
2347
|
-
for (const item of def.items ?? []) collectKinds(item, kinds, out, depth + 1);
|
|
2348
|
-
return;
|
|
2349
|
-
case "array":
|
|
2350
|
-
collectKinds(def.of, kinds, out, depth + 1);
|
|
2351
|
-
return;
|
|
2352
|
-
default: kinds.add("any");
|
|
2353
|
-
}
|
|
2354
|
-
}
|
|
2355
|
-
const typeCache = /* @__PURE__ */ new WeakMap();
|
|
2356
|
-
/**
|
|
2357
|
-
* The value kinds a filter on `fd` accepts (cached per descriptor). A leaf
|
|
2358
|
-
* inside a JSON value (a `@db.json` object or an array, addressable on
|
|
2359
|
-
* nested-object adapters) is opaque: its contents are not schema-enforced.
|
|
2360
|
-
*/
|
|
2361
|
-
function valueTypeOf(meta, fd) {
|
|
2362
|
-
let vt = typeCache.get(fd);
|
|
2363
|
-
if (vt) return vt;
|
|
2364
|
-
if (jsonValueAncestor(fd.path, meta.jsonValueParents) !== void 0 || fd.encrypted || fd.isGeoPoint || fd.designType === "json" || fd.designType === "object" || !fd.type) vt = OPAQUE;
|
|
2365
|
-
else {
|
|
2366
|
-
const kinds = /* @__PURE__ */ new Set();
|
|
2367
|
-
const out = { timestamp: false };
|
|
2368
|
-
collectKinds(fd.type, kinds, out);
|
|
2369
|
-
const metadata = fd.type.metadata;
|
|
2370
|
-
if ((fd.defaultValue?.kind === "fn" && fd.defaultValue.fn !== "uuid" || metadata?.has?.("db.agg.count") === true || metadata?.has?.("db.agg.countDistinct") === true) && kinds.delete("number")) kinds.add("integer");
|
|
2371
|
-
vt = kinds.size === 0 ? OPAQUE : {
|
|
2372
|
-
kinds,
|
|
2373
|
-
timestamp: out.timestamp
|
|
2374
|
-
};
|
|
2375
|
-
}
|
|
2376
|
-
typeCache.set(fd, vt);
|
|
2377
|
-
return vt;
|
|
2378
|
-
}
|
|
2379
|
-
/** A decimal literal (`5`, `-1.5`, `.5`, `1e3`), surrounding blanks allowed — no hex, no `Infinity`. */
|
|
2380
|
-
const NUMERIC_RE = /^\s*[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?\s*$/i;
|
|
2381
|
-
/** An integer literal (`5`, `-12`), surrounding blanks allowed. */
|
|
2382
|
-
const INTEGER_RE = /^\s*[+-]?\d+\s*$/;
|
|
2383
|
-
function acceptsScalar(kind, value) {
|
|
2384
|
-
switch (kind) {
|
|
2385
|
-
case "any": return true;
|
|
2386
|
-
case "string": return typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint";
|
|
2387
|
-
case "number":
|
|
2388
|
-
case "decimal": return typeof value === "number" && Number.isFinite(value) || typeof value === "bigint" || typeof value === "string" && NUMERIC_RE.test(value);
|
|
2389
|
-
case "integer": return typeof value === "number" && Number.isInteger(value) || typeof value === "bigint" || typeof value === "string" && INTEGER_RE.test(value);
|
|
2390
|
-
default: return typeof value === "boolean" || value === 0 || value === 1;
|
|
2391
|
-
}
|
|
2392
|
-
}
|
|
2393
|
-
/** The first element of `value` (itself when not an array) `vt` rejects, or `undefined`. */
|
|
2394
|
-
function rejectedValue(vt, value) {
|
|
2395
|
-
if (vt.kinds.has("any") || value === null || value === void 0) return void 0;
|
|
2396
|
-
if (Array.isArray(value)) {
|
|
2397
|
-
for (const item of value) {
|
|
2398
|
-
const bad = rejectedValue(vt, item);
|
|
2399
|
-
if (bad) return bad;
|
|
2400
|
-
}
|
|
2401
|
-
return;
|
|
2402
|
-
}
|
|
2403
|
-
if (typeof value === "object" && !isPlainObject(value)) return void 0;
|
|
2404
|
-
for (const kind of vt.kinds) if (acceptsScalar(kind, value)) return void 0;
|
|
2405
|
-
return { value };
|
|
2406
|
-
}
|
|
2407
|
-
const KIND_LABEL = {
|
|
2408
|
-
string: "a string",
|
|
2409
|
-
number: "a number",
|
|
2410
|
-
integer: "an integer",
|
|
2411
|
-
decimal: "a decimal (number or numeric string)",
|
|
2412
|
-
boolean: "a boolean"
|
|
2413
|
-
};
|
|
2414
|
-
function expectedOf(vt) {
|
|
2415
|
-
const labels = [];
|
|
2416
|
-
for (const kind of vt.kinds) {
|
|
2417
|
-
if (kind === "any") continue;
|
|
2418
|
-
labels.push(kind === "integer" && vt.timestamp ? "an integer (epoch milliseconds)" : KIND_LABEL[kind]);
|
|
2419
|
-
}
|
|
2420
|
-
return labels.join(" or ");
|
|
2421
|
-
}
|
|
2422
|
-
function describeValue(value) {
|
|
2423
|
-
if (typeof value === "string") return JSON.stringify(value.length > 40 ? `${value.slice(0, 40)}…` : value);
|
|
2424
|
-
if (isPlainObject(value)) return "an object";
|
|
2425
|
-
return String(value);
|
|
2426
|
-
}
|
|
2427
|
-
function valueError(path, op, message) {
|
|
2428
|
-
return new DbError("INVALID_QUERY", [{
|
|
2429
|
-
path,
|
|
2430
|
-
message: `Invalid filter value for "${path}"${op ? ` (${op})` : ""}: ${message}`
|
|
2431
|
-
}]);
|
|
2432
|
-
}
|
|
2433
|
-
function holdsStrings(vt) {
|
|
2434
|
-
return vt.kinds.has("string") || vt.kinds.has("any");
|
|
2435
|
-
}
|
|
2436
|
-
function checkRegex(path, vt, op, pattern) {
|
|
2437
|
-
if (!holdsStrings(vt)) throw valueError(path, op, `a pattern match needs a string field, "${path}" holds ${expectedOf(vt)}`);
|
|
2438
|
-
if (typeof pattern !== "string" && !(pattern instanceof RegExp)) throw valueError(path, op, `expected a regular expression, got ${describeValue(pattern)}`);
|
|
2439
|
-
}
|
|
2440
|
-
function checkValue(path, vt, op, value) {
|
|
2441
|
-
if (value instanceof RegExp) {
|
|
2442
|
-
checkRegex(path, vt, op ?? "RegExp", value);
|
|
2443
|
-
return;
|
|
2444
|
-
}
|
|
2445
|
-
const bad = rejectedValue(vt, value);
|
|
2446
|
-
if (bad) throw valueError(path, op, `expected ${expectedOf(vt)}, got ${describeValue(bad.value)}`);
|
|
2447
|
-
}
|
|
2448
|
-
/** Operators whose operand is compared with the field's values. */
|
|
2449
|
-
const COMPARE_OPS = new Set([
|
|
2450
|
-
"$eq",
|
|
2451
|
-
"$ne",
|
|
2452
|
-
"$gt",
|
|
2453
|
-
"$gte",
|
|
2454
|
-
"$lt",
|
|
2455
|
-
"$lte",
|
|
2456
|
-
"$in",
|
|
2457
|
-
"$nin"
|
|
2458
|
-
]);
|
|
2459
|
-
/** Checks one filter entry's value (a bare value or an operator map) against `vt`. */
|
|
2460
|
-
function checkEntry(path, vt, value) {
|
|
2461
|
-
if (vt.kinds.has("any")) return;
|
|
2462
|
-
if (!isPlainObject(value)) {
|
|
2463
|
-
checkValue(path, vt, void 0, value);
|
|
2464
|
-
return;
|
|
2465
|
-
}
|
|
2466
|
-
for (const [op, operand] of Object.entries(value)) if (op === "$regex") checkRegex(path, vt, op, operand);
|
|
2467
|
-
else if (COMPARE_OPS.has(op)) checkValue(path, vt, op, operand);
|
|
2468
|
-
}
|
|
2469
|
-
/**
|
|
2470
|
-
* Walks `filter` (through `$and` / `$or` / `$not`; relational predicates are
|
|
2471
|
-
* the related table's) and checks every entry whose key `typeOf` knows.
|
|
2472
|
-
*/
|
|
2473
|
-
function walkFilterValues(filter, typeOf) {
|
|
2474
|
-
if (!isPlainObject(filter)) return;
|
|
2475
|
-
for (const [key, value] of Object.entries(filter)) {
|
|
2476
|
-
if (key === "$and" || key === "$or") {
|
|
2477
|
-
if (Array.isArray(value)) for (const child of value) walkFilterValues(child, typeOf);
|
|
2478
|
-
continue;
|
|
2479
|
-
}
|
|
2480
|
-
if (key === "$not") {
|
|
2481
|
-
walkFilterValues(value, typeOf);
|
|
2482
|
-
continue;
|
|
2483
|
-
}
|
|
2484
|
-
if (key.startsWith("$") || hasRelationOp(value)) continue;
|
|
2485
|
-
const vt = typeOf(key);
|
|
2486
|
-
if (vt) checkEntry(key, vt, value);
|
|
2487
|
-
}
|
|
2488
|
-
}
|
|
2489
|
-
/**
|
|
2490
|
-
* Rejects (`INVALID_QUERY`, `path` = the field) a filter value that cannot
|
|
2491
|
-
* denote its field's declared type — see the module notes for the accepted
|
|
2492
|
-
* forms. Unknown paths are skipped (the path guard owns them).
|
|
2493
|
-
*/
|
|
2494
|
-
function guardFilterValues(meta, filter) {
|
|
2495
|
-
if (!filter) return;
|
|
2496
|
-
walkFilterValues(filter, (key) => {
|
|
2497
|
-
const fd = meta.descriptorByPath.get(key);
|
|
2498
|
-
return fd ? valueTypeOf(meta, fd) : void 0;
|
|
2499
|
-
});
|
|
2500
|
-
}
|
|
2501
|
-
/**
|
|
2502
|
-
* `$having` values: an aggregate alias is a number (`count`,
|
|
2503
|
-
* `countDistinct`, `sum`, `avg`, an expression) or its source field's type
|
|
2504
|
-
* (`min` / `max`, `first` / `last`),
|
|
2505
|
-
* a calendar-bucket alias a string label, any other key a `$groupBy`
|
|
2506
|
-
* field's own type.
|
|
2507
|
-
*/
|
|
2508
|
-
function guardHavingValues(meta, controls) {
|
|
2509
|
-
if (!controls?.$having) return;
|
|
2510
|
-
const aliases = /* @__PURE__ */ new Map();
|
|
2511
|
-
if (Array.isArray(controls.$select)) {
|
|
2512
|
-
for (const item of controls.$select) if (isAggregateOfExpr(item) || isSelectArithExpr(item)) aliases.set(item.$as, NUMBER);
|
|
2513
|
-
else if (isAggregateExpr(item)) {
|
|
2514
|
-
const fd = SOURCE_VALUE_FNS.has(item.$fn) ? meta.descriptorByPath.get(item.$field) : void 0;
|
|
2515
|
-
const counts = item.$fn === "count" || item.$fn === "countDistinct";
|
|
2516
|
-
aliases.set(resolveAlias$1(item), fd ? valueTypeOf(meta, fd) : counts ? INTEGER : NUMBER);
|
|
2517
|
-
} else if (isBucketExpr(item)) aliases.set(resolveAlias$1(item), STRING);
|
|
2518
|
-
}
|
|
2519
|
-
walkFilterValues(controls.$having, (key) => {
|
|
2520
|
-
const alias = aliases.get(key);
|
|
2521
|
-
if (alias) return alias;
|
|
2522
|
-
const fd = meta.descriptorByPath.get(key);
|
|
2523
|
-
return fd ? valueTypeOf(meta, fd) : void 0;
|
|
2524
|
-
});
|
|
2525
|
-
}
|
|
2526
|
-
//#endregion
|
|
2527
2719
|
//#region src/query/query-guards.ts
|
|
2528
2720
|
/**
|
|
2529
2721
|
* Engine-agnostic query-time guards, applied in the core layer BEFORE filter
|
|
@@ -3595,6 +3787,17 @@ var AtscriptDbReadable = class {
|
|
|
3595
3787
|
if (field === void 0) return void 0;
|
|
3596
3788
|
return this._meta.columnMap.get(field) ?? field;
|
|
3597
3789
|
}
|
|
3790
|
+
/**
|
|
3791
|
+
* Logical paths of the `@db.column.version.exempt` fields, including objects
|
|
3792
|
+
* whose direct children are all exempt. A patch that writes only these (and
|
|
3793
|
+
* no `$cas`) does not bump the version column. Empty when the table has no
|
|
3794
|
+
* `@db.column.version`.
|
|
3795
|
+
* @since 0.1.150
|
|
3796
|
+
*/
|
|
3797
|
+
get versionExemptFields() {
|
|
3798
|
+
this._ensureBuilt();
|
|
3799
|
+
return this._meta.versionExemptPaths;
|
|
3800
|
+
}
|
|
3598
3801
|
/** Dimension fields from `@db.column.dimension`. */
|
|
3599
3802
|
get dimensions() {
|
|
3600
3803
|
this._ensureBuilt();
|
|
@@ -4661,6 +4864,16 @@ var BaseDbAdapter = class {
|
|
|
4661
4864
|
this._verbose = enabled;
|
|
4662
4865
|
}
|
|
4663
4866
|
/**
|
|
4867
|
+
* Physical version column an UPDATE must bump, or `undefined` when it must
|
|
4868
|
+
* not: no `@db.column.version`, or a version-exempt patch
|
|
4869
|
+
* (`opts.keepVersion`, since 0.1.150 — the core never combines it with
|
|
4870
|
+
* `expectedVersion`; a violation throws).
|
|
4871
|
+
*/
|
|
4872
|
+
_versionColumnFor(opts, expectedVersion) {
|
|
4873
|
+
if (!opts?.keepVersion) return this._table.versionColumnPhysical;
|
|
4874
|
+
if (expectedVersion !== void 0) throw new Error("keepVersion cannot combine with expectedVersion");
|
|
4875
|
+
}
|
|
4876
|
+
/**
|
|
4664
4877
|
* Logs a debug message if verbose mode is enabled.
|
|
4665
4878
|
* Adapters call this to log DB operations with zero overhead when disabled.
|
|
4666
4879
|
*/
|
|
@@ -5000,9 +5213,10 @@ var BaseDbAdapter = class {
|
|
|
5000
5213
|
*
|
|
5001
5214
|
* @param filter - Filter identifying the record to patch.
|
|
5002
5215
|
* @param patch - The patch payload with array operations.
|
|
5216
|
+
* @param opts - `keepVersion`: skip the version bump (see {@link TDbUpdateOptions}).
|
|
5003
5217
|
* @returns Update result.
|
|
5004
5218
|
*/
|
|
5005
|
-
async nativePatch(_filter, _patch, _ops, _expectedVersion) {
|
|
5219
|
+
async nativePatch(_filter, _patch, _ops, _expectedVersion, _opts) {
|
|
5006
5220
|
throw new Error("Native patch not supported by this adapter");
|
|
5007
5221
|
}
|
|
5008
5222
|
/**
|
|
@@ -5047,6 +5261,7 @@ var BaseDbAdapter = class {
|
|
|
5047
5261
|
continue;
|
|
5048
5262
|
}
|
|
5049
5263
|
if (opts.shouldSkipType?.(index.type)) continue;
|
|
5264
|
+
if (index.type === "fulltext" && !index.fields.some((f) => !f.integer)) continue;
|
|
5050
5265
|
desiredNames.add(index.key);
|
|
5051
5266
|
if (!existingNames.has(index.key)) {
|
|
5052
5267
|
await attempt(`create index "${index.key}"`, () => opts.createIndex(index));
|
|
@@ -5067,6 +5282,13 @@ var BaseDbAdapter = class {
|
|
|
5067
5282
|
/**
|
|
5068
5283
|
* Returns available search indexes for this adapter.
|
|
5069
5284
|
* UI uses this to show index picker. Override in adapters that support search.
|
|
5285
|
+
*
|
|
5286
|
+
* A fulltext index may carry integer members ({@link TDbIndexField.integer},
|
|
5287
|
+
* since 0.1.150): list them in `fields`, keep them out of the physical text
|
|
5288
|
+
* index, and OR `member = n` into the search predicate when
|
|
5289
|
+
* `searchTermInteger(text)` is defined. An index of integer members only
|
|
5290
|
+
* is still listed (it makes the table natively searchable); a term that is
|
|
5291
|
+
* not a whole number then matches nothing.
|
|
5070
5292
|
*/
|
|
5071
5293
|
getSearchIndexes() {
|
|
5072
5294
|
return [];
|
|
@@ -5112,7 +5334,9 @@ var BaseDbAdapter = class {
|
|
|
5112
5334
|
return false;
|
|
5113
5335
|
}
|
|
5114
5336
|
/**
|
|
5115
|
-
* Full-text search. Override in adapters that support search.
|
|
5337
|
+
* Full-text search. Override in adapters that support search. The text
|
|
5338
|
+
* match is OR'd with exact-number equality on the index's integer members
|
|
5339
|
+
* when the whole term is a whole number (see `searchTermInteger`).
|
|
5116
5340
|
*
|
|
5117
5341
|
* @param text - Search text.
|
|
5118
5342
|
* @param query - Filter, sort, limit, etc.
|
|
@@ -5696,6 +5920,8 @@ function matchByKeys(a, b, keys) {
|
|
|
5696
5920
|
}
|
|
5697
5921
|
//#endregion
|
|
5698
5922
|
//#region src/patch/patch-decomposer.ts
|
|
5923
|
+
/** Metadata tag the flattener puts on top-level array fields (shared with `isVersionExemptPatch`). */
|
|
5924
|
+
const TOP_LEVEL_ARRAY_TAG = "db.__topLevelArray";
|
|
5699
5925
|
/**
|
|
5700
5926
|
* Decomposes a patch payload into a flat update object for adapters
|
|
5701
5927
|
* that don't support native patch operations.
|
|
@@ -5714,7 +5940,7 @@ function matchByKeys(a, b, keys) {
|
|
|
5714
5940
|
*/
|
|
5715
5941
|
function decomposePatch(payload, table) {
|
|
5716
5942
|
const update = {};
|
|
5717
|
-
flattenPatchPayload(payload, "", update, table,
|
|
5943
|
+
flattenPatchPayload(payload, "", update, table, TOP_LEVEL_ARRAY_TAG);
|
|
5718
5944
|
return update;
|
|
5719
5945
|
}
|
|
5720
5946
|
function flattenPatchPayload(payload, prefix, update, table, topLevelArrayTag) {
|
|
@@ -5796,6 +6022,44 @@ function assertNoVersionWrites(data, versionColumn) {
|
|
|
5796
6022
|
}]);
|
|
5797
6023
|
}
|
|
5798
6024
|
//#endregion
|
|
6025
|
+
//#region src/patch/version-exempt.ts
|
|
6026
|
+
/**
|
|
6027
|
+
* Whether a logical (pre-decomposition) patch writes ONLY version-exempt
|
|
6028
|
+
* fields (`@db.column.version.exempt`). `data` has nav fields, filter keys and
|
|
6029
|
+
* derived fields removed and `$cas` separated. Mirrors `flattenPatchPayload`
|
|
6030
|
+
* (keep the two side by side): PK keys are skipped; an exempt path (or one
|
|
6031
|
+
* under an exempt ancestor) is covered whatever its value (scalar, field op,
|
|
6032
|
+
* array ops, object); a non-exempt merge-strategy object recurses (only the
|
|
6033
|
+
* supplied children are written); anything else is a non-exempt write.
|
|
6034
|
+
* True iff at least one key is covered and none is not.
|
|
6035
|
+
* @since 0.1.150
|
|
6036
|
+
*/
|
|
6037
|
+
function isVersionExemptPatch(data, table) {
|
|
6038
|
+
const meta = table.getMetadata();
|
|
6039
|
+
if (meta.versionExemptPaths.size === 0) return false;
|
|
6040
|
+
const walk = (obj, prefix) => {
|
|
6041
|
+
let result = "none";
|
|
6042
|
+
for (const [k, value] of Object.entries(obj)) {
|
|
6043
|
+
const key = prefix ? `${prefix}.${k}` : k;
|
|
6044
|
+
if (table.primaryKeys.includes(key)) continue;
|
|
6045
|
+
if (meta.isVersionExemptPath(key)) {
|
|
6046
|
+
result = "exempt";
|
|
6047
|
+
continue;
|
|
6048
|
+
}
|
|
6049
|
+
const flatType = table.flatMap.get(key);
|
|
6050
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && !isDbFieldOp(value) && !flatType?.metadata?.get("db.__topLevelArray") && !flatType?.metadata?.has("db.json") && flatType?.metadata?.get("db.patch.strategy") === "merge") {
|
|
6051
|
+
const inner = walk(value, key);
|
|
6052
|
+
if (inner === "versioned") return "versioned";
|
|
6053
|
+
if (inner === "exempt") result = "exempt";
|
|
6054
|
+
continue;
|
|
6055
|
+
}
|
|
6056
|
+
return "versioned";
|
|
6057
|
+
}
|
|
6058
|
+
return result;
|
|
6059
|
+
};
|
|
6060
|
+
return walk(data, "") === "exempt";
|
|
6061
|
+
}
|
|
6062
|
+
//#endregion
|
|
5799
6063
|
//#region src/table/db-table.ts
|
|
5800
6064
|
/** Returns true when `value` is a plain object carrying any `$`-prefixed key (an operator object). */
|
|
5801
6065
|
function _hasOperatorKeys(value) {
|
|
@@ -6003,6 +6267,8 @@ function concurrentChange() {
|
|
|
6003
6267
|
message: "The record changed during the write — nothing was written, retry"
|
|
6004
6268
|
}]);
|
|
6005
6269
|
}
|
|
6270
|
+
/** Hand-off for a version-exempt patch (since 0.1.150) — shared, never mutated. */
|
|
6271
|
+
const KEEP_VERSION = Object.freeze({ keepVersion: true });
|
|
6006
6272
|
/** Upper bound of keys per `touchMany` UPDATE statement (parameter-count safety). */
|
|
6007
6273
|
const TOUCH_MANY_CHUNK = 500;
|
|
6008
6274
|
/** `touchMany` input rejection — always `INVALID_QUERY`, path names the key. */
|
|
@@ -6291,12 +6557,13 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
6291
6557
|
if (!matched[i] && _ownedBy?.strict?.[i]) throw concurrentChange();
|
|
6292
6558
|
continue;
|
|
6293
6559
|
}
|
|
6560
|
+
const updateOpts = expectedVersion === void 0 && versionColumn !== void 0 && isVersionExemptPatch(data, this) ? [KEEP_VERSION] : [];
|
|
6294
6561
|
let result;
|
|
6295
6562
|
if (this.adapter.supportsNativePatch()) {
|
|
6296
6563
|
const ops = separateFieldOps(data);
|
|
6297
6564
|
const translatedOps = ops ? this._fieldMapper.translateOpsKeys(ops, this._meta) : void 0;
|
|
6298
6565
|
const translatedData = this._fieldMapper.translatePatchKeys(data, this._meta);
|
|
6299
|
-
result = await this.adapter.nativePatch(translatedFilter, translatedData, translatedOps, expectedVersion);
|
|
6566
|
+
result = await this.adapter.nativePatch(translatedFilter, translatedData, translatedOps, expectedVersion, ...updateOpts);
|
|
6300
6567
|
} else {
|
|
6301
6568
|
const update = decomposePatch(data, this);
|
|
6302
6569
|
const ops = separateFieldOps(update);
|
|
@@ -6307,8 +6574,8 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
6307
6574
|
filter: translatedFilter,
|
|
6308
6575
|
controls: {}
|
|
6309
6576
|
}), this);
|
|
6310
|
-
result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps, expectedVersion);
|
|
6311
|
-
} else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps, expectedVersion);
|
|
6577
|
+
result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps, expectedVersion, ...updateOpts);
|
|
6578
|
+
} else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps, expectedVersion, ...updateOpts);
|
|
6312
6579
|
}
|
|
6313
6580
|
modifiedCount += result.modifiedCount;
|
|
6314
6581
|
matched[i] = result.matchedCount > 0;
|
|
@@ -6455,6 +6722,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
6455
6722
|
message: "$cas is not supported on updateMany — use bulkUpdate with per-row $cas for version-locked batch updates"
|
|
6456
6723
|
}]);
|
|
6457
6724
|
if (versionColumn !== void 0) assertNoVersionWrites(dataCopy, versionColumn);
|
|
6725
|
+
const updateOpts = versionColumn !== void 0 && isVersionExemptPatch(dataCopy, this) ? [KEEP_VERSION] : [];
|
|
6458
6726
|
await this._encryptItems([dataCopy], "patch");
|
|
6459
6727
|
const update = decomposePatch(dataCopy, this);
|
|
6460
6728
|
const ops = separateFieldOps(update);
|
|
@@ -6468,7 +6736,7 @@ var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
|
6468
6736
|
}),
|
|
6469
6737
|
modifiedCount: 0
|
|
6470
6738
|
};
|
|
6471
|
-
return enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps));
|
|
6739
|
+
return enrichFkViolation(this._meta, () => this.adapter.updateMany(translatedFilter, translatedUpdate, translatedOps, ...updateOpts));
|
|
6472
6740
|
}
|
|
6473
6741
|
async replaceMany(filter, data) {
|
|
6474
6742
|
this._ensureBuilt();
|
|
@@ -7540,7 +7808,8 @@ function computeTableSnapshot(readable, typeMapper, tableOptions) {
|
|
|
7540
7808
|
type: idx.type,
|
|
7541
7809
|
fields: idx.fields.map((f) => ({
|
|
7542
7810
|
name: f.name,
|
|
7543
|
-
sort: f.sort
|
|
7811
|
+
sort: f.sort,
|
|
7812
|
+
...f.integer ? { integer: true } : {}
|
|
7544
7813
|
}))
|
|
7545
7814
|
})).toSorted((a, b) => a.key.localeCompare(b.key));
|
|
7546
7815
|
const foreignKeys = [...readable.foreignKeys.values()].map((fk) => ({
|
|
@@ -7847,4 +8116,4 @@ function computeColumnDiff(desired, existing, typeMapper, opts) {
|
|
|
7847
8116
|
return diff;
|
|
7848
8117
|
}
|
|
7849
8118
|
//#endregion
|
|
7850
|
-
export {
|
|
8119
|
+
export { DocumentFieldMapper as $, resolveDesignType as A, checkHavingKeys as B, ALL_BUCKET_UNITS as C, IntegrityStrategy as D, createFailureCollector as E, ENCRYPTED_REASON as F, guardFilter as G, collectQueryPaths as H, acceptedOperatorsHint as I, guardQuery as J, guardPath as K, assertGeoPoint as L, searchIndexNotFoundMessage as M, vectorIndexNotFoundMessage as N, NativeIntegrity as O, ADAPTER_FILTER_REASON as P, RelationalFieldMapper as Q, bucketSourceVerdict as R, ApplicationIntegrity as S, BaseDbAdapter as T, groupSourceVerdict as U, classifyQueryPath as V, guardAggregate as W, sortFieldNames as X, narrowerFilterOps as Y, unsupportedOperatorMessage as Z, isViewType as _, NoopLogger as _t, computeTableHash as a, describeFulltext as at, assertNoVersionWrites as b, snapshotToExistingColumns as c, TableMetadata as ct, computeForeignKeyDiff as d, aliasTargetOf as dt, FieldMappingStrategy as et, fkColumns as f, isBucketableField as ft, isAtscriptDbView as g, resolveCalendarBuckets as gt, AtscriptDbView as h, normalizeComputedSelect as ht, computeSchemaHash as i, defaultFulltextIndex as it, geoIndexNotFoundMessage as j, AtscriptDbReadable as k, snapshotToExistingTableOptions as l, isGeoIndexableType as lt, hasForeignKeyChanges as m, jsonValueAncestor as mt, isColumnTypeChanged as n, numericOperandProblem as nt, computeTableSnapshot as o, searchTermInteger as ot, fkKey as p, isJsonValueField as pt, guardPaths as q, canonicalizeQueryNode as r, INTEGER_REGEX_OP as rt, computeViewSnapshot as s, splitFulltextFields as st, computeColumnDiff as t, UniquSelect as tt, viewSnapshotSources as u, isGeoPointType as ut, AtscriptDbTable as v, ALL_VIEW_CAPABILITIES as w, decomposePatch as x, isVersionExemptPatch as y, canFilterLeaf as z };
|