@atscript/db 0.1.38 → 0.1.40
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -303
- package/dist/agg.cjs +8 -3
- package/dist/agg.d.cts +7 -0
- package/dist/agg.d.mts +7 -0
- package/dist/agg.mjs +7 -3
- package/dist/control-DRgryKeg.cjs +14 -0
- package/dist/{control_as-bjmwe24C.mjs → control-IANbnfjG.mjs} +6 -18
- package/dist/db-readable-BQQzfguJ.d.cts +1249 -0
- package/dist/db-readable-Bbr4CjMb.d.mts +1249 -0
- package/dist/db-space-BUrQ5BFm.d.mts +309 -0
- package/dist/db-space-Vxpcnyt5.d.cts +309 -0
- package/dist/db-validator-plugin-07kDiis2.d.cts +22 -0
- package/dist/db-validator-plugin-CiqsHTI_.d.mts +22 -0
- package/dist/db-view-BntnAmXO.cjs +3071 -0
- package/dist/db-view-ZsoN91-q.mjs +2970 -0
- package/dist/index.cjs +95 -2801
- package/dist/index.d.cts +137 -0
- package/dist/index.d.mts +137 -0
- package/dist/index.mjs +55 -2761
- package/dist/{nested-writer-BkqL7cp3.cjs → nested-writer-BDXsDMPP.cjs} +196 -150
- package/dist/{nested-writer-NEN51mnR.mjs → nested-writer-Dmm1gbZV.mjs} +118 -70
- package/dist/ops-BdRAFLKY.d.mts +67 -0
- package/dist/ops-DXJ4Zw0P.d.cts +67 -0
- package/dist/ops.cjs +123 -0
- package/dist/ops.d.cts +2 -0
- package/dist/ops.d.mts +2 -0
- package/dist/ops.mjs +112 -0
- package/dist/plugin.cjs +90 -109
- package/dist/plugin.d.cts +6 -0
- package/dist/plugin.d.mts +6 -0
- package/dist/plugin.mjs +29 -49
- package/dist/rel.cjs +20 -20
- package/dist/rel.d.cts +119 -0
- package/dist/rel.d.mts +119 -0
- package/dist/rel.mjs +4 -5
- package/dist/{relation-helpers-guFL_oRf.cjs → relation-helpers-BYvsE1tR.cjs} +26 -22
- package/dist/{relation-helpers-DyBIlQnB.mjs → relation-helpers-CLasawQq.mjs} +11 -6
- package/dist/{relation-loader-Dv7qXYq7.mjs → relation-loader-BEOTXNcq.mjs} +63 -43
- package/dist/{relation-loader-CpnDRf9k.cjs → relation-loader-CRC5LcqM.cjs} +74 -49
- package/dist/shared.cjs +13 -13
- package/dist/{shared.d.ts → shared.d.cts} +14 -13
- package/dist/shared.d.mts +71 -0
- package/dist/shared.mjs +2 -3
- package/dist/sync.cjs +300 -252
- package/dist/sync.d.cts +369 -0
- package/dist/sync.d.mts +369 -0
- package/dist/sync.mjs +284 -233
- package/dist/{validation-utils-DEoCMmEb.cjs → validation-utils-DVJDijnB.cjs} +141 -109
- package/dist/{validation-utils-DhR_mtKa.mjs → validation-utils-DhjIjP1-.mjs} +71 -37
- package/package.json +31 -30
- package/LICENSE +0 -21
- package/dist/agg-BJFJ3dFQ.mjs +0 -8
- package/dist/agg-DnUWAOK8.cjs +0 -14
- package/dist/agg.d.ts +0 -13
- package/dist/chunk-CrpGerW8.cjs +0 -31
- package/dist/control_as-BFPERAF_.cjs +0 -28
- package/dist/index.d.ts +0 -1706
- package/dist/logger-B7oxCfLQ.mjs +0 -12
- package/dist/logger-Dt2v_-wb.cjs +0 -18
- package/dist/plugin.d.ts +0 -5
- package/dist/rel.d.ts +0 -1305
- package/dist/relation-loader-D4mTw6yH.cjs +0 -4
- package/dist/relation-loader-Ggy1ujwR.mjs +0 -4
- package/dist/sync.d.ts +0 -1878
|
@@ -0,0 +1,2970 @@
|
|
|
1
|
+
import { a as batchPatchNestedTo, c as batchReplaceNestedTo, d as preValidateNestedFrom, f as validateBatch, h as DbError, 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-Dmm1gbZV.mjs";
|
|
2
|
+
import { resolveAlias } from "./agg.mjs";
|
|
3
|
+
import { n as findRemoteFK, t as findFKForRelation } from "./relation-helpers-CLasawQq.mjs";
|
|
4
|
+
import { isDbFieldOp, separateFieldOps } from "./ops.mjs";
|
|
5
|
+
import { flattenAnnotatedType, isAnnotatedType } from "@atscript/typescript/utils";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
//#region src/logger.ts
|
|
8
|
+
const NoopLogger = {
|
|
9
|
+
error: () => {},
|
|
10
|
+
warn: () => {},
|
|
11
|
+
log: () => {},
|
|
12
|
+
info: () => {},
|
|
13
|
+
debug: () => {}
|
|
14
|
+
};
|
|
15
|
+
//#endregion
|
|
16
|
+
//#region src/table/table-metadata.ts
|
|
17
|
+
const INDEX_PREFIX = "atscript__";
|
|
18
|
+
function indexKey(type, name) {
|
|
19
|
+
return `${INDEX_PREFIX}${type}__${name.replace(/[^a-z0-9_.-]/gi, "_").replace(/_+/g, "_").slice(0, 117 - type.length - 2)}`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Finds the nearest ancestor of `path` that belongs to `set`.
|
|
23
|
+
* Used by both the build pipeline (in `_classifyFields`) and
|
|
24
|
+
* runtime reconstruction on the Readable.
|
|
25
|
+
*/
|
|
26
|
+
function findAncestorInSet(path, set) {
|
|
27
|
+
let pos = path.length;
|
|
28
|
+
while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
|
|
29
|
+
const ancestor = path.slice(0, pos);
|
|
30
|
+
if (set.has(ancestor)) return ancestor;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** Returns true if `metadata` indicates a navigation relation field. */
|
|
34
|
+
function isNavRelation(metadata) {
|
|
35
|
+
return metadata.has("db.rel.to") || metadata.has("db.rel.from") || metadata.has("db.rel.via");
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Computed metadata for a database table or view.
|
|
39
|
+
*
|
|
40
|
+
* Contains all field metadata, physical mapping indexes, relation definitions,
|
|
41
|
+
* and constraint information derived from Atscript annotations. Built lazily
|
|
42
|
+
* on first access via {@link build}, then immutable.
|
|
43
|
+
*
|
|
44
|
+
* This class owns the build pipeline that was previously part of
|
|
45
|
+
* `AtscriptDbReadable._flatten()`. The Readable delegates all metadata
|
|
46
|
+
* access to this class.
|
|
47
|
+
*/
|
|
48
|
+
var TableMetadata = class {
|
|
49
|
+
nestedObjects;
|
|
50
|
+
flatMap;
|
|
51
|
+
fieldDescriptors;
|
|
52
|
+
primaryKeys = [];
|
|
53
|
+
originalMetaIdFields = [];
|
|
54
|
+
indexes = /* @__PURE__ */ new Map();
|
|
55
|
+
foreignKeys = /* @__PURE__ */ new Map();
|
|
56
|
+
relations = /* @__PURE__ */ new Map();
|
|
57
|
+
navFields = /* @__PURE__ */ new Set();
|
|
58
|
+
ignoredFields = /* @__PURE__ */ new Set();
|
|
59
|
+
uniqueProps = /* @__PURE__ */ new Set();
|
|
60
|
+
defaults = /* @__PURE__ */ new Map();
|
|
61
|
+
columnMap = /* @__PURE__ */ new Map();
|
|
62
|
+
dimensions = [];
|
|
63
|
+
measures = [];
|
|
64
|
+
pathToPhysical = /* @__PURE__ */ new Map();
|
|
65
|
+
physicalToPath = /* @__PURE__ */ new Map();
|
|
66
|
+
flattenedParents = /* @__PURE__ */ new Set();
|
|
67
|
+
jsonFields = /* @__PURE__ */ new Set();
|
|
68
|
+
selectExpansion = /* @__PURE__ */ new Map();
|
|
69
|
+
booleanFields = /* @__PURE__ */ new Set();
|
|
70
|
+
decimalFields = /* @__PURE__ */ new Set();
|
|
71
|
+
allPhysicalFields = [];
|
|
72
|
+
requiresMappings = false;
|
|
73
|
+
toStorageFormatters;
|
|
74
|
+
fromStorageFormatters;
|
|
75
|
+
/** Leaf field descriptors indexed by physical column name (read path). */
|
|
76
|
+
leafByPhysical = /* @__PURE__ */ new Map();
|
|
77
|
+
/** Leaf field descriptors indexed by logical path (write/patch/filter paths). */
|
|
78
|
+
leafByLogical = /* @__PURE__ */ new Map();
|
|
79
|
+
_built = false;
|
|
80
|
+
_collateMap = /* @__PURE__ */ new Map();
|
|
81
|
+
_columnFromMap = /* @__PURE__ */ new Map();
|
|
82
|
+
constructor(nestedObjects) {
|
|
83
|
+
this.nestedObjects = nestedObjects;
|
|
84
|
+
}
|
|
85
|
+
get isBuilt() {
|
|
86
|
+
return this._built;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Runs the full metadata compilation pipeline. Called once by
|
|
90
|
+
* `AtscriptDbReadable._ensureBuilt()` on first metadata access.
|
|
91
|
+
*
|
|
92
|
+
* Pipeline steps:
|
|
93
|
+
* 1. `adapter.onBeforeFlatten(type)` — adapter hook
|
|
94
|
+
* 2. `flattenAnnotatedType()` — collect field tuples, detect nav fields eagerly
|
|
95
|
+
* 3. Replay non-nav-descendant tuples through annotation scanning + adapter.onFieldScanned
|
|
96
|
+
* 4. Classify fields and build path maps (skipped for nested-objects adapters)
|
|
97
|
+
* 5. `adapter.getMetadataOverrides()` → `_applyOverrides()` (PK/unique/inject adjustments)
|
|
98
|
+
* 6. Build field descriptors (TDbFieldMeta[])
|
|
99
|
+
* 7. Build leaf field indexes (skipped for nested-objects adapters)
|
|
100
|
+
* 8. Finalize indexes (resolve field names to physical)
|
|
101
|
+
* 9. `adapter.onAfterFlatten()` — adapter hook (read-only bookkeeping)
|
|
102
|
+
* 10. Build allPhysicalFields list
|
|
103
|
+
*/
|
|
104
|
+
build(type, adapter, logger) {
|
|
105
|
+
if (this._built) return;
|
|
106
|
+
adapter.onBeforeFlatten?.(type);
|
|
107
|
+
const collected = [];
|
|
108
|
+
this.flatMap = flattenAnnotatedType(type, {
|
|
109
|
+
topLevelArrayTag: adapter.getTopLevelArrayTag?.() ?? "db.__topLevelArray",
|
|
110
|
+
excludePhantomTypes: true,
|
|
111
|
+
onField: (path, fieldType, metadata) => {
|
|
112
|
+
if (isNavRelation(metadata)) this.navFields.add(path);
|
|
113
|
+
collected.push({
|
|
114
|
+
path,
|
|
115
|
+
type: fieldType,
|
|
116
|
+
metadata
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
for (const entry of collected) {
|
|
121
|
+
if (findAncestorInSet(entry.path, this.navFields) !== void 0) continue;
|
|
122
|
+
this._scanGenericAnnotations(entry.path, entry.type, entry.metadata, logger);
|
|
123
|
+
adapter.onFieldScanned?.(entry.path, entry.type, entry.metadata);
|
|
124
|
+
}
|
|
125
|
+
if (!this.nestedObjects) this._classifyFields();
|
|
126
|
+
const overrides = adapter.getMetadataOverrides?.(this);
|
|
127
|
+
if (overrides) this._applyOverrides(overrides);
|
|
128
|
+
this._buildFieldDescriptors(adapter);
|
|
129
|
+
if (!this.nestedObjects) this._buildLeafIndexes();
|
|
130
|
+
this._finalizeIndexes();
|
|
131
|
+
this._collateMap.clear();
|
|
132
|
+
this._columnFromMap.clear();
|
|
133
|
+
this.jsonFields.clear();
|
|
134
|
+
this._built = true;
|
|
135
|
+
adapter.onAfterFlatten?.();
|
|
136
|
+
if (this.nestedObjects && this.flatMap) {
|
|
137
|
+
for (const path of this.flatMap.keys()) if (path && !this.ignoredFields.has(path)) this.allPhysicalFields.push(path);
|
|
138
|
+
} else for (const physical of this.pathToPhysical.values()) this.allPhysicalFields.push(physical);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Applies adapter-provided metadata overrides atomically.
|
|
142
|
+
* Processing order: injectFields → removePrimaryKeys → addPrimaryKeys → addUniqueFields.
|
|
143
|
+
*/
|
|
144
|
+
_applyOverrides(overrides) {
|
|
145
|
+
if (overrides.injectFields) for (const { path, type } of overrides.injectFields) this.flatMap.set(path, type);
|
|
146
|
+
if (overrides.removePrimaryKeys) for (const field of overrides.removePrimaryKeys) {
|
|
147
|
+
const idx = this.primaryKeys.indexOf(field);
|
|
148
|
+
if (idx >= 0) this.primaryKeys.splice(idx, 1);
|
|
149
|
+
}
|
|
150
|
+
if (overrides.addPrimaryKeys) {
|
|
151
|
+
for (const field of overrides.addPrimaryKeys) if (!this.primaryKeys.includes(field)) this.primaryKeys.push(field);
|
|
152
|
+
}
|
|
153
|
+
if (overrides.addUniqueFields) for (const field of overrides.addUniqueFields) this.uniqueProps.add(field);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Scans `@db.*` and `@meta.id` annotations on a field during flattening.
|
|
157
|
+
*/
|
|
158
|
+
_scanGenericAnnotations(fieldName, fieldType, metadata, logger) {
|
|
159
|
+
if (metadata.has("meta.id")) {
|
|
160
|
+
this.primaryKeys.push(fieldName);
|
|
161
|
+
this.originalMetaIdFields.push(fieldName);
|
|
162
|
+
}
|
|
163
|
+
const column = metadata.get("db.column");
|
|
164
|
+
if (column) this.columnMap.set(fieldName, column);
|
|
165
|
+
const columnFrom = metadata.get("db.column.renamed");
|
|
166
|
+
if (columnFrom) this._columnFromMap.set(fieldName, columnFrom);
|
|
167
|
+
const resolvedDefault = resolveDefaultFromMetadata(metadata);
|
|
168
|
+
if (resolvedDefault) this.defaults.set(fieldName, resolvedDefault);
|
|
169
|
+
if (metadata.has("db.ignore")) this.ignoredFields.add(fieldName);
|
|
170
|
+
if (isNavRelation(metadata)) {
|
|
171
|
+
this.navFields.add(fieldName);
|
|
172
|
+
this.ignoredFields.add(fieldName);
|
|
173
|
+
const direction = metadata.has("db.rel.to") ? "to" : metadata.has("db.rel.from") ? "from" : "via";
|
|
174
|
+
const raw = direction === "via" ? metadata.get("db.rel.via") : metadata.get(`db.rel.${direction}`);
|
|
175
|
+
const alias = raw === true || typeof raw === "function" ? void 0 : raw;
|
|
176
|
+
const isArr = fieldType.type.kind === "array";
|
|
177
|
+
const elementType = isArr ? fieldType.type.of : fieldType;
|
|
178
|
+
const resolveTarget = () => elementType?.ref?.type() ?? elementType;
|
|
179
|
+
this.relations.set(fieldName, {
|
|
180
|
+
direction,
|
|
181
|
+
alias,
|
|
182
|
+
targetType: resolveTarget,
|
|
183
|
+
isArray: isArr,
|
|
184
|
+
...direction === "via" ? { viaType: raw } : {}
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
if (metadata.has("db.rel.FK")) {
|
|
188
|
+
const raw = metadata.get("db.rel.FK");
|
|
189
|
+
const alias = raw === true ? void 0 : raw;
|
|
190
|
+
if (fieldType.ref) {
|
|
191
|
+
const refTarget = fieldType.ref.type();
|
|
192
|
+
const targetTable = refTarget?.metadata?.get("db.table") || refTarget?.id || "";
|
|
193
|
+
const targetField = fieldType.ref.field;
|
|
194
|
+
const key = alias || `__auto_${fieldName}`;
|
|
195
|
+
const existing = this.foreignKeys.get(key);
|
|
196
|
+
if (existing) {
|
|
197
|
+
existing.fields.push(fieldName);
|
|
198
|
+
existing.targetFields.push(targetField);
|
|
199
|
+
} else this.foreignKeys.set(key, {
|
|
200
|
+
fields: [fieldName],
|
|
201
|
+
targetTable,
|
|
202
|
+
targetFields: [targetField],
|
|
203
|
+
targetTypeRef: fieldType.ref.type,
|
|
204
|
+
alias
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const onDelete = metadata.get("db.rel.onDelete");
|
|
209
|
+
const onUpdate = metadata.get("db.rel.onUpdate");
|
|
210
|
+
if (onDelete || onUpdate) {
|
|
211
|
+
for (const fk of this.foreignKeys.values()) if (fk.fields.includes(fieldName)) {
|
|
212
|
+
if (onDelete) fk.onDelete = onDelete;
|
|
213
|
+
if (onUpdate) fk.onUpdate = onUpdate;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const index of metadata.get("db.index.plain") || []) {
|
|
218
|
+
const name = index === true ? fieldName : index?.name || fieldName;
|
|
219
|
+
const sort = (index === true ? void 0 : index?.sort) || "asc";
|
|
220
|
+
this._addIndexField("plain", name, fieldName, { sort });
|
|
221
|
+
}
|
|
222
|
+
for (const index of metadata.get("db.index.unique") || []) {
|
|
223
|
+
const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
|
|
224
|
+
this._addIndexField("unique", name, fieldName);
|
|
225
|
+
}
|
|
226
|
+
for (const index of metadata.get("db.index.fulltext") || []) {
|
|
227
|
+
const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
|
|
228
|
+
const weight = index !== true && typeof index === "object" ? index?.weight : void 0;
|
|
229
|
+
this._addIndexField("fulltext", name, fieldName, { weight });
|
|
230
|
+
}
|
|
231
|
+
const collate = metadata.get("db.column.collate");
|
|
232
|
+
if (collate) this._collateMap.set(fieldName, collate);
|
|
233
|
+
const hasExplicitIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
|
|
234
|
+
if (metadata.has("db.json")) {
|
|
235
|
+
this.jsonFields.add(fieldName);
|
|
236
|
+
if (hasExplicitIndex) logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
|
|
237
|
+
}
|
|
238
|
+
if (metadata.has("db.column.dimension")) {
|
|
239
|
+
this.dimensions.push(fieldName);
|
|
240
|
+
if (!hasExplicitIndex) this._addIndexField("plain", fieldName, fieldName);
|
|
241
|
+
}
|
|
242
|
+
if (metadata.has("db.column.measure")) this.measures.push(fieldName);
|
|
243
|
+
}
|
|
244
|
+
_addIndexField(type, name, field, opts) {
|
|
245
|
+
const key = indexKey(type, name);
|
|
246
|
+
const index = this.indexes.get(key);
|
|
247
|
+
const indexField = {
|
|
248
|
+
name: field,
|
|
249
|
+
sort: opts?.sort ?? "asc"
|
|
250
|
+
};
|
|
251
|
+
if (opts?.weight !== void 0) indexField.weight = opts.weight;
|
|
252
|
+
if (index) index.fields.push(indexField);
|
|
253
|
+
else this.indexes.set(key, {
|
|
254
|
+
key,
|
|
255
|
+
name,
|
|
256
|
+
type,
|
|
257
|
+
fields: [indexField]
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Classifies each field as column, flattened, json, or parent-object.
|
|
262
|
+
* Builds the bidirectional pathToPhysical / physicalToPath maps.
|
|
263
|
+
*/
|
|
264
|
+
_classifyFields() {
|
|
265
|
+
for (const [path, type] of this.flatMap.entries()) {
|
|
266
|
+
if (!path) continue;
|
|
267
|
+
const designType = resolveDesignType(type);
|
|
268
|
+
const isJson = this.jsonFields.has(path);
|
|
269
|
+
const isArray = designType === "array";
|
|
270
|
+
const isObject = designType === "object";
|
|
271
|
+
if (isArray) this.jsonFields.add(path);
|
|
272
|
+
else if (isObject && isJson) {} else if (isObject && !isJson) this.flattenedParents.add(path);
|
|
273
|
+
}
|
|
274
|
+
for (const ignoredField of this.ignoredFields) if (this.flattenedParents.has(ignoredField)) {
|
|
275
|
+
const prefix = `${ignoredField}.`;
|
|
276
|
+
for (const path of this.flatMap.keys()) if (path.startsWith(prefix)) this.ignoredFields.add(path);
|
|
277
|
+
}
|
|
278
|
+
for (const parentPath of this.flattenedParents) if (this.columnMap.has(parentPath)) throw new Error(`@db.column cannot rename a flattened object field "${parentPath}" — apply @db.column to individual nested fields, or use @db.json to store as a single column`);
|
|
279
|
+
for (const [path] of this.flatMap.entries()) {
|
|
280
|
+
if (!path) continue;
|
|
281
|
+
if (this.flattenedParents.has(path)) continue;
|
|
282
|
+
if (findAncestorInSet(path, this.jsonFields) !== void 0) continue;
|
|
283
|
+
const isFlattened = findAncestorInSet(path, this.flattenedParents) !== void 0;
|
|
284
|
+
const columnOverride = this.columnMap.get(path);
|
|
285
|
+
let physicalName;
|
|
286
|
+
if (columnOverride) physicalName = isFlattened ? this._flattenedPrefix(path) + columnOverride : columnOverride;
|
|
287
|
+
else physicalName = isFlattened ? path.replace(/\./g, "__") : path;
|
|
288
|
+
this.pathToPhysical.set(path, physicalName);
|
|
289
|
+
this.physicalToPath.set(physicalName, path);
|
|
290
|
+
const fieldType = this.flatMap.get(path);
|
|
291
|
+
if (fieldType) {
|
|
292
|
+
const dt = resolveDesignType(fieldType);
|
|
293
|
+
if (dt === "boolean") this.booleanFields.add(physicalName);
|
|
294
|
+
else if (dt === "decimal") this.decimalFields.add(physicalName);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
for (const parentPath of this.flattenedParents) {
|
|
298
|
+
const prefix = `${parentPath}.`;
|
|
299
|
+
const leaves = [];
|
|
300
|
+
for (const [path, physical] of this.pathToPhysical) if (path.startsWith(prefix)) leaves.push(physical);
|
|
301
|
+
if (leaves.length > 0) this.selectExpansion.set(parentPath, leaves);
|
|
302
|
+
}
|
|
303
|
+
this.requiresMappings = this.flattenedParents.size > 0 || this.jsonFields.size > 0;
|
|
304
|
+
}
|
|
305
|
+
/** Returns the `__`-separated parent prefix for a dot-separated path, or empty string for top-level paths. */
|
|
306
|
+
_flattenedPrefix(path) {
|
|
307
|
+
const lastDot = path.lastIndexOf(".");
|
|
308
|
+
return lastDot >= 0 ? `${path.slice(0, lastDot).replace(/\./g, "__")}__` : "";
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Indexes `fieldDescriptors` into two lookup maps for unified
|
|
312
|
+
* read/write field classification in the RelationalFieldMapper.
|
|
313
|
+
*/
|
|
314
|
+
_buildLeafIndexes() {
|
|
315
|
+
for (const fd of this.fieldDescriptors) {
|
|
316
|
+
if (fd.ignored) continue;
|
|
317
|
+
this.leafByPhysical.set(fd.physicalName, fd);
|
|
318
|
+
this.leafByLogical.set(fd.path, fd);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Builds field descriptors, physical-name lookup, and value formatters.
|
|
323
|
+
* Called once during build() — everything it needs
|
|
324
|
+
* (flatMap, indexes, columnMap, etc.) is already populated.
|
|
325
|
+
*/
|
|
326
|
+
_buildFieldDescriptors(adapter) {
|
|
327
|
+
const descriptors = [];
|
|
328
|
+
const skipFlattening = this.nestedObjects;
|
|
329
|
+
const indexedFields = /* @__PURE__ */ new Set();
|
|
330
|
+
for (const index of this.indexes.values()) for (const f of index.fields) indexedFields.add(f.name);
|
|
331
|
+
for (const [path, type] of this.flatMap.entries()) {
|
|
332
|
+
if (!path) continue;
|
|
333
|
+
if (!skipFlattening && this.flattenedParents.has(path)) continue;
|
|
334
|
+
if (!skipFlattening && findAncestorInSet(path, this.jsonFields) !== void 0) continue;
|
|
335
|
+
const isJson = this.jsonFields.has(path);
|
|
336
|
+
const isFlattened = !skipFlattening && findAncestorInSet(path, this.flattenedParents) !== void 0;
|
|
337
|
+
const designType = isJson ? "json" : resolveDesignType(type);
|
|
338
|
+
let storage;
|
|
339
|
+
if (skipFlattening) storage = "column";
|
|
340
|
+
else if (isJson) storage = "json";
|
|
341
|
+
else if (isFlattened) storage = "flattened";
|
|
342
|
+
else storage = "column";
|
|
343
|
+
const physicalName = skipFlattening ? this.columnMap.get(path) ?? path : this.pathToPhysical.get(path) ?? this.columnMap.get(path) ?? path;
|
|
344
|
+
const fromLocal = this._columnFromMap.get(path);
|
|
345
|
+
let renamedFrom;
|
|
346
|
+
if (fromLocal) renamedFrom = isFlattened ? this._flattenedPrefix(path) + fromLocal : fromLocal;
|
|
347
|
+
descriptors.push({
|
|
348
|
+
path,
|
|
349
|
+
type,
|
|
350
|
+
physicalName,
|
|
351
|
+
designType,
|
|
352
|
+
optional: type.optional === true,
|
|
353
|
+
isPrimaryKey: this.primaryKeys.includes(path),
|
|
354
|
+
ignored: this.ignoredFields.has(path),
|
|
355
|
+
defaultValue: this.defaults.get(path),
|
|
356
|
+
storage,
|
|
357
|
+
flattenedFrom: isFlattened ? path : void 0,
|
|
358
|
+
renamedFrom,
|
|
359
|
+
collate: this._collateMap.get(path),
|
|
360
|
+
isIndexed: indexedFields.has(path) || void 0
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
this._resolveFkTargetFields(descriptors);
|
|
364
|
+
const fmtHook = adapter.formatValue?.bind(adapter);
|
|
365
|
+
if (fmtHook) for (const fd of descriptors) {
|
|
366
|
+
const fmt = fmtHook(fd);
|
|
367
|
+
if (fmt) if (typeof fmt === "function") {
|
|
368
|
+
if (!this.toStorageFormatters) this.toStorageFormatters = /* @__PURE__ */ new Map();
|
|
369
|
+
this.toStorageFormatters.set(fd.physicalName, fmt);
|
|
370
|
+
} else {
|
|
371
|
+
if (fmt.toStorage) {
|
|
372
|
+
if (!this.toStorageFormatters) this.toStorageFormatters = /* @__PURE__ */ new Map();
|
|
373
|
+
this.toStorageFormatters.set(fd.physicalName, fmt.toStorage);
|
|
374
|
+
}
|
|
375
|
+
if (fmt.fromStorage) {
|
|
376
|
+
if (!this.fromStorageFormatters) this.fromStorageFormatters = /* @__PURE__ */ new Map();
|
|
377
|
+
this.fromStorageFormatters.set(fd.physicalName, fmt.fromStorage);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
Object.freeze(descriptors);
|
|
382
|
+
this.fieldDescriptors = descriptors;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* Resolves `fkTargetField` for FK fields in field descriptors.
|
|
386
|
+
*/
|
|
387
|
+
_resolveFkTargetFields(descriptors) {
|
|
388
|
+
if (this.foreignKeys.size === 0) return;
|
|
389
|
+
const fkFieldToTarget = /* @__PURE__ */ new Map();
|
|
390
|
+
for (const fk of this.foreignKeys.values()) {
|
|
391
|
+
if (!fk.targetTypeRef) continue;
|
|
392
|
+
for (let i = 0; i < fk.fields.length; i++) fkFieldToTarget.set(fk.fields[i], {
|
|
393
|
+
targetTypeRef: fk.targetTypeRef,
|
|
394
|
+
targetField: fk.targetFields[i]
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
if (fkFieldToTarget.size === 0) return;
|
|
398
|
+
const flatCache = /* @__PURE__ */ new Map();
|
|
399
|
+
for (const descriptor of descriptors) {
|
|
400
|
+
const target = fkFieldToTarget.get(descriptor.path);
|
|
401
|
+
if (!target) continue;
|
|
402
|
+
const targetType = target.targetTypeRef();
|
|
403
|
+
if (!targetType) continue;
|
|
404
|
+
let targetFlatMap = flatCache.get(targetType);
|
|
405
|
+
if (!targetFlatMap) {
|
|
406
|
+
targetFlatMap = flattenAnnotatedType(targetType);
|
|
407
|
+
flatCache.set(targetType, targetFlatMap);
|
|
408
|
+
}
|
|
409
|
+
const targetFieldType = targetFlatMap.get(target.targetField);
|
|
410
|
+
if (!targetFieldType) continue;
|
|
411
|
+
const targetMetadata = targetFieldType.metadata;
|
|
412
|
+
descriptor.fkTargetField = {
|
|
413
|
+
path: target.targetField,
|
|
414
|
+
type: targetFieldType,
|
|
415
|
+
physicalName: target.targetField,
|
|
416
|
+
designType: resolveDesignType(targetFieldType),
|
|
417
|
+
optional: false,
|
|
418
|
+
isPrimaryKey: targetMetadata?.has("meta.id") ?? false,
|
|
419
|
+
ignored: false,
|
|
420
|
+
storage: "column",
|
|
421
|
+
defaultValue: targetMetadata ? resolveDefaultFromMetadata(targetMetadata) : void 0,
|
|
422
|
+
collate: targetMetadata?.get("db.column.collate")
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
_finalizeIndexes() {
|
|
427
|
+
for (const index of this.indexes.values()) if (index.type === "unique" && index.fields.length === 1) this.uniqueProps.add(index.fields[0].name);
|
|
428
|
+
for (const index of this.indexes.values()) for (const field of index.fields) field.name = this.pathToPhysical.get(field.name) ?? this.columnMap.get(field.name) ?? field.name;
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
//#region src/query/uniqu-select.ts
|
|
433
|
+
/**
|
|
434
|
+
* Wraps a raw `$select` value and provides lazy-cached conversions
|
|
435
|
+
* to the forms different adapters need.
|
|
436
|
+
*
|
|
437
|
+
* Only instantiated when `$select` is actually provided —
|
|
438
|
+
* `controls.$select` is `UniquSelect | undefined`.
|
|
439
|
+
*
|
|
440
|
+
* For exclusion → inclusion inversion, pass `allFields` (physical field names).
|
|
441
|
+
*/
|
|
442
|
+
var UniquSelect = class UniquSelect {
|
|
443
|
+
static UNRESOLVED = Symbol("unresolved");
|
|
444
|
+
_raw;
|
|
445
|
+
_allFields;
|
|
446
|
+
_array = UniquSelect.UNRESOLVED;
|
|
447
|
+
_projection = UniquSelect.UNRESOLVED;
|
|
448
|
+
_aggregates = UniquSelect.UNRESOLVED;
|
|
449
|
+
constructor(raw, allFields) {
|
|
450
|
+
this._raw = raw;
|
|
451
|
+
this._allFields = allFields;
|
|
452
|
+
}
|
|
453
|
+
/** Type guard: checks if a value is an AggregateExpr ({$fn, $field}). */
|
|
454
|
+
static _isAggregateExpr(v) {
|
|
455
|
+
return typeof v === "object" && v !== null && "$fn" in v && "$field" in v;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Resolved inclusion array of plain field names (strings only).
|
|
459
|
+
* AggregateExpr objects are filtered out.
|
|
460
|
+
* For exclusion form, inverts using `allFields` from constructor.
|
|
461
|
+
*/
|
|
462
|
+
get asArray() {
|
|
463
|
+
if (this._array !== UniquSelect.UNRESOLVED) return this._array;
|
|
464
|
+
if (Array.isArray(this._raw)) {
|
|
465
|
+
this._array = this._raw.filter((item) => typeof item === "string");
|
|
466
|
+
return this._array;
|
|
467
|
+
}
|
|
468
|
+
const raw = this._raw;
|
|
469
|
+
const entries = Object.entries(raw);
|
|
470
|
+
if (entries.length === 0) {
|
|
471
|
+
this._array = void 0;
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
if (entries[0][1] === 1) this._array = entries.filter((e) => e[1] === 1).map((e) => e[0]);
|
|
475
|
+
else if (this._allFields) {
|
|
476
|
+
const excluded = new Set(entries.filter((e) => e[1] === 0).map((e) => e[0]));
|
|
477
|
+
this._array = this._allFields.filter((f) => !excluded.has(f));
|
|
478
|
+
} else this._array = void 0;
|
|
479
|
+
return this._array;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Record projection preserving original semantics.
|
|
483
|
+
* Returns original object as-is if raw was object.
|
|
484
|
+
* Converts `string[]` to `{field: 1}` inclusion object.
|
|
485
|
+
* AggregateExpr objects in array form are ignored.
|
|
486
|
+
*/
|
|
487
|
+
get asProjection() {
|
|
488
|
+
if (this._projection !== UniquSelect.UNRESOLVED) return this._projection;
|
|
489
|
+
if (!Array.isArray(this._raw)) {
|
|
490
|
+
const raw = this._raw;
|
|
491
|
+
this._projection = Object.keys(raw).length === 0 ? void 0 : raw;
|
|
492
|
+
return this._projection;
|
|
493
|
+
}
|
|
494
|
+
const strings = this.asArray;
|
|
495
|
+
if (!strings || strings.length === 0) {
|
|
496
|
+
this._projection = void 0;
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const result = {};
|
|
500
|
+
for (const item of strings) result[item] = 1;
|
|
501
|
+
this._projection = result;
|
|
502
|
+
return this._projection;
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Extracts AggregateExpr entries from array-form $select.
|
|
506
|
+
* Returns undefined if no aggregates present or if $select is object form.
|
|
507
|
+
*/
|
|
508
|
+
get aggregates() {
|
|
509
|
+
if (this._aggregates !== UniquSelect.UNRESOLVED) return this._aggregates;
|
|
510
|
+
if (!Array.isArray(this._raw)) {
|
|
511
|
+
this._aggregates = void 0;
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const aggs = this._raw.filter((v) => UniquSelect._isAggregateExpr(v));
|
|
515
|
+
this._aggregates = aggs.length > 0 ? aggs : void 0;
|
|
516
|
+
return this._aggregates;
|
|
517
|
+
}
|
|
518
|
+
/** Whether the $select contains any AggregateExpr entries. */
|
|
519
|
+
get hasAggregates() {
|
|
520
|
+
return !!this.aggregates?.length;
|
|
521
|
+
}
|
|
522
|
+
};
|
|
523
|
+
//#endregion
|
|
524
|
+
//#region src/strategies/field-mapping.ts
|
|
525
|
+
/** Coerces a storage value (0/1/null) back to a JS boolean. */
|
|
526
|
+
function toBool(value) {
|
|
527
|
+
if (value === null || value === void 0) return value;
|
|
528
|
+
return !!value;
|
|
529
|
+
}
|
|
530
|
+
function toDecimalString(value) {
|
|
531
|
+
if (value === null || value === void 0) return value;
|
|
532
|
+
if (typeof value === "string") return value;
|
|
533
|
+
if (typeof value === "number") return String(value);
|
|
534
|
+
return value;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Strategy for mapping data between logical field shapes and physical storage.
|
|
538
|
+
* Two implementations: {@link DocumentFieldMapper} (nested objects, NoSQL)
|
|
539
|
+
* and `RelationalFieldMapper` (flattened columns, SQL).
|
|
540
|
+
*/
|
|
541
|
+
var FieldMappingStrategy = class {
|
|
542
|
+
/**
|
|
543
|
+
* Recursively walks a filter expression, applying adapter-specific value
|
|
544
|
+
* formatting via `formatFilterValue`. Shared by both document and relational
|
|
545
|
+
* mappers (relational adds key-renaming via `translateFilterWithRename`).
|
|
546
|
+
*/
|
|
547
|
+
translateFilter(filter, meta) {
|
|
548
|
+
if (!filter || typeof filter !== "object") return filter;
|
|
549
|
+
if (!meta.toStorageFormatters) return filter;
|
|
550
|
+
const result = {};
|
|
551
|
+
for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") result[key] = value.map((f) => this.translateFilter(f, meta));
|
|
552
|
+
else if (key === "$not") result[key] = this.translateFilter(value, meta);
|
|
553
|
+
else if (key.startsWith("$")) result[key] = value;
|
|
554
|
+
else result[key] = this.formatFilterValue(key, value, meta);
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Coerces field values from storage representation to JS types
|
|
559
|
+
* (booleans from 0/1, decimals from number to string).
|
|
560
|
+
*/
|
|
561
|
+
coerceFieldValues(row, meta) {
|
|
562
|
+
if (meta.booleanFields.size === 0 && meta.decimalFields.size === 0) return row;
|
|
563
|
+
for (const field of meta.booleanFields) if (field in row) row[field] = toBool(row[field]);
|
|
564
|
+
for (const field of meta.decimalFields) if (field in row) row[field] = toDecimalString(row[field]);
|
|
565
|
+
return row;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Applies adapter-specific fromStorage formatting to a row read from the database.
|
|
569
|
+
* Converts storage representations back to JS values (e.g. Date → epoch ms).
|
|
570
|
+
*/
|
|
571
|
+
applyFromStorageFormatters(row, meta) {
|
|
572
|
+
if (!meta.fromStorageFormatters) return row;
|
|
573
|
+
for (const [col, fmt] of meta.fromStorageFormatters) {
|
|
574
|
+
const val = row[col];
|
|
575
|
+
if (val !== null && val !== void 0) row[col] = fmt(val);
|
|
576
|
+
}
|
|
577
|
+
return row;
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* Sets a value at a dot-notation path, creating intermediate objects as needed.
|
|
581
|
+
*/
|
|
582
|
+
setNestedValue(obj, dotPath, value) {
|
|
583
|
+
const parts = dotPath.split(".");
|
|
584
|
+
let current = obj;
|
|
585
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
586
|
+
const part = parts[i];
|
|
587
|
+
if (current[part] === void 0 || current[part] === null) current[part] = {};
|
|
588
|
+
current = current[part];
|
|
589
|
+
}
|
|
590
|
+
current[parts[parts.length - 1]] = value;
|
|
591
|
+
}
|
|
592
|
+
/**
|
|
593
|
+
* If all children of a flattened parent are null, collapse the parent to null.
|
|
594
|
+
*/
|
|
595
|
+
reconstructNullParent(obj, parentPath, meta) {
|
|
596
|
+
const parts = parentPath.split(".");
|
|
597
|
+
let current = obj;
|
|
598
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
599
|
+
if (current[parts[i]] === void 0) return;
|
|
600
|
+
current = current[parts[i]];
|
|
601
|
+
}
|
|
602
|
+
const lastPart = parts[parts.length - 1];
|
|
603
|
+
const parentObj = current[lastPart];
|
|
604
|
+
if (typeof parentObj !== "object" || parentObj === null) return;
|
|
605
|
+
let allNull = true;
|
|
606
|
+
const parentKeys = Object.keys(parentObj);
|
|
607
|
+
for (const k of parentKeys) {
|
|
608
|
+
const v = parentObj[k];
|
|
609
|
+
if (v !== null && v !== void 0) {
|
|
610
|
+
allNull = false;
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
if (allNull) {
|
|
615
|
+
const parentType = meta.flatMap?.get(parentPath);
|
|
616
|
+
current[lastPart] = parentType?.optional ? null : {};
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Applies adapter-specific value formatting to a single filter value.
|
|
621
|
+
* Handles direct values, operator objects ({$gt: v}), and $in/$nin arrays.
|
|
622
|
+
*/
|
|
623
|
+
formatFilterValue(physicalName, value, meta) {
|
|
624
|
+
const fmt = meta.toStorageFormatters?.get(physicalName);
|
|
625
|
+
if (!fmt) return value;
|
|
626
|
+
if (value === null || value === void 0) return value;
|
|
627
|
+
if (typeof value !== "object") return fmt(value);
|
|
628
|
+
const ops = value;
|
|
629
|
+
const formatted = {};
|
|
630
|
+
for (const [op, opVal] of Object.entries(ops)) if ((op === "$in" || op === "$nin") && Array.isArray(opVal)) formatted[op] = opVal.map((v) => v === null || v === void 0 ? v : fmt(v));
|
|
631
|
+
else if (op.startsWith("$") && opVal !== null && opVal !== void 0) formatted[op] = fmt(opVal);
|
|
632
|
+
else formatted[op] = opVal;
|
|
633
|
+
return formatted;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Applies adapter-specific value formatting to prepared (physical-named) data.
|
|
637
|
+
*/
|
|
638
|
+
formatWriteValues(data, meta) {
|
|
639
|
+
if (!meta.toStorageFormatters) return data;
|
|
640
|
+
for (const [col, fmt] of meta.toStorageFormatters) {
|
|
641
|
+
const val = data[col];
|
|
642
|
+
if (val !== null && val !== void 0) data[col] = fmt(val);
|
|
643
|
+
}
|
|
644
|
+
return data;
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Prepares primary key values and strips ignored fields.
|
|
648
|
+
* Shared pre-processing for both document and relational write paths.
|
|
649
|
+
*/
|
|
650
|
+
prepareCommon(data, meta, adapter) {
|
|
651
|
+
for (const pk of meta.primaryKeys) if (data[pk] !== void 0) {
|
|
652
|
+
const fieldType = meta.flatMap?.get(pk);
|
|
653
|
+
if (fieldType) data[pk] = adapter.prepareId(data[pk], fieldType);
|
|
654
|
+
}
|
|
655
|
+
for (const field of meta.ignoredFields) if (!field.includes(".")) delete data[field];
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
/**
|
|
659
|
+
* Field mapper for document-oriented adapters (e.g. MongoDB).
|
|
660
|
+
* Nested objects are preserved as-is. Only applies column renames and
|
|
661
|
+
* value coercion.
|
|
662
|
+
*/
|
|
663
|
+
var DocumentFieldMapper = class extends FieldMappingStrategy {
|
|
664
|
+
reconstructFromRead(row, meta) {
|
|
665
|
+
return this.applyFromStorageFormatters(this.coerceFieldValues(row, meta), meta);
|
|
666
|
+
}
|
|
667
|
+
translateQuery(query, meta) {
|
|
668
|
+
const controls = query.controls;
|
|
669
|
+
return {
|
|
670
|
+
filter: meta.toStorageFormatters ? this.translateFilter(query.filter, meta) : query.filter,
|
|
671
|
+
controls: {
|
|
672
|
+
...controls,
|
|
673
|
+
$with: void 0,
|
|
674
|
+
$select: controls?.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
|
|
675
|
+
},
|
|
676
|
+
insights: query.insights
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
translateAggregateQuery(query, meta) {
|
|
680
|
+
const controls = query.controls;
|
|
681
|
+
return {
|
|
682
|
+
filter: meta.toStorageFormatters ? this.translateFilter(query.filter, meta) : query.filter ?? {},
|
|
683
|
+
controls: {
|
|
684
|
+
...controls,
|
|
685
|
+
$with: void 0,
|
|
686
|
+
$select: controls.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
|
|
687
|
+
},
|
|
688
|
+
insights: query.insights
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
prepareForWrite(payload, meta, adapter) {
|
|
692
|
+
const data = { ...payload };
|
|
693
|
+
this.prepareCommon(data, meta, adapter);
|
|
694
|
+
for (const [logical, physical] of meta.columnMap.entries()) if (logical in data) {
|
|
695
|
+
data[physical] = data[logical];
|
|
696
|
+
delete data[logical];
|
|
697
|
+
}
|
|
698
|
+
return this.formatWriteValues(data, meta);
|
|
699
|
+
}
|
|
700
|
+
translatePatchKeys(update, meta) {
|
|
701
|
+
return this.formatWriteValues(update, meta);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
//#endregion
|
|
705
|
+
//#region src/strategies/relational-field-mapper.ts
|
|
706
|
+
/**
|
|
707
|
+
* Field mapper for relational adapters (e.g. SQLite, MySQL).
|
|
708
|
+
* Flattens nested objects to `__`-separated column names and
|
|
709
|
+
* reconstructs them on read. Applies full physical-name translation
|
|
710
|
+
* for queries, filters, and controls.
|
|
711
|
+
*/
|
|
712
|
+
var RelationalFieldMapper = class extends FieldMappingStrategy {
|
|
713
|
+
reconstructFromRead(row, meta) {
|
|
714
|
+
if (!meta.requiresMappings) return this.applyFromStorageFormatters(this.coerceFieldValues(row, meta), meta);
|
|
715
|
+
const result = {};
|
|
716
|
+
const fromFmts = meta.fromStorageFormatters;
|
|
717
|
+
for (const physical of Object.keys(row)) {
|
|
718
|
+
const fd = meta.leafByPhysical.get(physical);
|
|
719
|
+
if (!fd) {
|
|
720
|
+
result[physical] = row[physical];
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
let raw = row[physical];
|
|
724
|
+
const fromFmt = fromFmts?.get(physical);
|
|
725
|
+
if (fromFmt && raw !== null && raw !== void 0) raw = fromFmt(raw);
|
|
726
|
+
const value = fd.designType === "boolean" ? toBool(raw) : fd.designType === "decimal" ? toDecimalString(raw) : raw;
|
|
727
|
+
if (fd.storage === "json") this.setNestedValue(result, fd.path, typeof value === "string" ? JSON.parse(value) : value);
|
|
728
|
+
else if (fd.storage === "flattened") this.setNestedValue(result, fd.path, value);
|
|
729
|
+
else result[fd.path] = value;
|
|
730
|
+
}
|
|
731
|
+
for (const parentPath of meta.flattenedParents) this.reconstructNullParent(result, parentPath, meta);
|
|
732
|
+
return result;
|
|
733
|
+
}
|
|
734
|
+
translateQuery(query, meta) {
|
|
735
|
+
if (!meta.requiresMappings) {
|
|
736
|
+
const controls = query.controls;
|
|
737
|
+
return {
|
|
738
|
+
filter: meta.toStorageFormatters ? this.translateFilter(query.filter, meta) : query.filter,
|
|
739
|
+
controls: {
|
|
740
|
+
...controls,
|
|
741
|
+
$with: void 0,
|
|
742
|
+
$select: controls?.$select ? new UniquSelect(controls.$select, meta.allPhysicalFields) : void 0
|
|
743
|
+
},
|
|
744
|
+
insights: query.insights
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return {
|
|
748
|
+
filter: this.translateFilterWithRename(query.filter, meta),
|
|
749
|
+
controls: query.controls ? this.translateControls(query.controls, meta) : {},
|
|
750
|
+
insights: query.insights
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
translateAggregateQuery(query, meta) {
|
|
754
|
+
const controls = query.controls;
|
|
755
|
+
const filter = meta.requiresMappings ? this.translateFilterWithRename(query.filter ?? {}, meta) : meta.toStorageFormatters ? this.translateFilter(query.filter ?? {}, meta) : query.filter ?? {};
|
|
756
|
+
const groupBy = controls.$groupBy.map((field) => meta.leafByLogical.get(field)?.physicalName ?? field);
|
|
757
|
+
let select;
|
|
758
|
+
if (controls.$select) select = controls.$select.map((item) => {
|
|
759
|
+
if (typeof item === "string") return meta.leafByLogical.get(item)?.physicalName ?? item;
|
|
760
|
+
if (item.$field === "*") return item;
|
|
761
|
+
return {
|
|
762
|
+
...item,
|
|
763
|
+
$field: meta.leafByLogical.get(item.$field)?.physicalName ?? item.$field
|
|
764
|
+
};
|
|
765
|
+
});
|
|
766
|
+
const aliases = /* @__PURE__ */ new Set();
|
|
767
|
+
if (controls.$select) {
|
|
768
|
+
for (const item of controls.$select) if (typeof item !== "string") aliases.add(resolveAlias(item));
|
|
769
|
+
}
|
|
770
|
+
let sort;
|
|
771
|
+
if (controls.$sort) {
|
|
772
|
+
const translated = {};
|
|
773
|
+
for (const [key, dir] of Object.entries(controls.$sort)) if (aliases.has(key)) translated[key] = dir;
|
|
774
|
+
else {
|
|
775
|
+
const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
|
|
776
|
+
translated[physical] = dir;
|
|
777
|
+
}
|
|
778
|
+
sort = translated;
|
|
779
|
+
}
|
|
780
|
+
let having;
|
|
781
|
+
if (controls.$having) having = meta.requiresMappings ? this.translateFilterWithRename(controls.$having, meta) : meta.toStorageFormatters ? this.translateFilter(controls.$having, meta) : controls.$having;
|
|
782
|
+
return {
|
|
783
|
+
filter,
|
|
784
|
+
controls: {
|
|
785
|
+
$groupBy: groupBy,
|
|
786
|
+
$select: select ? new UniquSelect(select, meta.allPhysicalFields) : void 0,
|
|
787
|
+
$sort: sort,
|
|
788
|
+
$having: having,
|
|
789
|
+
$skip: controls.$skip,
|
|
790
|
+
$limit: controls.$limit,
|
|
791
|
+
$count: controls.$count
|
|
792
|
+
},
|
|
793
|
+
insights: query.insights
|
|
794
|
+
};
|
|
795
|
+
}
|
|
796
|
+
/**
|
|
797
|
+
* Translates filter with key renaming from logical to physical names.
|
|
798
|
+
* Used by the relational query path where field paths must be mapped
|
|
799
|
+
* to `__`-separated column names.
|
|
800
|
+
*/
|
|
801
|
+
translateFilterWithRename(filter, meta) {
|
|
802
|
+
if (!filter || typeof filter !== "object") return filter;
|
|
803
|
+
const result = {};
|
|
804
|
+
for (const [key, value] of Object.entries(filter)) if (key === "$and" || key === "$or") result[key] = value.map((f) => this.translateFilterWithRename(f, meta));
|
|
805
|
+
else if (key === "$not") result[key] = this.translateFilterWithRename(value, meta);
|
|
806
|
+
else if (key.startsWith("$")) result[key] = value;
|
|
807
|
+
else {
|
|
808
|
+
const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
|
|
809
|
+
result[physical] = this.formatFilterValue(physical, value, meta);
|
|
810
|
+
}
|
|
811
|
+
return result;
|
|
812
|
+
}
|
|
813
|
+
prepareForWrite(payload, meta, adapter) {
|
|
814
|
+
const data = { ...payload };
|
|
815
|
+
this.prepareCommon(data, meta, adapter);
|
|
816
|
+
if (!meta.requiresMappings) {
|
|
817
|
+
for (const [logical, physical] of meta.columnMap.entries()) if (logical in data) {
|
|
818
|
+
data[physical] = data[logical];
|
|
819
|
+
delete data[logical];
|
|
820
|
+
}
|
|
821
|
+
return this.formatWriteValues(data, meta);
|
|
822
|
+
}
|
|
823
|
+
return this.formatWriteValues(this.flattenPayload(data, meta), meta);
|
|
824
|
+
}
|
|
825
|
+
translatePatchKeys(update, meta) {
|
|
826
|
+
if (!meta.requiresMappings && !meta.toStorageFormatters) return update;
|
|
827
|
+
const result = {};
|
|
828
|
+
const updateKeys = Object.keys(update);
|
|
829
|
+
for (const key of updateKeys) {
|
|
830
|
+
const value = update[key];
|
|
831
|
+
const operatorMatch = key.match(/^(.+?)(\.__\$.+)$/);
|
|
832
|
+
const basePath = operatorMatch ? operatorMatch[1] : key;
|
|
833
|
+
const suffix = operatorMatch ? operatorMatch[2] : "";
|
|
834
|
+
const fd = meta.leafByLogical.get(basePath);
|
|
835
|
+
const finalKey = (fd?.physicalName ?? basePath) + suffix;
|
|
836
|
+
if (fd?.storage === "json" && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
|
|
837
|
+
else result[finalKey] = value;
|
|
838
|
+
}
|
|
839
|
+
return this.formatWriteValues(result, meta);
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Translates field names in sort and projection controls from
|
|
843
|
+
* logical dot-paths to physical column names.
|
|
844
|
+
*/
|
|
845
|
+
translateControls(controls, meta) {
|
|
846
|
+
if (!controls) return {};
|
|
847
|
+
const result = {
|
|
848
|
+
...controls,
|
|
849
|
+
$select: void 0,
|
|
850
|
+
$with: void 0
|
|
851
|
+
};
|
|
852
|
+
if (controls.$sort) {
|
|
853
|
+
const translated = {};
|
|
854
|
+
const sortObj = controls.$sort;
|
|
855
|
+
const sortKeys = Object.keys(sortObj);
|
|
856
|
+
for (const key of sortKeys) {
|
|
857
|
+
if (meta.flattenedParents.has(key)) continue;
|
|
858
|
+
const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
|
|
859
|
+
translated[physical] = sortObj[key];
|
|
860
|
+
}
|
|
861
|
+
result.$sort = translated;
|
|
862
|
+
}
|
|
863
|
+
if (controls.$select) {
|
|
864
|
+
let translatedRaw;
|
|
865
|
+
if (Array.isArray(controls.$select)) {
|
|
866
|
+
const expanded = [];
|
|
867
|
+
for (const key of controls.$select) {
|
|
868
|
+
const expansion = meta.selectExpansion.get(key);
|
|
869
|
+
if (expansion) expanded.push(...expansion);
|
|
870
|
+
else expanded.push(meta.leafByLogical.get(key)?.physicalName ?? key);
|
|
871
|
+
}
|
|
872
|
+
translatedRaw = expanded;
|
|
873
|
+
} else {
|
|
874
|
+
const translated = {};
|
|
875
|
+
const selectObj = controls.$select;
|
|
876
|
+
const selectKeys = Object.keys(selectObj);
|
|
877
|
+
for (const key of selectKeys) {
|
|
878
|
+
const val = selectObj[key];
|
|
879
|
+
const expansion = meta.selectExpansion.get(key);
|
|
880
|
+
if (expansion) for (const leaf of expansion) translated[leaf] = val;
|
|
881
|
+
else {
|
|
882
|
+
const physical = meta.leafByLogical.get(key)?.physicalName ?? key;
|
|
883
|
+
translated[physical] = val;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
translatedRaw = translated;
|
|
887
|
+
}
|
|
888
|
+
result.$select = new UniquSelect(translatedRaw, meta.allPhysicalFields);
|
|
889
|
+
}
|
|
890
|
+
return result;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Flattens nested object fields into __-separated keys and
|
|
894
|
+
* JSON-stringifies @db.json / array fields.
|
|
895
|
+
*/
|
|
896
|
+
flattenPayload(data, meta) {
|
|
897
|
+
const result = {};
|
|
898
|
+
for (const key of Object.keys(data)) this.writeFlattenedField(key, data[key], result, meta);
|
|
899
|
+
return result;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* Classifies and writes a single field to the result object.
|
|
903
|
+
* Recurses into nested objects that should be flattened.
|
|
904
|
+
*/
|
|
905
|
+
writeFlattenedField(path, value, result, meta) {
|
|
906
|
+
if (meta.ignoredFields.has(path)) return;
|
|
907
|
+
if (meta.flattenedParents.has(path)) {
|
|
908
|
+
if (value === null || value === void 0) this.setFlattenedChildrenNull(path, result, meta);
|
|
909
|
+
else if (typeof value === "object" && !Array.isArray(value)) {
|
|
910
|
+
const obj = value;
|
|
911
|
+
for (const key of Object.keys(obj)) this.writeFlattenedField(`${path}.${key}`, obj[key], result, meta);
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
const fd = meta.leafByLogical.get(path);
|
|
915
|
+
const physical = fd?.physicalName ?? path.replace(/\./g, "__");
|
|
916
|
+
if (fd?.storage === "json") result[physical] = value !== void 0 && value !== null ? JSON.stringify(value) : value;
|
|
917
|
+
else result[physical] = value;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* When a parent object is null/undefined, set all its flattened children to null.
|
|
922
|
+
*/
|
|
923
|
+
setFlattenedChildrenNull(parentPath, result, meta) {
|
|
924
|
+
const prefix = `${parentPath}.`;
|
|
925
|
+
for (const [path, fd] of meta.leafByLogical.entries()) if (path.startsWith(prefix)) result[fd.physicalName] = null;
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
//#endregion
|
|
929
|
+
//#region src/table/db-readable.ts
|
|
930
|
+
/**
|
|
931
|
+
* Resolves the design type from an annotated type.
|
|
932
|
+
* Encapsulates the `kind === ''` check and fallback logic that
|
|
933
|
+
* otherwise trips up every adapter author.
|
|
934
|
+
*
|
|
935
|
+
* For union types (e.g., from flattened `{...} | {...}` objects):
|
|
936
|
+
* - If all members resolve to the same type → returns that type (strong type)
|
|
937
|
+
* - If members disagree → returns `'union'` (out of scope for type management)
|
|
938
|
+
*/
|
|
939
|
+
function resolveDesignType(fieldType) {
|
|
940
|
+
if (fieldType.type.kind === "") return fieldType.type.designType ?? "string";
|
|
941
|
+
if (fieldType.type.kind === "object") return "object";
|
|
942
|
+
if (fieldType.type.kind === "array") return "array";
|
|
943
|
+
if (fieldType.type.kind === "union") {
|
|
944
|
+
const items = fieldType.type.items;
|
|
945
|
+
if (items.length > 0) {
|
|
946
|
+
const resolved = items.map((item) => resolveDesignType(item));
|
|
947
|
+
if (resolved.every((type) => type === resolved[0])) return resolved[0];
|
|
948
|
+
}
|
|
949
|
+
return "union";
|
|
950
|
+
}
|
|
951
|
+
return "string";
|
|
952
|
+
}
|
|
953
|
+
/**
|
|
954
|
+
* Resolves `@db.default.*` annotations from a metadata map into a {@link TDbDefaultValue}.
|
|
955
|
+
* Used both during normal field descriptor construction and for FK target field resolution.
|
|
956
|
+
*/
|
|
957
|
+
function resolveDefaultFromMetadata(metadata) {
|
|
958
|
+
const defaultValue = metadata.get("db.default");
|
|
959
|
+
if (defaultValue !== void 0) return {
|
|
960
|
+
kind: "value",
|
|
961
|
+
value: defaultValue
|
|
962
|
+
};
|
|
963
|
+
if (metadata.has("db.default.increment")) {
|
|
964
|
+
const startValue = metadata.get("db.default.increment");
|
|
965
|
+
return {
|
|
966
|
+
kind: "fn",
|
|
967
|
+
fn: "increment",
|
|
968
|
+
start: typeof startValue === "number" ? startValue : void 0
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
if (metadata.has("db.default.uuid")) return {
|
|
972
|
+
kind: "fn",
|
|
973
|
+
fn: "uuid"
|
|
974
|
+
};
|
|
975
|
+
if (metadata.has("db.default.now")) return {
|
|
976
|
+
kind: "fn",
|
|
977
|
+
fn: "now"
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* Checks whether an id value is type-compatible with a field's design type.
|
|
982
|
+
* Used by `findById` to skip primary-key lookup when the id clearly can't match,
|
|
983
|
+
* falling through to unique-property search instead.
|
|
984
|
+
*/
|
|
985
|
+
function isIdCompatible(id, fieldType) {
|
|
986
|
+
switch (resolveDesignType(fieldType)) {
|
|
987
|
+
case "number":
|
|
988
|
+
if (typeof id === "number") return true;
|
|
989
|
+
if (typeof id === "string") return id !== "" && !Number.isNaN(Number(id));
|
|
990
|
+
return false;
|
|
991
|
+
case "boolean": return typeof id === "boolean";
|
|
992
|
+
case "object":
|
|
993
|
+
case "array": return typeof id === "object" && id !== null;
|
|
994
|
+
default: return typeof id === "string";
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Shared read-only database abstraction driven by Atscript annotations.
|
|
999
|
+
*
|
|
1000
|
+
* Contains all field metadata computation, read operations, query translation,
|
|
1001
|
+
* relation loading, and result reconstruction. Extended by both
|
|
1002
|
+
* {@link AtscriptDbTable} (adds write operations) and {@link AtscriptDbView}
|
|
1003
|
+
* (adds view plan/DDL).
|
|
1004
|
+
*/
|
|
1005
|
+
var AtscriptDbReadable = class {
|
|
1006
|
+
/** Resolved table/collection/view name. */
|
|
1007
|
+
tableName;
|
|
1008
|
+
/** Database schema/namespace from `@db.schema` (if set). */
|
|
1009
|
+
schema;
|
|
1010
|
+
/** Sync method from `@db.sync.method` ('drop' | 'recreate' | undefined). */
|
|
1011
|
+
_syncMethod;
|
|
1012
|
+
/** Previous table/view name from `@db.table.renamed` or `@db.view.renamed`. */
|
|
1013
|
+
renamedFrom;
|
|
1014
|
+
/** Computed metadata for this table/view. Built lazily on first access. */
|
|
1015
|
+
_meta;
|
|
1016
|
+
/** Strategy for mapping between logical field shapes and physical storage. */
|
|
1017
|
+
_fieldMapper;
|
|
1018
|
+
_writeTableResolver;
|
|
1019
|
+
constructor(_type, adapter, logger = NoopLogger, _tableResolver) {
|
|
1020
|
+
this._type = _type;
|
|
1021
|
+
this.adapter = adapter;
|
|
1022
|
+
this.logger = logger;
|
|
1023
|
+
this._tableResolver = _tableResolver;
|
|
1024
|
+
if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
|
|
1025
|
+
if (_type.type.kind !== "object") throw new Error("Database type must be an object type");
|
|
1026
|
+
const adapterName = adapter.getAdapterTableName?.(_type);
|
|
1027
|
+
const dbTable = _type.metadata.get("db.table");
|
|
1028
|
+
const dbViewName = _type.metadata.get("db.view");
|
|
1029
|
+
const fallbackName = _type.id || "";
|
|
1030
|
+
this.tableName = adapterName || dbTable || dbViewName || fallbackName;
|
|
1031
|
+
if (!this.tableName) throw new Error("@db.table or @db.view annotation expected");
|
|
1032
|
+
this.schema = _type.metadata.get("db.schema");
|
|
1033
|
+
this._syncMethod = _type.metadata.get("db.sync.method");
|
|
1034
|
+
this.renamedFrom = _type.metadata.get("db.table.renamed") ?? _type.metadata.get("db.view.renamed");
|
|
1035
|
+
this._meta = new TableMetadata(adapter.supportsNestedObjects());
|
|
1036
|
+
this._fieldMapper = adapter.supportsNestedObjects() ? new DocumentFieldMapper() : new RelationalFieldMapper();
|
|
1037
|
+
adapter.registerReadable(this, logger);
|
|
1038
|
+
}
|
|
1039
|
+
/** Ensures metadata is built. Called before any metadata access. */
|
|
1040
|
+
_ensureBuilt() {
|
|
1041
|
+
if (!this._meta.isBuilt) this._meta.build(this.type, this.adapter, this.logger);
|
|
1042
|
+
}
|
|
1043
|
+
/** Whether this readable is a view (overridden in AtscriptDbView). */
|
|
1044
|
+
get isView() {
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
/** Returns the underlying adapter with its concrete type preserved. */
|
|
1048
|
+
getAdapter() {
|
|
1049
|
+
return this.adapter;
|
|
1050
|
+
}
|
|
1051
|
+
/** The raw annotated type. */
|
|
1052
|
+
get type() {
|
|
1053
|
+
return this._type;
|
|
1054
|
+
}
|
|
1055
|
+
/** Lazily-built flat map of all fields (dot-notation paths → annotated types). */
|
|
1056
|
+
get flatMap() {
|
|
1057
|
+
this._ensureBuilt();
|
|
1058
|
+
return this._meta.flatMap;
|
|
1059
|
+
}
|
|
1060
|
+
/** All computed indexes from `@db.index.*` annotations. */
|
|
1061
|
+
get indexes() {
|
|
1062
|
+
this._ensureBuilt();
|
|
1063
|
+
return this._meta.indexes;
|
|
1064
|
+
}
|
|
1065
|
+
/** Primary key field names from `@meta.id`. */
|
|
1066
|
+
get primaryKeys() {
|
|
1067
|
+
this._ensureBuilt();
|
|
1068
|
+
return this._meta.primaryKeys;
|
|
1069
|
+
}
|
|
1070
|
+
/** Original `@meta.id` field names as declared in the schema (before adapter manipulation). */
|
|
1071
|
+
get originalMetaIdFields() {
|
|
1072
|
+
this._ensureBuilt();
|
|
1073
|
+
return this._meta.originalMetaIdFields;
|
|
1074
|
+
}
|
|
1075
|
+
/** Dimension fields from `@db.column.dimension`. */
|
|
1076
|
+
get dimensions() {
|
|
1077
|
+
this._ensureBuilt();
|
|
1078
|
+
return this._meta.dimensions;
|
|
1079
|
+
}
|
|
1080
|
+
/** Measure fields from `@db.column.measure`. */
|
|
1081
|
+
get measures() {
|
|
1082
|
+
this._ensureBuilt();
|
|
1083
|
+
return this._meta.measures;
|
|
1084
|
+
}
|
|
1085
|
+
/** Sync method for structural changes: 'drop' (lossy), 'recreate' (lossless), or undefined (manual). */
|
|
1086
|
+
get syncMethod() {
|
|
1087
|
+
return this._syncMethod;
|
|
1088
|
+
}
|
|
1089
|
+
/** Logical → physical column name mapping from `@db.column`. */
|
|
1090
|
+
get columnMap() {
|
|
1091
|
+
this._ensureBuilt();
|
|
1092
|
+
return this._meta.columnMap;
|
|
1093
|
+
}
|
|
1094
|
+
/** Default values from `@db.default.*`. */
|
|
1095
|
+
get defaults() {
|
|
1096
|
+
this._ensureBuilt();
|
|
1097
|
+
return this._meta.defaults;
|
|
1098
|
+
}
|
|
1099
|
+
/** Fields excluded from DB via `@db.ignore`. */
|
|
1100
|
+
get ignoredFields() {
|
|
1101
|
+
this._ensureBuilt();
|
|
1102
|
+
return this._meta.ignoredFields;
|
|
1103
|
+
}
|
|
1104
|
+
/** Navigational fields (`@db.rel.to` / `@db.rel.from`) — not stored as columns. */
|
|
1105
|
+
get navFields() {
|
|
1106
|
+
this._ensureBuilt();
|
|
1107
|
+
return this._meta.navFields;
|
|
1108
|
+
}
|
|
1109
|
+
/** Single-field unique index properties. */
|
|
1110
|
+
get uniqueProps() {
|
|
1111
|
+
this._ensureBuilt();
|
|
1112
|
+
return this._meta.uniqueProps;
|
|
1113
|
+
}
|
|
1114
|
+
/** Foreign key constraints from `@db.rel.FK` annotations. */
|
|
1115
|
+
get foreignKeys() {
|
|
1116
|
+
this._ensureBuilt();
|
|
1117
|
+
return this._meta.foreignKeys;
|
|
1118
|
+
}
|
|
1119
|
+
/** Navigational relation metadata from `@db.rel.to` / `@db.rel.from`. */
|
|
1120
|
+
get relations() {
|
|
1121
|
+
this._ensureBuilt();
|
|
1122
|
+
return this._meta.relations;
|
|
1123
|
+
}
|
|
1124
|
+
/** The underlying database adapter instance. */
|
|
1125
|
+
get dbAdapter() {
|
|
1126
|
+
return this.adapter;
|
|
1127
|
+
}
|
|
1128
|
+
/**
|
|
1129
|
+
* Enables or disables verbose (debug-level) DB call logging for this table/view.
|
|
1130
|
+
* When disabled (default), no log strings are constructed — zero overhead.
|
|
1131
|
+
*/
|
|
1132
|
+
setVerbose(enabled) {
|
|
1133
|
+
this.adapter.setVerbose(enabled);
|
|
1134
|
+
}
|
|
1135
|
+
/** Precomputed logical dot-path → physical column name map. */
|
|
1136
|
+
get pathToPhysical() {
|
|
1137
|
+
this._ensureBuilt();
|
|
1138
|
+
return this._meta.pathToPhysical;
|
|
1139
|
+
}
|
|
1140
|
+
/** Precomputed physical column name → logical dot-path map (inverse). */
|
|
1141
|
+
get physicalToPath() {
|
|
1142
|
+
this._ensureBuilt();
|
|
1143
|
+
return this._meta.physicalToPath;
|
|
1144
|
+
}
|
|
1145
|
+
/** Descriptor for the primary ID field(s). */
|
|
1146
|
+
getIdDescriptor() {
|
|
1147
|
+
this._ensureBuilt();
|
|
1148
|
+
return {
|
|
1149
|
+
fields: [...this._meta.primaryKeys],
|
|
1150
|
+
isComposite: this._meta.primaryKeys.length > 1
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Pre-computed field metadata for adapter use.
|
|
1155
|
+
*/
|
|
1156
|
+
get fieldDescriptors() {
|
|
1157
|
+
this._ensureBuilt();
|
|
1158
|
+
return this._meta.fieldDescriptors;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Creates a new validator with custom options.
|
|
1162
|
+
*/
|
|
1163
|
+
createValidator(opts) {
|
|
1164
|
+
return this._type.validator(opts);
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Finds a single record matching the query.
|
|
1168
|
+
* The return type automatically excludes nav props unless they are
|
|
1169
|
+
* explicitly requested via `$with`.
|
|
1170
|
+
*/
|
|
1171
|
+
async findOne(query) {
|
|
1172
|
+
this._ensureBuilt();
|
|
1173
|
+
const withRelations = query.controls?.$with;
|
|
1174
|
+
const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
|
|
1175
|
+
const result = await this.adapter.findOne(translatedQuery);
|
|
1176
|
+
if (!result) return null;
|
|
1177
|
+
const row = this._fieldMapper.reconstructFromRead(result, this._meta);
|
|
1178
|
+
if (withRelations?.length) await this.loadRelations([row], withRelations);
|
|
1179
|
+
return row;
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Finds all records matching the query.
|
|
1183
|
+
* The return type automatically excludes nav props unless they are
|
|
1184
|
+
* explicitly requested via `$with`.
|
|
1185
|
+
*/
|
|
1186
|
+
async findMany(query) {
|
|
1187
|
+
this._ensureBuilt();
|
|
1188
|
+
const withRelations = query.controls?.$with;
|
|
1189
|
+
const translatedQuery = this._fieldMapper.translateQuery(query, this._meta);
|
|
1190
|
+
const rows = (await this.adapter.findMany(translatedQuery)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1191
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1192
|
+
return rows;
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Counts records matching the query.
|
|
1196
|
+
*/
|
|
1197
|
+
async count(query) {
|
|
1198
|
+
this._ensureBuilt();
|
|
1199
|
+
query ??= {
|
|
1200
|
+
filter: {},
|
|
1201
|
+
controls: {}
|
|
1202
|
+
};
|
|
1203
|
+
return this.adapter.count(this._fieldMapper.translateQuery(query, this._meta));
|
|
1204
|
+
}
|
|
1205
|
+
/**
|
|
1206
|
+
* Finds records and total count in a single logical call.
|
|
1207
|
+
*/
|
|
1208
|
+
async findManyWithCount(query) {
|
|
1209
|
+
this._ensureBuilt();
|
|
1210
|
+
const withRelations = query.controls?.$with;
|
|
1211
|
+
const translated = this._fieldMapper.translateQuery(query, this._meta);
|
|
1212
|
+
const result = await this.adapter.findManyWithCount(translated);
|
|
1213
|
+
const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1214
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1215
|
+
return {
|
|
1216
|
+
data: rows,
|
|
1217
|
+
count: result.count
|
|
1218
|
+
};
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Executes an aggregate query with GROUP BY and aggregate functions.
|
|
1222
|
+
*
|
|
1223
|
+
* Validates:
|
|
1224
|
+
* - Plain fields in $select are a subset of $groupBy
|
|
1225
|
+
* - When dimensions/measures are defined (strict mode): $groupBy fields
|
|
1226
|
+
* must be dimensions, aggregate $field values must be measures (or '*')
|
|
1227
|
+
*
|
|
1228
|
+
* Translates field names, delegates to adapter.aggregate(),
|
|
1229
|
+
* then reverse-maps and applies fromStorage formatters on results.
|
|
1230
|
+
*/
|
|
1231
|
+
async aggregate(query) {
|
|
1232
|
+
this._ensureBuilt();
|
|
1233
|
+
const { $groupBy, $select } = query.controls;
|
|
1234
|
+
if ($select) {
|
|
1235
|
+
const groupBySet = new Set($groupBy);
|
|
1236
|
+
for (const item of $select) if (typeof item === "string" && !groupBySet.has(item)) throw new DbError("INVALID_QUERY", [{
|
|
1237
|
+
path: "$select",
|
|
1238
|
+
message: `Plain field "${item}" in $select must also appear in $groupBy`
|
|
1239
|
+
}]);
|
|
1240
|
+
}
|
|
1241
|
+
const { dimensions, measures } = this._meta;
|
|
1242
|
+
if (dimensions.length > 0 || measures.length > 0) {
|
|
1243
|
+
const dimSet = new Set(dimensions);
|
|
1244
|
+
const measSet = new Set(measures);
|
|
1245
|
+
for (const field of $groupBy) if (!dimSet.has(field)) throw new DbError("INVALID_QUERY", [{
|
|
1246
|
+
path: "$groupBy",
|
|
1247
|
+
message: `Field "${field}" is not a dimension`
|
|
1248
|
+
}]);
|
|
1249
|
+
if ($select) {
|
|
1250
|
+
for (const item of $select) if (typeof item !== "string" && item.$field !== "*" && !measSet.has(item.$field)) throw new DbError("INVALID_QUERY", [{
|
|
1251
|
+
path: "$select",
|
|
1252
|
+
message: `Aggregate field "${item.$field}" is not a measure`
|
|
1253
|
+
}]);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
const dbQuery = this._fieldMapper.translateAggregateQuery(query, this._meta);
|
|
1257
|
+
return (await this.adapter.aggregate(dbQuery)).map((row) => {
|
|
1258
|
+
const mapped = {};
|
|
1259
|
+
for (const [key, value] of Object.entries(row)) {
|
|
1260
|
+
const logical = this._meta.physicalToPath.get(key) ?? key;
|
|
1261
|
+
const fmt = this._meta.fromStorageFormatters?.get(key);
|
|
1262
|
+
mapped[logical] = fmt && value !== null && value !== void 0 ? fmt(value) : value;
|
|
1263
|
+
}
|
|
1264
|
+
return mapped;
|
|
1265
|
+
});
|
|
1266
|
+
}
|
|
1267
|
+
/** Whether the underlying adapter supports text search. */
|
|
1268
|
+
isSearchable() {
|
|
1269
|
+
return this.adapter.isSearchable();
|
|
1270
|
+
}
|
|
1271
|
+
/** Returns available search indexes from the adapter. */
|
|
1272
|
+
getSearchIndexes() {
|
|
1273
|
+
return this.adapter.getSearchIndexes();
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Full-text search with query translation and result reconstruction.
|
|
1277
|
+
*/
|
|
1278
|
+
async search(text, query, indexName) {
|
|
1279
|
+
this._ensureBuilt();
|
|
1280
|
+
const withRelations = query.controls?.$with;
|
|
1281
|
+
const translated = this._fieldMapper.translateQuery(query, this._meta);
|
|
1282
|
+
const rows = (await this.adapter.search(text, translated, indexName)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1283
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1284
|
+
return rows;
|
|
1285
|
+
}
|
|
1286
|
+
/**
|
|
1287
|
+
* Full-text search with count for paginated search results.
|
|
1288
|
+
*/
|
|
1289
|
+
async searchWithCount(text, query, indexName) {
|
|
1290
|
+
this._ensureBuilt();
|
|
1291
|
+
const withRelations = query.controls?.$with;
|
|
1292
|
+
const translated = this._fieldMapper.translateQuery(query, this._meta);
|
|
1293
|
+
const result = await this.adapter.searchWithCount(text, translated, indexName);
|
|
1294
|
+
const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1295
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1296
|
+
return {
|
|
1297
|
+
data: rows,
|
|
1298
|
+
count: result.count
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
/** Whether the underlying adapter supports vector similarity search. */
|
|
1302
|
+
isVectorSearchable() {
|
|
1303
|
+
return this.adapter.isVectorSearchable();
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* Vector similarity search with query translation and result reconstruction.
|
|
1307
|
+
*
|
|
1308
|
+
* Overloads:
|
|
1309
|
+
* - `vectorSearch(vector, query?)` — uses default vector index
|
|
1310
|
+
* - `vectorSearch(indexName, vector, query?)` — targets a specific vector index
|
|
1311
|
+
*/
|
|
1312
|
+
async vectorSearch(vectorOrIndex, maybeVectorOrQuery, maybeQuery) {
|
|
1313
|
+
const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
|
|
1314
|
+
this._ensureBuilt();
|
|
1315
|
+
const withRelations = (query?.controls)?.$with;
|
|
1316
|
+
const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
|
|
1317
|
+
const rows = (await this.adapter.vectorSearch(vector, translated, indexName)).map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1318
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1319
|
+
return rows;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Vector similarity search with count for paginated results.
|
|
1323
|
+
*
|
|
1324
|
+
* Overloads:
|
|
1325
|
+
* - `vectorSearchWithCount(vector, query?)` — uses default vector index
|
|
1326
|
+
* - `vectorSearchWithCount(indexName, vector, query?)` — targets a specific vector index
|
|
1327
|
+
*/
|
|
1328
|
+
async vectorSearchWithCount(vectorOrIndex, maybeVectorOrQuery, maybeQuery) {
|
|
1329
|
+
const { vector, query, indexName } = this._resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery);
|
|
1330
|
+
this._ensureBuilt();
|
|
1331
|
+
const withRelations = (query?.controls)?.$with;
|
|
1332
|
+
const translated = this._fieldMapper.translateQuery(query || {}, this._meta);
|
|
1333
|
+
const result = await this.adapter.vectorSearchWithCount(vector, translated, indexName);
|
|
1334
|
+
const rows = result.data.map((row) => this._fieldMapper.reconstructFromRead(row, this._meta));
|
|
1335
|
+
if (withRelations?.length) await this.loadRelations(rows, withRelations);
|
|
1336
|
+
return {
|
|
1337
|
+
data: rows,
|
|
1338
|
+
count: result.count
|
|
1339
|
+
};
|
|
1340
|
+
}
|
|
1341
|
+
/** Resolves overloaded vector search arguments into canonical form. */
|
|
1342
|
+
_resolveVectorSearchArgs(vectorOrIndex, maybeVectorOrQuery, maybeQuery) {
|
|
1343
|
+
if (Array.isArray(vectorOrIndex)) return {
|
|
1344
|
+
vector: vectorOrIndex,
|
|
1345
|
+
query: maybeVectorOrQuery,
|
|
1346
|
+
indexName: void 0
|
|
1347
|
+
};
|
|
1348
|
+
return {
|
|
1349
|
+
vector: maybeVectorOrQuery,
|
|
1350
|
+
query: maybeQuery,
|
|
1351
|
+
indexName: vectorOrIndex
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Finds a single record by any type-compatible identifier — primary key
|
|
1356
|
+
* or single-field unique index.
|
|
1357
|
+
* The return type excludes nav props unless `$with` is provided in controls.
|
|
1358
|
+
*
|
|
1359
|
+
* ```typescript
|
|
1360
|
+
* // Without relations — nav props stripped from result
|
|
1361
|
+
* const user = await table.findById('123')
|
|
1362
|
+
*
|
|
1363
|
+
* // With relations — only requested nav props appear
|
|
1364
|
+
* const user = await table.findById('123', { controls: { $with: [{ name: 'posts' }] } })
|
|
1365
|
+
* ```
|
|
1366
|
+
*/
|
|
1367
|
+
async findById(id, query) {
|
|
1368
|
+
this._ensureBuilt();
|
|
1369
|
+
const filter = this._resolveIdFilter(id);
|
|
1370
|
+
if (!filter) return null;
|
|
1371
|
+
return await this.findOne({
|
|
1372
|
+
filter,
|
|
1373
|
+
controls: query?.controls || {}
|
|
1374
|
+
});
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Resolves an id value into a filter expression.
|
|
1378
|
+
*/
|
|
1379
|
+
_resolveIdFilter(id) {
|
|
1380
|
+
const orFilters = [];
|
|
1381
|
+
const pkFields = this.primaryKeys;
|
|
1382
|
+
if (pkFields.length === 1) {
|
|
1383
|
+
const filter = this._tryFieldFilter(pkFields[0], id);
|
|
1384
|
+
if (filter) orFilters.push(filter);
|
|
1385
|
+
} else if (pkFields.length > 1 && typeof id === "object" && id !== null) {
|
|
1386
|
+
const idObj = id;
|
|
1387
|
+
const compositeFilter = {};
|
|
1388
|
+
let valid = true;
|
|
1389
|
+
for (const field of pkFields) {
|
|
1390
|
+
const fieldType = this.flatMap.get(field);
|
|
1391
|
+
if (fieldType && !isIdCompatible(idObj[field], fieldType)) {
|
|
1392
|
+
valid = false;
|
|
1393
|
+
break;
|
|
1394
|
+
}
|
|
1395
|
+
try {
|
|
1396
|
+
compositeFilter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
|
|
1397
|
+
} catch {
|
|
1398
|
+
valid = false;
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
if (valid) orFilters.push(compositeFilter);
|
|
1403
|
+
}
|
|
1404
|
+
for (const prop of this.uniqueProps) {
|
|
1405
|
+
const filter = this._tryFieldFilter(prop, id);
|
|
1406
|
+
if (filter) orFilters.push(filter);
|
|
1407
|
+
}
|
|
1408
|
+
if (typeof id === "object" && id !== null && orFilters.length === 0) {
|
|
1409
|
+
const idObj = id;
|
|
1410
|
+
for (const index of this._meta.indexes.values()) {
|
|
1411
|
+
if (index.type !== "unique" || index.fields.length < 2) continue;
|
|
1412
|
+
const compoundFilter = {};
|
|
1413
|
+
let valid = true;
|
|
1414
|
+
for (const indexField of index.fields) {
|
|
1415
|
+
const fieldName = indexField.name;
|
|
1416
|
+
if (idObj[fieldName] === void 0) {
|
|
1417
|
+
valid = false;
|
|
1418
|
+
break;
|
|
1419
|
+
}
|
|
1420
|
+
const fieldType = this.flatMap.get(fieldName);
|
|
1421
|
+
if (fieldType && !isIdCompatible(idObj[fieldName], fieldType)) {
|
|
1422
|
+
valid = false;
|
|
1423
|
+
break;
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
compoundFilter[fieldName] = fieldType ? this.adapter.prepareId(idObj[fieldName], fieldType) : idObj[fieldName];
|
|
1427
|
+
} catch {
|
|
1428
|
+
valid = false;
|
|
1429
|
+
break;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (valid) orFilters.push(compoundFilter);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (orFilters.length === 0) return null;
|
|
1436
|
+
if (orFilters.length === 1) return orFilters[0];
|
|
1437
|
+
return { $or: orFilters };
|
|
1438
|
+
}
|
|
1439
|
+
/**
|
|
1440
|
+
* Attempts to build a single-field filter `{ field: preparedId }`.
|
|
1441
|
+
*/
|
|
1442
|
+
_tryFieldFilter(field, id) {
|
|
1443
|
+
const fieldType = this.flatMap.get(field);
|
|
1444
|
+
if (fieldType && !isIdCompatible(id, fieldType)) return null;
|
|
1445
|
+
try {
|
|
1446
|
+
const prepared = fieldType ? this.adapter.prepareId(id, fieldType) : id;
|
|
1447
|
+
return { [field]: prepared };
|
|
1448
|
+
} catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Public entry point for relation loading. Used by adapters for nested $with delegation.
|
|
1454
|
+
*/
|
|
1455
|
+
async loadRelations(rows, withRelations) {
|
|
1456
|
+
const { loadRelationsImpl } = await import("./relation-loader-BEOTXNcq.mjs").then((n) => n.n);
|
|
1457
|
+
return loadRelationsImpl(rows, withRelations, this);
|
|
1458
|
+
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Finds the FK entry that connects a `@db.rel.to` relation to its target.
|
|
1461
|
+
* Thin wrapper — delegates to relation-loader for shared use with db-table.ts write path.
|
|
1462
|
+
*/
|
|
1463
|
+
_findFKForRelation(relation) {
|
|
1464
|
+
return findFKForRelation(relation, this._meta.foreignKeys);
|
|
1465
|
+
}
|
|
1466
|
+
/**
|
|
1467
|
+
* Finds a FK on a remote table that points back to this table.
|
|
1468
|
+
* Thin wrapper — delegates to relation-loader for shared use with db-table.ts write path.
|
|
1469
|
+
*/
|
|
1470
|
+
_findRemoteFK(targetTable, thisTableName, alias) {
|
|
1471
|
+
return findRemoteFK(targetTable, thisTableName, alias);
|
|
1472
|
+
}
|
|
1473
|
+
};
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/strategies/integrity.ts
|
|
1476
|
+
/**
|
|
1477
|
+
* Strategy for referential integrity enforcement.
|
|
1478
|
+
* Two implementations: {@link NativeIntegrity} (DB handles FK constraints)
|
|
1479
|
+
* and `ApplicationIntegrity` (generic layer validates + cascades).
|
|
1480
|
+
*/
|
|
1481
|
+
var IntegrityStrategy = class {};
|
|
1482
|
+
/**
|
|
1483
|
+
* Integrity strategy for adapters with native FK support (e.g. SQLite, MySQL).
|
|
1484
|
+
* All operations are no-ops — the database engine enforces constraints.
|
|
1485
|
+
*/
|
|
1486
|
+
var NativeIntegrity = class extends IntegrityStrategy {
|
|
1487
|
+
async validateForeignKeys() {}
|
|
1488
|
+
async cascadeBeforeDelete() {}
|
|
1489
|
+
needsCascade() {
|
|
1490
|
+
return false;
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
//#endregion
|
|
1494
|
+
//#region src/base-adapter.ts
|
|
1495
|
+
const EMPTY_DEFAULT_FNS = /* @__PURE__ */ new Set();
|
|
1496
|
+
const txStorage = new AsyncLocalStorage();
|
|
1497
|
+
/**
|
|
1498
|
+
* Abstract base class for database adapters.
|
|
1499
|
+
*
|
|
1500
|
+
* Adapter instances are 1:1 with readable instances (tables or views).
|
|
1501
|
+
* When an {@link AtscriptDbReadable} is created with an adapter, it calls
|
|
1502
|
+
* {@link registerReadable} to establish a bidirectional relationship:
|
|
1503
|
+
*
|
|
1504
|
+
* ```
|
|
1505
|
+
* AtscriptDbReadable ──delegates ops──▶ BaseDbAdapter
|
|
1506
|
+
* ◀──reads metadata── (via this._table)
|
|
1507
|
+
* ```
|
|
1508
|
+
*
|
|
1509
|
+
* Adapter authors can access all computed metadata through `this._table`:
|
|
1510
|
+
* - `this._table.tableName` — resolved table/collection/view name
|
|
1511
|
+
* - `this._table.flatMap` — all fields as dot-notation paths
|
|
1512
|
+
* - `this._table.indexes` — computed index definitions
|
|
1513
|
+
* - `this._table.primaryKeys` — primary key field names
|
|
1514
|
+
* - `this._table.columnMap` — logical → physical column mappings
|
|
1515
|
+
* - `this._table.defaults` — default value configurations
|
|
1516
|
+
* - `this._table.ignoredFields` — fields excluded from DB
|
|
1517
|
+
* - `this._table.uniqueProps` — single-field unique index properties
|
|
1518
|
+
* - `this._table.isView` — whether this is a view (vs a table)
|
|
1519
|
+
*/
|
|
1520
|
+
var BaseDbAdapter = class {
|
|
1521
|
+
_table;
|
|
1522
|
+
_metaIdPhysical;
|
|
1523
|
+
/**
|
|
1524
|
+
* Returns the physical column name of the single @meta.id field (if any).
|
|
1525
|
+
* Used to return the user's logical ID instead of the DB-generated ID on insert.
|
|
1526
|
+
*/
|
|
1527
|
+
_getMetaIdPhysical() {
|
|
1528
|
+
if (this._metaIdPhysical === void 0) {
|
|
1529
|
+
const fields = this._table.originalMetaIdFields;
|
|
1530
|
+
if (fields.length === 1) {
|
|
1531
|
+
const field = fields[0];
|
|
1532
|
+
this._metaIdPhysical = this._table.columnMap.get(field) ?? field;
|
|
1533
|
+
} else this._metaIdPhysical = null;
|
|
1534
|
+
}
|
|
1535
|
+
return this._metaIdPhysical;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Resolves the correct insertedId: prefers the user-supplied PK value
|
|
1539
|
+
* from the data over the DB-generated fallback (e.g. rowid, _id).
|
|
1540
|
+
*/
|
|
1541
|
+
_resolveInsertedId(data, dbGeneratedId) {
|
|
1542
|
+
const metaIdPhysical = this._getMetaIdPhysical();
|
|
1543
|
+
return metaIdPhysical ? data[metaIdPhysical] ?? dbGeneratedId : dbGeneratedId;
|
|
1544
|
+
}
|
|
1545
|
+
/** Logger instance — set via {@link registerReadable} from the readable's logger. */
|
|
1546
|
+
logger = NoopLogger;
|
|
1547
|
+
/** When true, adapter logs DB calls via `logger.debug`. Off by default. */
|
|
1548
|
+
_verbose = false;
|
|
1549
|
+
/**
|
|
1550
|
+
* Called by {@link AtscriptDbReadable} constructor. Gives the adapter access
|
|
1551
|
+
* to the readable's computed metadata for internal use in query rendering,
|
|
1552
|
+
* index sync, etc.
|
|
1553
|
+
*/
|
|
1554
|
+
registerReadable(readable, logger) {
|
|
1555
|
+
this._table = readable;
|
|
1556
|
+
if (logger) this.logger = logger;
|
|
1557
|
+
}
|
|
1558
|
+
/**
|
|
1559
|
+
* Enables or disables verbose (debug-level) logging for this adapter.
|
|
1560
|
+
* When disabled, no log strings are constructed — zero overhead.
|
|
1561
|
+
*/
|
|
1562
|
+
setVerbose(enabled) {
|
|
1563
|
+
this._verbose = enabled;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* Logs a debug message if verbose mode is enabled.
|
|
1567
|
+
* Adapters call this to log DB operations with zero overhead when disabled.
|
|
1568
|
+
*/
|
|
1569
|
+
_log(...args) {
|
|
1570
|
+
if (!this._verbose) return;
|
|
1571
|
+
this.logger.debug(...args);
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Runs `fn` inside a database transaction. Nested calls (from related tables
|
|
1575
|
+
* within the same async chain) reuse the existing transaction automatically.
|
|
1576
|
+
*
|
|
1577
|
+
* The generic layer handles nesting detection via `AsyncLocalStorage`.
|
|
1578
|
+
* Adapters override `_beginTransaction`, `_commitTransaction`, and
|
|
1579
|
+
* `_rollbackTransaction` to provide raw DB-specific transaction primitives.
|
|
1580
|
+
*/
|
|
1581
|
+
async withTransaction(fn) {
|
|
1582
|
+
if (txStorage.getStore()) return fn();
|
|
1583
|
+
const ctx = { state: void 0 };
|
|
1584
|
+
ctx.state = await this._beginTransaction();
|
|
1585
|
+
return txStorage.run(ctx, async () => {
|
|
1586
|
+
try {
|
|
1587
|
+
const result = await fn();
|
|
1588
|
+
await this._commitTransaction(ctx.state);
|
|
1589
|
+
return result;
|
|
1590
|
+
} catch (error) {
|
|
1591
|
+
try {
|
|
1592
|
+
await this._rollbackTransaction(ctx.state);
|
|
1593
|
+
} catch {}
|
|
1594
|
+
throw error;
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Returns the opaque transaction state from the current async context.
|
|
1600
|
+
* Adapters use this to retrieve DB-specific state (e.g., MongoDB `ClientSession`).
|
|
1601
|
+
*/
|
|
1602
|
+
_getTransactionState() {
|
|
1603
|
+
return txStorage.getStore()?.state;
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Runs `fn` inside the transaction ALS context with the given state.
|
|
1607
|
+
* Adapters that override `withTransaction` (e.g., to use MongoDB's
|
|
1608
|
+
* `session.withTransaction()` Convenient API) use this to set up the
|
|
1609
|
+
* shared context so that nested adapters see the same session.
|
|
1610
|
+
* If a context already exists (nesting), it's reused.
|
|
1611
|
+
*/
|
|
1612
|
+
_runInTransactionContext(state, fn) {
|
|
1613
|
+
if (txStorage.getStore()) return fn();
|
|
1614
|
+
return txStorage.run({ state }, fn);
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Starts a raw transaction. Returns opaque state stored in the async context.
|
|
1618
|
+
* Override in adapters that support transactions.
|
|
1619
|
+
*/
|
|
1620
|
+
async _beginTransaction() {}
|
|
1621
|
+
/** Commits the raw transaction. Override in adapters that support transactions. */
|
|
1622
|
+
async _commitTransaction(_state) {}
|
|
1623
|
+
/** Rolls back the raw transaction. Override in adapters that support transactions. */
|
|
1624
|
+
async _rollbackTransaction(_state) {}
|
|
1625
|
+
/**
|
|
1626
|
+
* Returns additional validator plugins for this adapter.
|
|
1627
|
+
* These are merged with the built-in Atscript validators.
|
|
1628
|
+
*
|
|
1629
|
+
* Example: MongoDB adapter returns ObjectId validation plugin.
|
|
1630
|
+
*/
|
|
1631
|
+
getValidatorPlugins() {
|
|
1632
|
+
return [];
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Transforms an ID value for the database.
|
|
1636
|
+
* Override to convert string → ObjectId, parse numeric IDs, etc.
|
|
1637
|
+
*
|
|
1638
|
+
* @param id - The raw ID value.
|
|
1639
|
+
* @param fieldType - The annotated type of the ID field.
|
|
1640
|
+
* @returns The transformed ID value.
|
|
1641
|
+
*/
|
|
1642
|
+
prepareId(id, _fieldType) {
|
|
1643
|
+
return id;
|
|
1644
|
+
}
|
|
1645
|
+
/**
|
|
1646
|
+
* Whether this adapter supports native patch operations.
|
|
1647
|
+
* When `true`, {@link AtscriptDbTable} delegates patch payloads to
|
|
1648
|
+
* {@link nativePatch} instead of using the generic decomposition.
|
|
1649
|
+
*/
|
|
1650
|
+
supportsNativePatch() {
|
|
1651
|
+
return false;
|
|
1652
|
+
}
|
|
1653
|
+
/**
|
|
1654
|
+
* Whether this adapter handles nested objects natively.
|
|
1655
|
+
* When `true`, the generic layer skips flattening and
|
|
1656
|
+
* passes nested objects as-is to the adapter.
|
|
1657
|
+
* MongoDB returns `true`; relational adapters return `false` (default).
|
|
1658
|
+
*/
|
|
1659
|
+
supportsNestedObjects() {
|
|
1660
|
+
return false;
|
|
1661
|
+
}
|
|
1662
|
+
/**
|
|
1663
|
+
* Whether the DB engine handles static `@db.default "value"` natively
|
|
1664
|
+
* via column-level DEFAULT clauses in CREATE TABLE.
|
|
1665
|
+
* When `true`, `_applyDefaults()` skips client-side value defaults,
|
|
1666
|
+
* letting the DB apply its own DEFAULT. SQL adapters return `true`;
|
|
1667
|
+
* document stores (MongoDB) return `false` and apply defaults client-side.
|
|
1668
|
+
*/
|
|
1669
|
+
supportsNativeValueDefaults() {
|
|
1670
|
+
return false;
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Function default names handled natively by this adapter's DB engine.
|
|
1674
|
+
* Fields with these defaults are omitted from INSERT when no value is provided,
|
|
1675
|
+
* letting the DB apply its own DEFAULT expression (e.g. CURRENT_TIMESTAMP, UUID()).
|
|
1676
|
+
*
|
|
1677
|
+
* Override in adapters whose DB engine supports function defaults.
|
|
1678
|
+
* The generic layer checks this in `_applyDefaults()` to decide whether
|
|
1679
|
+
* to generate the value client-side or leave it for the DB.
|
|
1680
|
+
*/
|
|
1681
|
+
nativeDefaultFns() {
|
|
1682
|
+
return EMPTY_DEFAULT_FNS;
|
|
1683
|
+
}
|
|
1684
|
+
/**
|
|
1685
|
+
* Whether this adapter enforces foreign key constraints natively.
|
|
1686
|
+
* When `true`, the generic layer skips application-level cascade/setNull
|
|
1687
|
+
* on delete — the DB engine handles it (e.g. SQLite `ON DELETE CASCADE`).
|
|
1688
|
+
* When `false` (default), the generic layer implements cascade logic
|
|
1689
|
+
* by finding child records and deleting/nullifying them before the parent.
|
|
1690
|
+
*/
|
|
1691
|
+
supportsNativeForeignKeys() {
|
|
1692
|
+
return false;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Whether this adapter handles `$with` relation loading natively.
|
|
1696
|
+
* When `true`, the table layer delegates to {@link loadRelations}
|
|
1697
|
+
* instead of using the generic batch-loading strategy.
|
|
1698
|
+
*
|
|
1699
|
+
* Adapters can use this to implement SQL JOINs, MongoDB `$lookup`,
|
|
1700
|
+
* or other DB-native relation loading optimizations.
|
|
1701
|
+
*
|
|
1702
|
+
* Default: `false` — the table layer uses application-level batch loading.
|
|
1703
|
+
*/
|
|
1704
|
+
supportsNativeRelations() {
|
|
1705
|
+
return false;
|
|
1706
|
+
}
|
|
1707
|
+
/**
|
|
1708
|
+
* Loads relations onto result rows using adapter-native operations.
|
|
1709
|
+
* Only called when {@link supportsNativeRelations} returns `true`.
|
|
1710
|
+
*
|
|
1711
|
+
* The adapter receives the rows to enrich, the `$with` relation specs,
|
|
1712
|
+
* and the table's relation/FK metadata for resolution.
|
|
1713
|
+
*
|
|
1714
|
+
* @param rows - The result rows to enrich (mutable — add relation properties in place).
|
|
1715
|
+
* @param withRelations - The `$with` specs from the query.
|
|
1716
|
+
* @param relations - This table's relation metadata (from `@db.rel.to`/`@db.rel.from`).
|
|
1717
|
+
* @param foreignKeys - This table's FK metadata (from `@db.rel.FK`).
|
|
1718
|
+
* @param tableResolver - Optional callback to resolve annotated types to table metadata (needed for FROM/VIA relations).
|
|
1719
|
+
*/
|
|
1720
|
+
async loadRelations(_rows, _withRelations, _relations, _foreignKeys, _tableResolver) {
|
|
1721
|
+
throw new Error("Native relation loading not supported by this adapter");
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* Applies a patch payload using native database operations.
|
|
1725
|
+
* Only called when {@link supportsNativePatch} returns `true`.
|
|
1726
|
+
*
|
|
1727
|
+
* @param filter - Filter identifying the record to patch.
|
|
1728
|
+
* @param patch - The patch payload with array operations.
|
|
1729
|
+
* @returns Update result.
|
|
1730
|
+
*/
|
|
1731
|
+
async nativePatch(_filter, _patch, _ops) {
|
|
1732
|
+
throw new Error("Native patch not supported by this adapter");
|
|
1733
|
+
}
|
|
1734
|
+
/**
|
|
1735
|
+
* Resolves the full table name, optionally including the schema prefix.
|
|
1736
|
+
* Override for databases that don't support schemas (e.g., SQLite).
|
|
1737
|
+
*
|
|
1738
|
+
* @param includeSchema - Whether to prepend `schema.` prefix (default: true).
|
|
1739
|
+
*/
|
|
1740
|
+
resolveTableName(includeSchema = true) {
|
|
1741
|
+
const schema = this._table.schema;
|
|
1742
|
+
const name = this._table.tableName;
|
|
1743
|
+
return includeSchema && schema ? `${schema}.${name}` : name;
|
|
1744
|
+
}
|
|
1745
|
+
/**
|
|
1746
|
+
* Template method for index synchronization.
|
|
1747
|
+
* Implements the diff algorithm (list → compare → create/drop).
|
|
1748
|
+
* Adapters provide the three DB-specific primitives.
|
|
1749
|
+
*
|
|
1750
|
+
* @example
|
|
1751
|
+
* ```typescript
|
|
1752
|
+
* async syncIndexes() {
|
|
1753
|
+
* await this.syncIndexesWithDiff({
|
|
1754
|
+
* listExisting: async () => this.driver.all('PRAGMA index_list(...)'),
|
|
1755
|
+
* createIndex: async (index) => this.driver.exec('CREATE INDEX ...'),
|
|
1756
|
+
* dropIndex: async (name) => this.driver.exec('DROP INDEX ...'),
|
|
1757
|
+
* shouldSkipType: (type) => type === 'fulltext',
|
|
1758
|
+
* })
|
|
1759
|
+
* }
|
|
1760
|
+
* ```
|
|
1761
|
+
*/
|
|
1762
|
+
async syncIndexesWithDiff(opts) {
|
|
1763
|
+
const prefix = opts.prefix ?? "atscript__";
|
|
1764
|
+
const existing = await opts.listExisting();
|
|
1765
|
+
const existingNames = new Set(existing.filter((i) => i.name.startsWith(prefix)).map((i) => i.name));
|
|
1766
|
+
const desiredNames = /* @__PURE__ */ new Set();
|
|
1767
|
+
for (const index of this._table.indexes.values()) {
|
|
1768
|
+
if (opts.shouldSkipType?.(index.type)) continue;
|
|
1769
|
+
desiredNames.add(index.key);
|
|
1770
|
+
if (!existingNames.has(index.key)) await opts.createIndex(index);
|
|
1771
|
+
}
|
|
1772
|
+
for (const name of existingNames) if (!desiredNames.has(name)) await opts.dropIndex(name);
|
|
1773
|
+
}
|
|
1774
|
+
/**
|
|
1775
|
+
* Returns available search indexes for this adapter.
|
|
1776
|
+
* UI uses this to show index picker. Override in adapters that support search.
|
|
1777
|
+
*/
|
|
1778
|
+
getSearchIndexes() {
|
|
1779
|
+
return [];
|
|
1780
|
+
}
|
|
1781
|
+
/**
|
|
1782
|
+
* Whether this adapter supports text search.
|
|
1783
|
+
* Default: `true` when {@link getSearchIndexes} returns any entries.
|
|
1784
|
+
*/
|
|
1785
|
+
isSearchable() {
|
|
1786
|
+
return this.getSearchIndexes().length > 0;
|
|
1787
|
+
}
|
|
1788
|
+
/**
|
|
1789
|
+
* Whether this adapter supports vector similarity search.
|
|
1790
|
+
* Override in adapters that support vector search.
|
|
1791
|
+
*/
|
|
1792
|
+
isVectorSearchable() {
|
|
1793
|
+
return false;
|
|
1794
|
+
}
|
|
1795
|
+
/**
|
|
1796
|
+
* Full-text search. Override in adapters that support search.
|
|
1797
|
+
*
|
|
1798
|
+
* @param text - Search text.
|
|
1799
|
+
* @param query - Filter, sort, limit, etc.
|
|
1800
|
+
* @param indexName - Optional search index to target.
|
|
1801
|
+
*/
|
|
1802
|
+
async search(_text, _query, _indexName) {
|
|
1803
|
+
throw new Error("Search not supported by this adapter");
|
|
1804
|
+
}
|
|
1805
|
+
/**
|
|
1806
|
+
* Full-text search with count (for paginated search results).
|
|
1807
|
+
*
|
|
1808
|
+
* @param text - Search text.
|
|
1809
|
+
* @param query - Filter, sort, limit, etc.
|
|
1810
|
+
* @param indexName - Optional search index to target.
|
|
1811
|
+
*/
|
|
1812
|
+
async searchWithCount(_text, _query, _indexName) {
|
|
1813
|
+
throw new Error("Search not supported by this adapter");
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Vector similarity search. Override in adapters that support vector search.
|
|
1817
|
+
*
|
|
1818
|
+
* @param vector - Pre-computed embedding vector.
|
|
1819
|
+
* @param query - Filter, sort, limit, etc.
|
|
1820
|
+
* @param indexName - Optional vector index to target (for multi-vector documents).
|
|
1821
|
+
*/
|
|
1822
|
+
async vectorSearch(_vector, _query, _indexName) {
|
|
1823
|
+
throw new Error("Vector search not supported by this adapter");
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Vector similarity search with count (for paginated results).
|
|
1827
|
+
*
|
|
1828
|
+
* @param vector - Pre-computed embedding vector.
|
|
1829
|
+
* @param query - Filter, sort, limit, etc.
|
|
1830
|
+
* @param indexName - Optional vector index to target (for multi-vector documents).
|
|
1831
|
+
*/
|
|
1832
|
+
async vectorSearchWithCount(_vector, _query, _indexName) {
|
|
1833
|
+
throw new Error("Vector search not supported by this adapter");
|
|
1834
|
+
}
|
|
1835
|
+
/**
|
|
1836
|
+
* Fetches records and total count in one call.
|
|
1837
|
+
* Default: two parallel calls. Adapters may override for single-query optimization.
|
|
1838
|
+
*/
|
|
1839
|
+
async findManyWithCount(query) {
|
|
1840
|
+
const [data, count] = await Promise.all([this.findMany(query), this.count(query)]);
|
|
1841
|
+
return {
|
|
1842
|
+
data,
|
|
1843
|
+
count
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* Executes an aggregate query (GROUP BY + aggregate functions).
|
|
1848
|
+
* Default throws — override in adapters that support aggregation.
|
|
1849
|
+
*/
|
|
1850
|
+
async aggregate(_query) {
|
|
1851
|
+
throw new Error("Aggregation not supported by this adapter");
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* When true, the adapter can handle column type changes in-place
|
|
1855
|
+
* (e.g. MySQL's ALTER TABLE MODIFY COLUMN) without requiring table recreation.
|
|
1856
|
+
* The generic sync layer will delegate type changes to {@link syncColumns}
|
|
1857
|
+
* instead of requiring `@db.sync.method "recreate"` or `"drop"`.
|
|
1858
|
+
*/
|
|
1859
|
+
supportsColumnModify;
|
|
1860
|
+
};
|
|
1861
|
+
//#endregion
|
|
1862
|
+
//#region src/strategies/application-integrity.ts
|
|
1863
|
+
const MAX_CASCADE_DEPTH = 100;
|
|
1864
|
+
const cascadeStorage = new AsyncLocalStorage();
|
|
1865
|
+
/**
|
|
1866
|
+
* Integrity strategy for adapters without native FK support (e.g. MongoDB).
|
|
1867
|
+
* Validates FK constraints and executes cascade/setNull actions at
|
|
1868
|
+
* the application level.
|
|
1869
|
+
*/
|
|
1870
|
+
var ApplicationIntegrity = class extends IntegrityStrategy {
|
|
1871
|
+
/**
|
|
1872
|
+
* Validates FK constraints by querying target tables for referenced records.
|
|
1873
|
+
* Collects unique FK values across items, batches them into target-table
|
|
1874
|
+
* lookups, and throws FK_VIOLATION if any references are missing.
|
|
1875
|
+
*/
|
|
1876
|
+
async validateForeignKeys(items, meta, fkLookupResolver, writeTableResolver, partial, excludeTargetTable) {
|
|
1877
|
+
if (!fkLookupResolver) return;
|
|
1878
|
+
const checks = [];
|
|
1879
|
+
for (const [, fk] of meta.foreignKeys) {
|
|
1880
|
+
if (excludeTargetTable && fk.targetTable === excludeTargetTable) continue;
|
|
1881
|
+
const valueSets = fk.fields.map(() => /* @__PURE__ */ new Set());
|
|
1882
|
+
for (const item of items) {
|
|
1883
|
+
if (partial && !fk.fields.some((f) => f in item)) continue;
|
|
1884
|
+
let allPresent = true;
|
|
1885
|
+
const vals = [];
|
|
1886
|
+
for (const field of fk.fields) {
|
|
1887
|
+
const v = item[field];
|
|
1888
|
+
if (v === null || v === void 0) {
|
|
1889
|
+
allPresent = false;
|
|
1890
|
+
break;
|
|
1891
|
+
}
|
|
1892
|
+
vals.push(v);
|
|
1893
|
+
}
|
|
1894
|
+
if (!allPresent) continue;
|
|
1895
|
+
for (let i = 0; i < vals.length; i++) valueSets[i].add(vals[i]);
|
|
1896
|
+
}
|
|
1897
|
+
if (valueSets[0].size === 0) continue;
|
|
1898
|
+
let target = fkLookupResolver(fk.targetTable);
|
|
1899
|
+
if (!target && fk.targetTypeRef && writeTableResolver) {
|
|
1900
|
+
const resolved = writeTableResolver(fk.targetTypeRef());
|
|
1901
|
+
if (resolved) target = { count: (filter) => resolved.count({ filter }) };
|
|
1902
|
+
}
|
|
1903
|
+
if (!target) continue;
|
|
1904
|
+
const filter = {};
|
|
1905
|
+
let firstValues = [];
|
|
1906
|
+
for (let i = 0; i < fk.targetFields.length; i++) {
|
|
1907
|
+
const values = [...valueSets[i]];
|
|
1908
|
+
if (i === 0) firstValues = values;
|
|
1909
|
+
filter[fk.targetFields[i]] = values.length === 1 ? values[0] : { $in: values };
|
|
1910
|
+
}
|
|
1911
|
+
const expectedCount = firstValues.length;
|
|
1912
|
+
checks.push(async () => {
|
|
1913
|
+
if (await target.count(filter) < expectedCount) {
|
|
1914
|
+
const sample = firstValues.slice(0, 3).join(", ");
|
|
1915
|
+
const suffix = firstValues.length > 3 ? `, ... (${firstValues.length} total)` : "";
|
|
1916
|
+
throw new DbError("FK_VIOLATION", [{
|
|
1917
|
+
path: fk.fields.join(", "),
|
|
1918
|
+
message: `FK constraint violation: "${fk.fields.join(", ")}" references non-existent record in "${fk.targetTable}" (values: ${sample}${suffix})`
|
|
1919
|
+
}]);
|
|
1920
|
+
}
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
if (checks.length > 0) await Promise.all(checks.map((fn) => fn()));
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* Applies cascade/setNull actions on child tables before deleting parent records.
|
|
1927
|
+
* Finds all records matching `filter`, extracts their PK values, then for each
|
|
1928
|
+
* child table with a FK pointing to this table:
|
|
1929
|
+
* - `restrict`: throws if any children exist
|
|
1930
|
+
* - `cascade`: recursively deletes child records
|
|
1931
|
+
* - `setNull`: sets FK fields to null
|
|
1932
|
+
*/
|
|
1933
|
+
async cascadeBeforeDelete(filter, tableName, meta, cascadeResolver, translateFilter, adapter) {
|
|
1934
|
+
const parentCtx = cascadeStorage.getStore();
|
|
1935
|
+
const visited = parentCtx?.visited ?? /* @__PURE__ */ new Set();
|
|
1936
|
+
const depth = (parentCtx?.depth ?? 0) + 1;
|
|
1937
|
+
if (depth > MAX_CASCADE_DEPTH) throw new DbError("CASCADE_CYCLE", [{
|
|
1938
|
+
path: tableName,
|
|
1939
|
+
message: `Cascade delete aborted: chain exceeded ${MAX_CASCADE_DEPTH} levels, likely caused by a circular or deeply nested cascade relationship`
|
|
1940
|
+
}]);
|
|
1941
|
+
const targets = cascadeResolver(tableName);
|
|
1942
|
+
if (targets.length === 0) return;
|
|
1943
|
+
const neededLogical = /* @__PURE__ */ new Set();
|
|
1944
|
+
for (const t of targets) for (const tf of t.fk.targetFields) neededLogical.add(tf);
|
|
1945
|
+
for (const pk of meta.primaryKeys) neededLogical.add(pk);
|
|
1946
|
+
const physicalToLogical = /* @__PURE__ */ new Map();
|
|
1947
|
+
const physicalFields = [];
|
|
1948
|
+
for (const logical of neededLogical) {
|
|
1949
|
+
const physical = meta.pathToPhysical.get(logical) ?? meta.columnMap.get(logical) ?? logical;
|
|
1950
|
+
physicalFields.push(physical);
|
|
1951
|
+
physicalToLogical.set(physical, logical);
|
|
1952
|
+
}
|
|
1953
|
+
const rawRecords = await adapter.findMany({
|
|
1954
|
+
filter: translateFilter(filter),
|
|
1955
|
+
controls: { $select: new UniquSelect(physicalFields) }
|
|
1956
|
+
});
|
|
1957
|
+
if (rawRecords.length === 0) return;
|
|
1958
|
+
const allRecords = rawRecords.map((r) => {
|
|
1959
|
+
const mapped = {};
|
|
1960
|
+
for (const [key, val] of Object.entries(r)) mapped[physicalToLogical.get(key) ?? key] = val;
|
|
1961
|
+
return mapped;
|
|
1962
|
+
});
|
|
1963
|
+
const pkFields = meta.primaryKeys;
|
|
1964
|
+
const addedKeys = [];
|
|
1965
|
+
const records = [];
|
|
1966
|
+
for (const record of allRecords) {
|
|
1967
|
+
const key = this.recordKey(tableName, pkFields, record);
|
|
1968
|
+
if (visited.has(key)) continue;
|
|
1969
|
+
visited.add(key);
|
|
1970
|
+
addedKeys.push(key);
|
|
1971
|
+
records.push(record);
|
|
1972
|
+
}
|
|
1973
|
+
if (records.length === 0) return;
|
|
1974
|
+
try {
|
|
1975
|
+
await cascadeStorage.run({
|
|
1976
|
+
visited,
|
|
1977
|
+
depth
|
|
1978
|
+
}, async () => {
|
|
1979
|
+
const restrictChecks = [];
|
|
1980
|
+
for (const target of targets) {
|
|
1981
|
+
if (target.fk.onDelete !== "restrict") continue;
|
|
1982
|
+
const childFilter = this.buildCascadeChildFilter(records, target.fk);
|
|
1983
|
+
if (!childFilter) continue;
|
|
1984
|
+
restrictChecks.push(target.count(childFilter).then((count) => {
|
|
1985
|
+
if (count > 0) throw new DbError("CONFLICT", [{
|
|
1986
|
+
path: tableName,
|
|
1987
|
+
message: `Cannot delete from "${tableName}": ${count} record(s) in "${target.childTable}" (${target.fk.fields.join(", ")}) reference it (RESTRICT)`
|
|
1988
|
+
}]);
|
|
1989
|
+
}));
|
|
1990
|
+
}
|
|
1991
|
+
if (restrictChecks.length > 0) await Promise.all(restrictChecks);
|
|
1992
|
+
for (const target of targets) {
|
|
1993
|
+
const action = target.fk.onDelete;
|
|
1994
|
+
if (!action || action === "noAction" || action === "restrict") continue;
|
|
1995
|
+
const childFilter = this.buildCascadeChildFilter(records, target.fk);
|
|
1996
|
+
if (!childFilter) continue;
|
|
1997
|
+
switch (action) {
|
|
1998
|
+
case "cascade":
|
|
1999
|
+
await target.deleteMany(childFilter);
|
|
2000
|
+
break;
|
|
2001
|
+
case "setNull": {
|
|
2002
|
+
const nullData = {};
|
|
2003
|
+
for (const f of target.fk.fields) nullData[f] = null;
|
|
2004
|
+
await target.updateMany(childFilter, nullData);
|
|
2005
|
+
break;
|
|
2006
|
+
}
|
|
2007
|
+
}
|
|
2008
|
+
}
|
|
2009
|
+
});
|
|
2010
|
+
} finally {
|
|
2011
|
+
for (const key of addedKeys) visited.delete(key);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
needsCascade(cascadeResolver) {
|
|
2015
|
+
return !!cascadeResolver;
|
|
2016
|
+
}
|
|
2017
|
+
recordKey(tableName, pkFields, record) {
|
|
2018
|
+
let key = tableName;
|
|
2019
|
+
for (const f of pkFields) {
|
|
2020
|
+
const v = record[f];
|
|
2021
|
+
key += `\0${v === null || v === void 0 ? "" : String(v)}`;
|
|
2022
|
+
}
|
|
2023
|
+
return key;
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* Builds a filter for child records whose FK matches the deleted parent's PK values.
|
|
2027
|
+
*/
|
|
2028
|
+
buildCascadeChildFilter(parentRecords, fk) {
|
|
2029
|
+
if (fk.fields.length === 1 && fk.targetFields.length === 1) {
|
|
2030
|
+
const pkField = fk.targetFields[0];
|
|
2031
|
+
const values = parentRecords.map((r) => r[pkField]).filter((v) => v !== void 0 && v !== null);
|
|
2032
|
+
if (values.length === 0) return;
|
|
2033
|
+
return values.length === 1 ? { [fk.fields[0]]: values[0] } : { [fk.fields[0]]: { $in: values } };
|
|
2034
|
+
}
|
|
2035
|
+
const orFilters = [];
|
|
2036
|
+
for (const record of parentRecords) {
|
|
2037
|
+
const condition = {};
|
|
2038
|
+
let valid = true;
|
|
2039
|
+
for (let i = 0; i < fk.fields.length; i++) {
|
|
2040
|
+
const val = record[fk.targetFields[i]];
|
|
2041
|
+
if (val === void 0 || val === null) {
|
|
2042
|
+
valid = false;
|
|
2043
|
+
break;
|
|
2044
|
+
}
|
|
2045
|
+
condition[fk.fields[i]] = val;
|
|
2046
|
+
}
|
|
2047
|
+
if (valid) orFilters.push(condition);
|
|
2048
|
+
}
|
|
2049
|
+
if (orFilters.length === 0) return;
|
|
2050
|
+
return orFilters.length === 1 ? orFilters[0] : { $or: orFilters };
|
|
2051
|
+
}
|
|
2052
|
+
};
|
|
2053
|
+
//#endregion
|
|
2054
|
+
//#region src/patch/patch-types.ts
|
|
2055
|
+
/**
|
|
2056
|
+
* Extracts `@expect.array.key` properties from an array-of-objects type.
|
|
2057
|
+
* These keys uniquely identify an element inside the array and are used
|
|
2058
|
+
* for `$update`, `$remove`, and `$upsert` operations.
|
|
2059
|
+
*
|
|
2060
|
+
* @param def - Atscript array type definition.
|
|
2061
|
+
* @returns Set of property names marked as keys; empty set if none.
|
|
2062
|
+
*/
|
|
2063
|
+
function getKeyProps(def) {
|
|
2064
|
+
if (def.type.of.type.kind === "object") {
|
|
2065
|
+
const objType = def.type.of.type;
|
|
2066
|
+
const keyProps = /* @__PURE__ */ new Set();
|
|
2067
|
+
for (const [key, val] of objType.props.entries()) if (val.metadata.get("expect.array.key")) keyProps.add(key);
|
|
2068
|
+
return keyProps;
|
|
2069
|
+
}
|
|
2070
|
+
return /* @__PURE__ */ new Set();
|
|
2071
|
+
}
|
|
2072
|
+
//#endregion
|
|
2073
|
+
//#region src/db-validator-plugin.ts
|
|
2074
|
+
/** Set of recognised array‑patch operator keys. */
|
|
2075
|
+
const PATCH_OPS = new Set([
|
|
2076
|
+
"$replace",
|
|
2077
|
+
"$insert",
|
|
2078
|
+
"$upsert",
|
|
2079
|
+
"$update",
|
|
2080
|
+
"$remove"
|
|
2081
|
+
]);
|
|
2082
|
+
/**
|
|
2083
|
+
* Returns `true` when `value` looks like a patch‑operator object
|
|
2084
|
+
* (at least one key is a recognised operator and no unknown keys).
|
|
2085
|
+
*/
|
|
2086
|
+
function isPatchOperatorObject(value) {
|
|
2087
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
2088
|
+
const keys = Object.keys(value);
|
|
2089
|
+
if (keys.length === 0) return false;
|
|
2090
|
+
return keys.every((k) => PATCH_OPS.has(k));
|
|
2091
|
+
}
|
|
2092
|
+
/**
|
|
2093
|
+
* Validator plugin for database operations.
|
|
2094
|
+
*
|
|
2095
|
+
* Handles navigation field constraints and delegates to the standard validator
|
|
2096
|
+
* for type checking. The annotated type tree already includes nav fields with
|
|
2097
|
+
* their full target types — this plugin controls WHEN recursion is allowed
|
|
2098
|
+
* based on the operation mode (insert/replace/patch).
|
|
2099
|
+
*
|
|
2100
|
+
* Replaces the old `navFieldsValidatorPlugin` (which blindly skipped all nav
|
|
2101
|
+
* fields) and `_checkNavProps()` (which validated constraints separately).
|
|
2102
|
+
*/
|
|
2103
|
+
function createDbValidatorPlugin() {
|
|
2104
|
+
return (ctx, def, value) => {
|
|
2105
|
+
const dbCtx = ctx.context;
|
|
2106
|
+
if (!dbCtx) return;
|
|
2107
|
+
const isTo = def.metadata.has("db.rel.to");
|
|
2108
|
+
const isFrom = def.metadata.has("db.rel.from");
|
|
2109
|
+
const isVia = def.metadata.has("db.rel.via");
|
|
2110
|
+
if (isTo || isFrom || isVia) return handleNavField(ctx, def, value, dbCtx, isTo, isFrom, isVia);
|
|
2111
|
+
if (dbCtx.mode === "patch" && isDbFieldOp(value)) return true;
|
|
2112
|
+
if (dbCtx.mode === "patch" && def.type.kind === "array" && dbCtx.flatMap) {
|
|
2113
|
+
const flatEntry = dbCtx.flatMap.get(ctx.path);
|
|
2114
|
+
if (flatEntry?.metadata?.has("db.__topLevelArray") && !flatEntry.metadata.has("db.json")) return handleArrayPatch(ctx, def, value);
|
|
2115
|
+
}
|
|
2116
|
+
};
|
|
2117
|
+
}
|
|
2118
|
+
function handleNavField(ctx, def, value, dbCtx, isTo, isFrom, isVia) {
|
|
2119
|
+
const pathParts = ctx.path.split(".");
|
|
2120
|
+
const fieldName = pathParts[pathParts.length - 1] || ctx.path;
|
|
2121
|
+
if (value === null) {
|
|
2122
|
+
ctx.error(`Cannot process null navigation property '${fieldName}'`);
|
|
2123
|
+
return false;
|
|
2124
|
+
}
|
|
2125
|
+
if (value === void 0) return true;
|
|
2126
|
+
if (dbCtx.mode === "patch") {
|
|
2127
|
+
if (isFrom || isVia) {
|
|
2128
|
+
if (isPatchOperatorObject(value)) return validateNavPatchOps(ctx, def, value, fieldName);
|
|
2129
|
+
const relType = isFrom ? "1:N" : "M:N";
|
|
2130
|
+
ctx.error(`Cannot patch ${relType} relation '${fieldName}' with a plain value — use patch operators ({ $insert, $remove, $replace, $update, $upsert })`);
|
|
2131
|
+
return false;
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
if (isVia) return true;
|
|
2135
|
+
}
|
|
2136
|
+
/**
|
|
2137
|
+
* Validates patch operator values against the nav field's target array type.
|
|
2138
|
+
* Each operator's items are validated against the element type.
|
|
2139
|
+
*/
|
|
2140
|
+
function validateNavPatchOps(ctx, def, ops, fieldName) {
|
|
2141
|
+
if (def.type.kind !== "array") {
|
|
2142
|
+
ctx.error(`Cannot use patch operators on non-array relation '${fieldName}'`);
|
|
2143
|
+
return false;
|
|
2144
|
+
}
|
|
2145
|
+
const arrayDef = def;
|
|
2146
|
+
for (const op of [
|
|
2147
|
+
"$replace",
|
|
2148
|
+
"$insert",
|
|
2149
|
+
"$upsert"
|
|
2150
|
+
]) if (ops[op] !== void 0) {
|
|
2151
|
+
if (!ctx.validateAnnotatedType(arrayDef, ops[op])) return false;
|
|
2152
|
+
}
|
|
2153
|
+
for (const op of ["$update", "$remove"]) if (ops[op] !== void 0) {
|
|
2154
|
+
if (!validatePartialItems(ctx, arrayDef, ops[op], op, true)) return false;
|
|
2155
|
+
}
|
|
2156
|
+
return true;
|
|
2157
|
+
}
|
|
2158
|
+
/**
|
|
2159
|
+
* Handles patch‑mode validation for top‑level embedded arrays.
|
|
2160
|
+
*
|
|
2161
|
+
* When the incoming value is:
|
|
2162
|
+
* - A plain array → falls through to default array validation ($replace semantics)
|
|
2163
|
+
* - A patch operator object → validates each operator's payload individually
|
|
2164
|
+
*/
|
|
2165
|
+
function handleArrayPatch(ctx, def, value) {
|
|
2166
|
+
if (Array.isArray(value)) return;
|
|
2167
|
+
if (typeof value !== "object" || value === null) return;
|
|
2168
|
+
const ops = value;
|
|
2169
|
+
const keys = Object.keys(ops);
|
|
2170
|
+
if (keys.length === 0 || !keys.every((k) => PATCH_OPS.has(k))) {
|
|
2171
|
+
if (keys.some((k) => PATCH_OPS.has(k))) {
|
|
2172
|
+
const unknown = keys.filter((k) => !PATCH_OPS.has(k));
|
|
2173
|
+
ctx.error(`Unknown patch operator(s): ${unknown.join(", ")}. Allowed: $replace, $insert, $upsert, $update, $remove`);
|
|
2174
|
+
return false;
|
|
2175
|
+
}
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
for (const op of [
|
|
2179
|
+
"$replace",
|
|
2180
|
+
"$insert",
|
|
2181
|
+
"$upsert"
|
|
2182
|
+
]) if (ops[op] !== void 0) {
|
|
2183
|
+
if (!ctx.validateAnnotatedType(def, ops[op])) return false;
|
|
2184
|
+
}
|
|
2185
|
+
const isMerge = def.metadata.get("db.patch.strategy") === "merge";
|
|
2186
|
+
for (const op of ["$update", "$remove"]) if (ops[op] !== void 0) {
|
|
2187
|
+
if (!validatePartialItems(ctx, def, ops[op], op, isMerge)) return false;
|
|
2188
|
+
}
|
|
2189
|
+
return true;
|
|
2190
|
+
}
|
|
2191
|
+
/**
|
|
2192
|
+
* Validates `$update` / `$remove` items.
|
|
2193
|
+
*
|
|
2194
|
+
* Each item must be an object. For object arrays with `@expect.array.key` fields,
|
|
2195
|
+
* key properties are required and non‑key properties are validated but optional.
|
|
2196
|
+
* For primitive arrays, items are validated directly against the element type.
|
|
2197
|
+
*/
|
|
2198
|
+
function validatePartialItems(ctx, arrayDef, items, op, isMerge) {
|
|
2199
|
+
if (!Array.isArray(items)) {
|
|
2200
|
+
ctx.error(`${op} must be an array`);
|
|
2201
|
+
return false;
|
|
2202
|
+
}
|
|
2203
|
+
const elementDef = arrayDef.type.of;
|
|
2204
|
+
if (elementDef.type.kind !== "object") return ctx.validateAnnotatedType(arrayDef, items);
|
|
2205
|
+
const keyProps = getKeyProps(arrayDef);
|
|
2206
|
+
for (let i = 0; i < items.length; i++) {
|
|
2207
|
+
const item = items[i];
|
|
2208
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
2209
|
+
ctx.error(`${op}[${i}]: expected object`);
|
|
2210
|
+
return false;
|
|
2211
|
+
}
|
|
2212
|
+
const rec = item;
|
|
2213
|
+
if (keyProps.size > 0) {
|
|
2214
|
+
for (const kp of keyProps) if (rec[kp] === void 0 || rec[kp] === null) {
|
|
2215
|
+
ctx.error(`${op}[${i}]: key field '${kp}' is required`);
|
|
2216
|
+
return false;
|
|
2217
|
+
}
|
|
2218
|
+
}
|
|
2219
|
+
const objType = elementDef.type;
|
|
2220
|
+
for (const [key, val] of Object.entries(rec)) {
|
|
2221
|
+
const propDef = objType.props.get(key);
|
|
2222
|
+
if (propDef) {
|
|
2223
|
+
if (!ctx.validateAnnotatedType(propDef, val)) return false;
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2226
|
+
if (op === "$update" && isMerge !== true) {
|
|
2227
|
+
for (const [propName, propDef] of objType.props) if (!propDef.optional && !keyProps.has(propName) && rec[propName] === void 0) {
|
|
2228
|
+
ctx.error(`${op}[${i}]: field '${propName}' is required (replace strategy)`);
|
|
2229
|
+
return false;
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
return true;
|
|
2234
|
+
}
|
|
2235
|
+
//#endregion
|
|
2236
|
+
//#region src/patch/array-ops-resolver.ts
|
|
2237
|
+
/**
|
|
2238
|
+
* Resolves array patch operator keys (`__$insert`, `__$remove`, `__$upsert`,
|
|
2239
|
+
* `__$update`, `__$keys`) in a decomposed update into plain resolved arrays.
|
|
2240
|
+
*
|
|
2241
|
+
* This is the generic fallback for adapters that don't support native patch
|
|
2242
|
+
* operations (e.g., SQLite). It performs a read‑modify‑write:
|
|
2243
|
+
*
|
|
2244
|
+
* 1. Detects `__$` operator keys in the update object
|
|
2245
|
+
* 2. Reads the current record to get existing array values
|
|
2246
|
+
* 3. Applies operations in‑memory
|
|
2247
|
+
* 4. Replaces operator keys with resolved plain values
|
|
2248
|
+
*
|
|
2249
|
+
* @param update - The decomposed update (from `decomposePatch`), potentially
|
|
2250
|
+
* containing `field.__$insert`, `field.__$remove`, etc.
|
|
2251
|
+
* @param currentRecord - The current record fetched from the database (only the
|
|
2252
|
+
* fields that have array ops). Can be `null` if the record doesn't exist.
|
|
2253
|
+
* @param table - The AtscriptDbTable for metadata access (key props, unique items).
|
|
2254
|
+
* @returns A new update object with all `__$` keys resolved to plain values.
|
|
2255
|
+
*/
|
|
2256
|
+
function resolveArrayOps(update, currentRecord, table) {
|
|
2257
|
+
const resolved = {};
|
|
2258
|
+
const opsMap = /* @__PURE__ */ new Map();
|
|
2259
|
+
const seenOpsFields = /* @__PURE__ */ new Set();
|
|
2260
|
+
for (const [key, value] of Object.entries(update)) {
|
|
2261
|
+
const match = key.match(/^(.+?)\.__\$(.+)$/);
|
|
2262
|
+
if (!match) {
|
|
2263
|
+
resolved[key] = value;
|
|
2264
|
+
continue;
|
|
2265
|
+
}
|
|
2266
|
+
const field = match[1];
|
|
2267
|
+
const op = match[2];
|
|
2268
|
+
seenOpsFields.add(field);
|
|
2269
|
+
let fieldOps = opsMap.get(field);
|
|
2270
|
+
if (!fieldOps) {
|
|
2271
|
+
fieldOps = {};
|
|
2272
|
+
opsMap.set(field, fieldOps);
|
|
2273
|
+
}
|
|
2274
|
+
switch (op) {
|
|
2275
|
+
case "insert":
|
|
2276
|
+
fieldOps.insert = value;
|
|
2277
|
+
break;
|
|
2278
|
+
case "remove":
|
|
2279
|
+
fieldOps.remove = value;
|
|
2280
|
+
break;
|
|
2281
|
+
case "upsert":
|
|
2282
|
+
fieldOps.upsert = value;
|
|
2283
|
+
break;
|
|
2284
|
+
case "update":
|
|
2285
|
+
fieldOps.update = value;
|
|
2286
|
+
break;
|
|
2287
|
+
case "keys":
|
|
2288
|
+
fieldOps.keys = value;
|
|
2289
|
+
break;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
for (const [field, ops] of opsMap) {
|
|
2293
|
+
const raw = currentRecord?.[field] ?? [];
|
|
2294
|
+
const current = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
2295
|
+
const arrayType = table.flatMap.get(field);
|
|
2296
|
+
const keyProps = arrayType ? getKeyProps(arrayType) : /* @__PURE__ */ new Set();
|
|
2297
|
+
const uniqueItems = arrayType?.metadata?.get("expect.array.uniqueItems");
|
|
2298
|
+
const mergeStrategy = arrayType?.metadata?.get("db.patch.strategy") === "merge";
|
|
2299
|
+
resolved[field] = applyOps(current, ops, ops.keys && ops.keys.length > 0 ? new Set(ops.keys) : keyProps, !!uniqueItems, mergeStrategy);
|
|
2300
|
+
}
|
|
2301
|
+
return resolved;
|
|
2302
|
+
}
|
|
2303
|
+
/**
|
|
2304
|
+
* Extracts the set of field names that have array ops in the update.
|
|
2305
|
+
* Used to determine which fields need to be fetched from the current record.
|
|
2306
|
+
*/
|
|
2307
|
+
function getArrayOpsFields(update) {
|
|
2308
|
+
const fields = /* @__PURE__ */ new Set();
|
|
2309
|
+
for (const key of Object.keys(update)) {
|
|
2310
|
+
const match = key.match(/^(.+?)\.__\$(.+)$/);
|
|
2311
|
+
if (match) fields.add(match[1]);
|
|
2312
|
+
}
|
|
2313
|
+
return fields;
|
|
2314
|
+
}
|
|
2315
|
+
/**
|
|
2316
|
+
* Applies patch operations to an array in‑memory.
|
|
2317
|
+
* Order: remove → update → upsert → insert (most intuitive semantics).
|
|
2318
|
+
*/
|
|
2319
|
+
function applyOps(current, ops, keyProps, uniqueItems, mergeStrategy) {
|
|
2320
|
+
let result = [...current];
|
|
2321
|
+
if (ops.remove) result = applyRemove(result, ops.remove, keyProps);
|
|
2322
|
+
if (ops.update) result = applyUpdate(result, ops.update, keyProps);
|
|
2323
|
+
if (ops.upsert) result = applyUpsert(result, ops.upsert, keyProps, mergeStrategy);
|
|
2324
|
+
if (ops.insert) result = applyInsert(result, ops.insert, keyProps, uniqueItems);
|
|
2325
|
+
return result;
|
|
2326
|
+
}
|
|
2327
|
+
function applyRemove(arr, items, keyProps) {
|
|
2328
|
+
if (keyProps.size === 0) {
|
|
2329
|
+
const removeSet = new Set(items.map((i) => JSON.stringify(i)));
|
|
2330
|
+
return arr.filter((el) => !removeSet.has(JSON.stringify(el)));
|
|
2331
|
+
}
|
|
2332
|
+
return arr.filter((el) => {
|
|
2333
|
+
const elObj = el;
|
|
2334
|
+
return !items.some((item) => matchByKeys(elObj, item, keyProps));
|
|
2335
|
+
});
|
|
2336
|
+
}
|
|
2337
|
+
function applyUpdate(arr, items, keyProps) {
|
|
2338
|
+
if (keyProps.size === 0) return arr;
|
|
2339
|
+
return arr.map((el) => {
|
|
2340
|
+
const elObj = el;
|
|
2341
|
+
const match = items.find((item) => matchByKeys(elObj, item, keyProps));
|
|
2342
|
+
if (match) return {
|
|
2343
|
+
...elObj,
|
|
2344
|
+
...match
|
|
2345
|
+
};
|
|
2346
|
+
return el;
|
|
2347
|
+
});
|
|
2348
|
+
}
|
|
2349
|
+
function applyUpsert(arr, items, keyProps, mergeStrategy) {
|
|
2350
|
+
const result = [...arr];
|
|
2351
|
+
for (const item of items) if (keyProps.size === 0) {
|
|
2352
|
+
const idx = result.findIndex((el) => JSON.stringify(el) === JSON.stringify(item));
|
|
2353
|
+
if (idx >= 0) result[idx] = item;
|
|
2354
|
+
else result.push(item);
|
|
2355
|
+
} else {
|
|
2356
|
+
const itemObj = item;
|
|
2357
|
+
const idx = result.findIndex((el) => matchByKeys(el, itemObj, keyProps));
|
|
2358
|
+
if (idx >= 0) result[idx] = mergeStrategy ? {
|
|
2359
|
+
...result[idx],
|
|
2360
|
+
...itemObj
|
|
2361
|
+
} : itemObj;
|
|
2362
|
+
else result.push(item);
|
|
2363
|
+
}
|
|
2364
|
+
return result;
|
|
2365
|
+
}
|
|
2366
|
+
function applyInsert(arr, items, keyProps, uniqueItems) {
|
|
2367
|
+
if (!uniqueItems) return [...arr, ...items];
|
|
2368
|
+
const result = [...arr];
|
|
2369
|
+
for (const item of items) if (!(keyProps.size > 0 ? result.some((el) => matchByKeys(el, item, keyProps)) : result.some((el) => JSON.stringify(el) === JSON.stringify(item)))) result.push(item);
|
|
2370
|
+
return result;
|
|
2371
|
+
}
|
|
2372
|
+
function matchByKeys(a, b, keys) {
|
|
2373
|
+
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
2374
|
+
return true;
|
|
2375
|
+
}
|
|
2376
|
+
//#endregion
|
|
2377
|
+
//#region src/patch/patch-decomposer.ts
|
|
2378
|
+
/**
|
|
2379
|
+
* Decomposes a patch payload into a flat update object for adapters
|
|
2380
|
+
* that don't support native patch operations.
|
|
2381
|
+
*
|
|
2382
|
+
* Handles:
|
|
2383
|
+
* - Top-level array patches (`$replace`, `$insert`, `$upsert`, `$update`, `$remove`)
|
|
2384
|
+
* - Merge strategy for nested objects
|
|
2385
|
+
* - Simple field sets
|
|
2386
|
+
*
|
|
2387
|
+
* For adapters with native patch support (e.g., MongoDB aggregation pipelines),
|
|
2388
|
+
* use {@link BaseDbAdapter.nativePatch} instead.
|
|
2389
|
+
*
|
|
2390
|
+
* @param payload - The patch payload from the user.
|
|
2391
|
+
* @param table - The AtscriptDbTable instance for metadata access.
|
|
2392
|
+
* @returns A flat update object suitable for a basic `updateOne` call.
|
|
2393
|
+
*/
|
|
2394
|
+
function decomposePatch(payload, table) {
|
|
2395
|
+
const update = {};
|
|
2396
|
+
flattenPatchPayload(payload, "", update, table, "db.__topLevelArray");
|
|
2397
|
+
return update;
|
|
2398
|
+
}
|
|
2399
|
+
function flattenPatchPayload(payload, prefix, update, table, topLevelArrayTag) {
|
|
2400
|
+
for (const [_key, value] of Object.entries(payload)) {
|
|
2401
|
+
const key = prefix ? `${prefix}.${_key}` : _key;
|
|
2402
|
+
if (table.primaryKeys.includes(key)) continue;
|
|
2403
|
+
const flatType = table.flatMap.get(key);
|
|
2404
|
+
const isTopLevelArray = flatType?.metadata?.get(topLevelArrayTag);
|
|
2405
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value) && isTopLevelArray && !flatType?.metadata?.has("db.json")) decomposeArrayPatch(key, value, flatType, update, table);
|
|
2406
|
+
else if (typeof value === "object" && value !== null && !Array.isArray(value) && flatType?.metadata?.get("db.patch.strategy") === "merge") flattenPatchPayload(value, key, update, table, topLevelArrayTag);
|
|
2407
|
+
else update[key] = value;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
/**
|
|
2411
|
+
* Decomposes array patch operators into simple field updates.
|
|
2412
|
+
*
|
|
2413
|
+
* For adapters without native array operations, this does a best-effort
|
|
2414
|
+
* decomposition:
|
|
2415
|
+
* - `$replace` → direct set
|
|
2416
|
+
* - `$insert` → value to append (adapter must handle)
|
|
2417
|
+
* - `$upsert` → value to upsert by key (adapter must handle)
|
|
2418
|
+
* - `$update` → value to update by key (adapter must handle)
|
|
2419
|
+
* - `$remove` → value to remove by key (adapter must handle)
|
|
2420
|
+
*
|
|
2421
|
+
* Note: For full correctness with `$insert`/`$upsert`/`$update`/`$remove`,
|
|
2422
|
+
* the adapter should implement native patch support. This generic decomposition
|
|
2423
|
+
* handles `$replace` directly and stores the other operations in a structured
|
|
2424
|
+
* format the adapter can interpret.
|
|
2425
|
+
*/
|
|
2426
|
+
function decomposeArrayPatch(key, value, fieldType, update, _table) {
|
|
2427
|
+
const keyProps = fieldType.type.kind === "array" ? getKeyProps(fieldType) : /* @__PURE__ */ new Set();
|
|
2428
|
+
if (value.$replace !== void 0) {
|
|
2429
|
+
update[key] = value.$replace;
|
|
2430
|
+
return;
|
|
2431
|
+
}
|
|
2432
|
+
if (value.$insert !== void 0) update[`${key}.__$insert`] = value.$insert;
|
|
2433
|
+
if (value.$upsert !== void 0) update[`${key}.__$upsert`] = value.$upsert;
|
|
2434
|
+
if (value.$update !== void 0) update[`${key}.__$update`] = value.$update;
|
|
2435
|
+
if (value.$remove !== void 0) update[`${key}.__$remove`] = value.$remove;
|
|
2436
|
+
if (keyProps.size > 0 && (value.$upsert !== void 0 || value.$update !== void 0 || value.$remove !== void 0)) update[`${key}.__$keys`] = [...keyProps];
|
|
2437
|
+
}
|
|
2438
|
+
//#endregion
|
|
2439
|
+
//#region src/table/db-table.ts
|
|
2440
|
+
/**
|
|
2441
|
+
* Generic database table abstraction driven by Atscript `@db.*` annotations.
|
|
2442
|
+
*
|
|
2443
|
+
* Extends {@link AtscriptDbReadable} (read operations, field metadata, query
|
|
2444
|
+
* translation, relation loading) with write operations, validators, and
|
|
2445
|
+
* schema management.
|
|
2446
|
+
*
|
|
2447
|
+
* ```typescript
|
|
2448
|
+
* const adapter = new MongoAdapter(db)
|
|
2449
|
+
* const users = new AtscriptDbTable(UsersType, adapter)
|
|
2450
|
+
* await users.insertOne({ name: 'John', email: 'john@example.com' })
|
|
2451
|
+
* ```
|
|
2452
|
+
*
|
|
2453
|
+
* @typeParam T - The Atscript annotated type for this table.
|
|
2454
|
+
* @typeParam DataType - The inferred data shape from the annotated type.
|
|
2455
|
+
*/
|
|
2456
|
+
/** Zero-allocation emptiness check for objects. */
|
|
2457
|
+
function _isEmptyObj(obj) {
|
|
2458
|
+
for (const _ in obj) return false;
|
|
2459
|
+
return true;
|
|
2460
|
+
}
|
|
2461
|
+
/** Translates a single ops record from logical to physical column names. */
|
|
2462
|
+
function _translateOpsRecord(rec, meta) {
|
|
2463
|
+
const out = {};
|
|
2464
|
+
for (const key in rec) out[meta.leafByLogical.get(key)?.physicalName ?? key] = rec[key];
|
|
2465
|
+
return out;
|
|
2466
|
+
}
|
|
2467
|
+
/** Translates ops keys from logical field names to physical column names. */
|
|
2468
|
+
function _translateOpsKeys(ops, meta) {
|
|
2469
|
+
return {
|
|
2470
|
+
inc: ops.inc ? _translateOpsRecord(ops.inc, meta) : void 0,
|
|
2471
|
+
mul: ops.mul ? _translateOpsRecord(ops.mul, meta) : void 0
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Forces nav fields non-optional so the plugin handles null/undefined
|
|
2476
|
+
* checks (validator skips optional+null before plugins run).
|
|
2477
|
+
*/
|
|
2478
|
+
function forceNavNonOptional(type) {
|
|
2479
|
+
if (type.metadata?.has("db.rel.to") || type.metadata?.has("db.rel.from") || type.metadata?.has("db.rel.via")) return type.optional ? {
|
|
2480
|
+
...type,
|
|
2481
|
+
optional: false
|
|
2482
|
+
} : type;
|
|
2483
|
+
return type;
|
|
2484
|
+
}
|
|
2485
|
+
/** Makes PK, defaulted, and FK fields optional; forces nav fields non-optional. */
|
|
2486
|
+
function insertReplace(type) {
|
|
2487
|
+
if (type.metadata?.has("meta.id") || type.metadata?.has("db.default") || type.metadata?.has("db.default.increment") || type.metadata?.has("db.default.uuid") || type.metadata?.has("db.default.now") || type.metadata?.has("db.rel.FK")) return {
|
|
2488
|
+
...type,
|
|
2489
|
+
optional: true
|
|
2490
|
+
};
|
|
2491
|
+
return forceNavNonOptional(type);
|
|
2492
|
+
}
|
|
2493
|
+
var AtscriptDbTable = class extends AtscriptDbReadable {
|
|
2494
|
+
_cascadeResolver;
|
|
2495
|
+
_fkLookupResolver;
|
|
2496
|
+
_integrity;
|
|
2497
|
+
validators = /* @__PURE__ */ new Map();
|
|
2498
|
+
constructor(_type, adapter, logger, _tableResolver, _writeTableResolver) {
|
|
2499
|
+
super(_type, adapter, logger, _tableResolver);
|
|
2500
|
+
if (_writeTableResolver) this._writeTableResolver = _writeTableResolver;
|
|
2501
|
+
this._integrity = adapter.supportsNativeForeignKeys() ? new NativeIntegrity() : new ApplicationIntegrity();
|
|
2502
|
+
}
|
|
2503
|
+
/**
|
|
2504
|
+
* Sets the cascade resolver for application-level cascade deletes.
|
|
2505
|
+
* Called by DbSpace after table creation.
|
|
2506
|
+
*/
|
|
2507
|
+
setCascadeResolver(resolver) {
|
|
2508
|
+
this._cascadeResolver = resolver;
|
|
2509
|
+
}
|
|
2510
|
+
/**
|
|
2511
|
+
* Sets the FK lookup resolver for application-level FK validation.
|
|
2512
|
+
* Called by DbSpace after table creation.
|
|
2513
|
+
*/
|
|
2514
|
+
setFkLookupResolver(resolver) {
|
|
2515
|
+
this._fkLookupResolver = resolver;
|
|
2516
|
+
}
|
|
2517
|
+
/**
|
|
2518
|
+
* Returns a cached validator for the given purpose.
|
|
2519
|
+
* Built with adapter plugins from {@link BaseDbAdapter.getValidatorPlugins}.
|
|
2520
|
+
*
|
|
2521
|
+
* Standard purposes: `'insert'`, `'update'`, `'patch'`.
|
|
2522
|
+
* Adapters may define additional purposes.
|
|
2523
|
+
*/
|
|
2524
|
+
getValidator(purpose) {
|
|
2525
|
+
if (!this.validators.has(purpose)) {
|
|
2526
|
+
const validator = this._buildValidator(purpose);
|
|
2527
|
+
this.validators.set(purpose, validator);
|
|
2528
|
+
}
|
|
2529
|
+
return this.validators.get(purpose);
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Inserts a single record. Delegates to {@link insertMany} for unified
|
|
2533
|
+
* nested creation support.
|
|
2534
|
+
*/
|
|
2535
|
+
async insertOne(payload, opts) {
|
|
2536
|
+
return { insertedId: (await this.insertMany([payload], opts)).insertedIds[0] };
|
|
2537
|
+
}
|
|
2538
|
+
/**
|
|
2539
|
+
* Inserts multiple records with batch-optimized nested creation.
|
|
2540
|
+
*
|
|
2541
|
+
* Supports **nested creation**: if payloads include data for navigation
|
|
2542
|
+
* fields (`@db.rel.to` / `@db.rel.from`), related records are created
|
|
2543
|
+
* automatically in batches. TO dependencies are batch-created first
|
|
2544
|
+
* (their PKs become our FKs), FROM dependents are batch-created after
|
|
2545
|
+
* (they receive our PKs as their FKs). Fully recursive — nested records
|
|
2546
|
+
* with their own nav data trigger further batch inserts at each level.
|
|
2547
|
+
* Recursive up to `maxDepth` (default 3).
|
|
2548
|
+
*/
|
|
2549
|
+
async insertMany(payloads, opts) {
|
|
2550
|
+
this._ensureBuilt();
|
|
2551
|
+
const maxDepth = opts?.maxDepth ?? 3;
|
|
2552
|
+
const depth = opts?._depth ?? 0;
|
|
2553
|
+
const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
|
|
2554
|
+
if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
|
|
2555
|
+
return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
|
|
2556
|
+
const items = payloads.map((p) => this._applyDefaults({ ...p }));
|
|
2557
|
+
validateBatch(this.getValidator("insert"), items, { mode: "insert" });
|
|
2558
|
+
const host = this;
|
|
2559
|
+
if (canNest) await batchInsertNestedTo(host, items, maxDepth, depth);
|
|
2560
|
+
const prepared = [];
|
|
2561
|
+
for (const data of items) {
|
|
2562
|
+
for (const navField of this._meta.navFields) delete data[navField];
|
|
2563
|
+
prepared.push(this._fieldMapper.prepareForWrite(data, this._meta, this.adapter));
|
|
2564
|
+
}
|
|
2565
|
+
await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
|
|
2566
|
+
if (canNest) await preValidateNestedFrom(host, payloads);
|
|
2567
|
+
const result = await this.adapter.insertMany(prepared);
|
|
2568
|
+
if (canNest) await batchInsertNestedFrom(host, payloads, result.insertedIds, maxDepth, depth);
|
|
2569
|
+
if (canNest) await batchInsertNestedVia(host, payloads, result.insertedIds, maxDepth, depth);
|
|
2570
|
+
return result;
|
|
2571
|
+
}));
|
|
2572
|
+
}
|
|
2573
|
+
/**
|
|
2574
|
+
* Replaces a single record identified by primary key(s).
|
|
2575
|
+
* Delegates to {@link bulkReplace} for unified nested relation support.
|
|
2576
|
+
*/
|
|
2577
|
+
async replaceOne(payload, opts) {
|
|
2578
|
+
return this.bulkReplace([payload], opts);
|
|
2579
|
+
}
|
|
2580
|
+
/**
|
|
2581
|
+
* Replaces multiple records with deep nested relation support.
|
|
2582
|
+
*
|
|
2583
|
+
* Supports all relation types (TO, FROM, VIA). TO dependencies are
|
|
2584
|
+
* replaced first (their PKs become our FKs), FROM dependents are replaced
|
|
2585
|
+
* after (they receive our PKs as their FKs), VIA relations clear and
|
|
2586
|
+
* re-create junction rows. Fully recursive up to `maxDepth` (default 3).
|
|
2587
|
+
*/
|
|
2588
|
+
async bulkReplace(payloads, opts) {
|
|
2589
|
+
this._ensureBuilt();
|
|
2590
|
+
const maxDepth = opts?.maxDepth ?? 3;
|
|
2591
|
+
const depth = opts?._depth ?? 0;
|
|
2592
|
+
const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
|
|
2593
|
+
if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
|
|
2594
|
+
return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
|
|
2595
|
+
const items = payloads.map((p) => this._applyDefaults({ ...p }));
|
|
2596
|
+
const originals = canNest ? payloads.map((p) => ({ ...p })) : [];
|
|
2597
|
+
validateBatch(this.getValidator("bulkReplace"), items, { mode: "replace" });
|
|
2598
|
+
const host = this;
|
|
2599
|
+
if (canNest) await batchReplaceNestedTo(host, items, maxDepth, depth);
|
|
2600
|
+
await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver);
|
|
2601
|
+
if (canNest) await preValidateNestedFrom(host, originals);
|
|
2602
|
+
let matchedCount = 0;
|
|
2603
|
+
let modifiedCount = 0;
|
|
2604
|
+
for (const data of items) {
|
|
2605
|
+
for (const navField of this._meta.navFields) delete data[navField];
|
|
2606
|
+
const filter = this._extractPrimaryKeyFilter(data);
|
|
2607
|
+
const prepared = this._fieldMapper.prepareForWrite(data, this._meta, this.adapter);
|
|
2608
|
+
const result = await this.adapter.replaceOne(this._fieldMapper.translateFilter(filter, this._meta), prepared);
|
|
2609
|
+
matchedCount += result.matchedCount;
|
|
2610
|
+
modifiedCount += result.modifiedCount;
|
|
2611
|
+
}
|
|
2612
|
+
if (canNest) await batchReplaceNestedFrom(host, originals, maxDepth, depth);
|
|
2613
|
+
if (canNest) await batchReplaceNestedVia(host, originals, maxDepth, depth);
|
|
2614
|
+
return {
|
|
2615
|
+
matchedCount,
|
|
2616
|
+
modifiedCount
|
|
2617
|
+
};
|
|
2618
|
+
}));
|
|
2619
|
+
}
|
|
2620
|
+
/**
|
|
2621
|
+
* Partially updates a single record identified by primary key(s).
|
|
2622
|
+
* Delegates to {@link bulkUpdate} for unified nested relation support.
|
|
2623
|
+
*/
|
|
2624
|
+
async updateOne(payload, opts) {
|
|
2625
|
+
return this.bulkUpdate([payload], opts);
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* Partially updates multiple records with deep nested relation support.
|
|
2629
|
+
*
|
|
2630
|
+
* Only TO relations (1:1, N:1) are supported for patching. FROM/VIA
|
|
2631
|
+
* relations will error — use {@link bulkReplace} for those.
|
|
2632
|
+
* Recursive up to `maxDepth` (default 3).
|
|
2633
|
+
*/
|
|
2634
|
+
async bulkUpdate(payloads, opts) {
|
|
2635
|
+
this._ensureBuilt();
|
|
2636
|
+
const maxDepth = opts?.maxDepth ?? 3;
|
|
2637
|
+
const depth = opts?._depth ?? 0;
|
|
2638
|
+
const canNest = depth < maxDepth && this._writeTableResolver && this._meta.navFields.size > 0;
|
|
2639
|
+
if (!canNest && this._meta.navFields.size > 0) checkDepthOverflow(payloads, maxDepth, this._meta);
|
|
2640
|
+
return enrichFkViolation(this._meta, () => this.adapter.withTransaction(async () => {
|
|
2641
|
+
validateBatch(this.getValidator("bulkUpdate"), payloads, {
|
|
2642
|
+
mode: "patch",
|
|
2643
|
+
flatMap: this.flatMap
|
|
2644
|
+
});
|
|
2645
|
+
const originals = canNest ? payloads.map((p) => ({ ...p })) : [];
|
|
2646
|
+
const host = this;
|
|
2647
|
+
if (canNest) await batchPatchNestedTo(host, payloads, maxDepth, depth);
|
|
2648
|
+
await this._integrity.validateForeignKeys(payloads, this._meta, this._fkLookupResolver, this._writeTableResolver, true);
|
|
2649
|
+
let matchedCount = 0;
|
|
2650
|
+
let modifiedCount = 0;
|
|
2651
|
+
for (const payload of payloads) {
|
|
2652
|
+
const data = { ...payload };
|
|
2653
|
+
for (const navField of this._meta.navFields) delete data[navField];
|
|
2654
|
+
const filter = this._extractPrimaryKeyFilter(data);
|
|
2655
|
+
for (const pk of this._meta.primaryKeys) delete data[pk];
|
|
2656
|
+
const ops = separateFieldOps(data);
|
|
2657
|
+
if (_isEmptyObj(data) && !ops) {
|
|
2658
|
+
matchedCount += 1;
|
|
2659
|
+
modifiedCount += 0;
|
|
2660
|
+
continue;
|
|
2661
|
+
}
|
|
2662
|
+
let result;
|
|
2663
|
+
const translatedFilter = this._fieldMapper.translateFilter(filter, this._meta);
|
|
2664
|
+
const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
|
|
2665
|
+
if (this.adapter.supportsNativePatch()) result = await this.adapter.nativePatch(translatedFilter, data, translatedOps);
|
|
2666
|
+
else {
|
|
2667
|
+
const update = decomposePatch(data, this);
|
|
2668
|
+
const translatedUpdate = this._fieldMapper.translatePatchKeys(update, this._meta);
|
|
2669
|
+
if (getArrayOpsFields(translatedUpdate).size > 0) {
|
|
2670
|
+
const resolved = resolveArrayOps(translatedUpdate, await this.adapter.findOne({
|
|
2671
|
+
filter: translatedFilter,
|
|
2672
|
+
controls: {}
|
|
2673
|
+
}), this);
|
|
2674
|
+
result = await this.adapter.updateOne(translatedFilter, resolved, translatedOps);
|
|
2675
|
+
} else result = await this.adapter.updateOne(translatedFilter, translatedUpdate, translatedOps);
|
|
2676
|
+
}
|
|
2677
|
+
matchedCount += result.matchedCount;
|
|
2678
|
+
modifiedCount += result.modifiedCount;
|
|
2679
|
+
}
|
|
2680
|
+
if (canNest) await batchPatchNestedFrom(host, originals, maxDepth, depth);
|
|
2681
|
+
if (canNest) await batchPatchNestedVia(host, originals, maxDepth, depth);
|
|
2682
|
+
return {
|
|
2683
|
+
matchedCount,
|
|
2684
|
+
modifiedCount
|
|
2685
|
+
};
|
|
2686
|
+
}));
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Deletes a single record by any type-compatible identifier — primary key
|
|
2690
|
+
* or single-field unique index. Uses the same resolution logic as `findById`.
|
|
2691
|
+
*
|
|
2692
|
+
* When the adapter does not support native foreign keys (e.g. MongoDB),
|
|
2693
|
+
* cascade and setNull actions are applied before the delete.
|
|
2694
|
+
*/
|
|
2695
|
+
async deleteOne(id) {
|
|
2696
|
+
this._ensureBuilt();
|
|
2697
|
+
const filter = this._resolveIdFilter(id);
|
|
2698
|
+
if (!filter) return { deletedCount: 0 };
|
|
2699
|
+
if (this._integrity.needsCascade(this._cascadeResolver)) return remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
|
|
2700
|
+
await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
|
|
2701
|
+
return this.adapter.deleteOne(this._fieldMapper.translateFilter(filter, this._meta));
|
|
2702
|
+
}));
|
|
2703
|
+
return remapDeleteFkViolation(this.tableName, () => this.adapter.deleteOne(this._fieldMapper.translateFilter(filter, this._meta)));
|
|
2704
|
+
}
|
|
2705
|
+
async updateMany(filter, data) {
|
|
2706
|
+
this._ensureBuilt();
|
|
2707
|
+
await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver, true);
|
|
2708
|
+
const dataCopy = { ...data };
|
|
2709
|
+
const ops = separateFieldOps(dataCopy);
|
|
2710
|
+
const translatedOps = ops ? _translateOpsKeys(ops, this._meta) : void 0;
|
|
2711
|
+
return enrichFkViolation(this._meta, () => this.adapter.updateMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite(dataCopy, this._meta, this.adapter), translatedOps));
|
|
2712
|
+
}
|
|
2713
|
+
async replaceMany(filter, data) {
|
|
2714
|
+
this._ensureBuilt();
|
|
2715
|
+
await this._integrity.validateForeignKeys([data], this._meta, this._fkLookupResolver, this._writeTableResolver);
|
|
2716
|
+
return enrichFkViolation(this._meta, () => this.adapter.replaceMany(this._fieldMapper.translateFilter(filter, this._meta), this._fieldMapper.prepareForWrite({ ...data }, this._meta, this.adapter)));
|
|
2717
|
+
}
|
|
2718
|
+
async deleteMany(filter) {
|
|
2719
|
+
this._ensureBuilt();
|
|
2720
|
+
if (this._integrity.needsCascade(this._cascadeResolver)) return remapDeleteFkViolation(this.tableName, () => this.adapter.withTransaction(async () => {
|
|
2721
|
+
await this._integrity.cascadeBeforeDelete(filter, this.tableName, this._meta, this._cascadeResolver, (f) => this._fieldMapper.translateFilter(f, this._meta), this.adapter);
|
|
2722
|
+
return this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta));
|
|
2723
|
+
}));
|
|
2724
|
+
return remapDeleteFkViolation(this.tableName, () => this.adapter.deleteMany(this._fieldMapper.translateFilter(filter, this._meta)));
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Synchronizes indexes between Atscript definitions and the database.
|
|
2728
|
+
*/
|
|
2729
|
+
async syncIndexes() {
|
|
2730
|
+
this._ensureBuilt();
|
|
2731
|
+
return this.adapter.syncIndexes();
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* Ensures the table/collection exists in the database.
|
|
2735
|
+
*/
|
|
2736
|
+
async ensureTable() {
|
|
2737
|
+
this._ensureBuilt();
|
|
2738
|
+
return this.adapter.ensureTable();
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Applies default values for fields that are missing from the payload.
|
|
2742
|
+
* Defaults handled natively by the DB engine are skipped — the field stays
|
|
2743
|
+
* absent so the DB's own DEFAULT clause applies.
|
|
2744
|
+
*/
|
|
2745
|
+
_applyDefaults(data) {
|
|
2746
|
+
const nativeValues = this.adapter.supportsNativeValueDefaults();
|
|
2747
|
+
const nativeFns = this.adapter.nativeDefaultFns();
|
|
2748
|
+
for (const [field, def] of this._meta.defaults.entries()) if (data[field] === void 0) {
|
|
2749
|
+
if (def.kind === "value" && !nativeValues) {
|
|
2750
|
+
const fieldType = this._meta.flatMap?.get(field);
|
|
2751
|
+
data[field] = (fieldType?.type.kind === "" && fieldType.type.designType) === "string" ? def.value : JSON.parse(def.value);
|
|
2752
|
+
} else if (def.kind === "fn" && !nativeFns.has(def.fn)) switch (def.fn) {
|
|
2753
|
+
case "now":
|
|
2754
|
+
data[field] = Date.now();
|
|
2755
|
+
break;
|
|
2756
|
+
case "uuid":
|
|
2757
|
+
data[field] = crypto.randomUUID();
|
|
2758
|
+
break;
|
|
2759
|
+
}
|
|
2760
|
+
}
|
|
2761
|
+
return data;
|
|
2762
|
+
}
|
|
2763
|
+
/**
|
|
2764
|
+
* Extracts primary key field(s) from a payload to build a filter.
|
|
2765
|
+
*/
|
|
2766
|
+
_extractPrimaryKeyFilter(payload) {
|
|
2767
|
+
const pkFields = this.primaryKeys;
|
|
2768
|
+
if (pkFields.length === 0) throw new DbError("NOT_FOUND", [{
|
|
2769
|
+
path: "",
|
|
2770
|
+
message: "No primary key defined — cannot extract filter"
|
|
2771
|
+
}]);
|
|
2772
|
+
const filter = {};
|
|
2773
|
+
for (const field of pkFields) {
|
|
2774
|
+
if (payload[field] === void 0) throw new DbError("NOT_FOUND", [{
|
|
2775
|
+
path: field,
|
|
2776
|
+
message: `Missing primary key field "${field}" in payload`
|
|
2777
|
+
}]);
|
|
2778
|
+
const fieldType = this.flatMap.get(field);
|
|
2779
|
+
filter[field] = fieldType ? this.adapter.prepareId(payload[field], fieldType) : payload[field];
|
|
2780
|
+
}
|
|
2781
|
+
return filter;
|
|
2782
|
+
}
|
|
2783
|
+
/**
|
|
2784
|
+
* Pre-validate items (type validation + FK constraints) without inserting them.
|
|
2785
|
+
* Used by parent tables to validate FROM children before the main insert,
|
|
2786
|
+
* ensuring errors are caught before the parent is committed.
|
|
2787
|
+
*
|
|
2788
|
+
* @param opts.excludeFkTargetTable - Skip FK validation to this table (the parent).
|
|
2789
|
+
*/
|
|
2790
|
+
async preValidateItems(items, opts) {
|
|
2791
|
+
this._ensureBuilt();
|
|
2792
|
+
validateBatch(this.getValidator("insert"), items.map((raw) => this._applyDefaults({ ...raw })), { mode: "insert" });
|
|
2793
|
+
await this._integrity.validateForeignKeys(items, this._meta, this._fkLookupResolver, this._writeTableResolver, false, opts?.excludeFkTargetTable);
|
|
2794
|
+
}
|
|
2795
|
+
/**
|
|
2796
|
+
* Builds a validator for a given purpose with adapter plugins.
|
|
2797
|
+
*
|
|
2798
|
+
* Uses annotation-based `replace` callback to make `@meta.id` and
|
|
2799
|
+
* `@db.default` fields optional — works at all nesting levels
|
|
2800
|
+
* (including inside nav field target types).
|
|
2801
|
+
*/
|
|
2802
|
+
_buildValidator(purpose) {
|
|
2803
|
+
const dbPlugin = createDbValidatorPlugin();
|
|
2804
|
+
const plugins = [...this.adapter.getValidatorPlugins(), dbPlugin];
|
|
2805
|
+
switch (purpose) {
|
|
2806
|
+
case "insert": return this.createValidator({
|
|
2807
|
+
plugins,
|
|
2808
|
+
replace: insertReplace
|
|
2809
|
+
});
|
|
2810
|
+
case "patch": return this.createValidator({
|
|
2811
|
+
plugins,
|
|
2812
|
+
partial: true,
|
|
2813
|
+
replace: forceNavNonOptional
|
|
2814
|
+
});
|
|
2815
|
+
case "bulkReplace": return this.createValidator({
|
|
2816
|
+
plugins,
|
|
2817
|
+
replace: insertReplace
|
|
2818
|
+
});
|
|
2819
|
+
case "bulkUpdate": {
|
|
2820
|
+
const navFields = this._meta.navFields;
|
|
2821
|
+
return this.createValidator({
|
|
2822
|
+
plugins,
|
|
2823
|
+
partial: (_def, path) => {
|
|
2824
|
+
if (path === "") return true;
|
|
2825
|
+
const root = path.split(".")[0];
|
|
2826
|
+
if (navFields.has(root)) return true;
|
|
2827
|
+
return _def.metadata.get("db.patch.strategy") === "merge";
|
|
2828
|
+
},
|
|
2829
|
+
replace: forceNavNonOptional
|
|
2830
|
+
});
|
|
2831
|
+
}
|
|
2832
|
+
default: return this.createValidator({ plugins });
|
|
2833
|
+
}
|
|
2834
|
+
}
|
|
2835
|
+
};
|
|
2836
|
+
//#endregion
|
|
2837
|
+
//#region src/table/db-view.ts
|
|
2838
|
+
/**
|
|
2839
|
+
* Database view abstraction driven by Atscript `@db.view.*` annotations.
|
|
2840
|
+
*
|
|
2841
|
+
* Extends {@link AtscriptDbReadable} with view plan resolution — entry table,
|
|
2842
|
+
* joins, filter, and materialization flag. Read operations are inherited;
|
|
2843
|
+
* write operations are not available on views.
|
|
2844
|
+
*
|
|
2845
|
+
* ```typescript
|
|
2846
|
+
* const adapter = new SqliteAdapter(db)
|
|
2847
|
+
* const activeUsers = new AtscriptDbView(ActiveUsersType, adapter)
|
|
2848
|
+
* const users = await activeUsers.findMany({ filter: {}, controls: {} })
|
|
2849
|
+
* ```
|
|
2850
|
+
*/
|
|
2851
|
+
var AtscriptDbView = class extends AtscriptDbReadable {
|
|
2852
|
+
_viewPlan;
|
|
2853
|
+
get isView() {
|
|
2854
|
+
return true;
|
|
2855
|
+
}
|
|
2856
|
+
/**
|
|
2857
|
+
* Whether this is an external view — declared with `@db.view` only,
|
|
2858
|
+
* without `@db.view.for`. External views reference pre-existing DB views
|
|
2859
|
+
* and are not managed (created/dropped) by schema sync.
|
|
2860
|
+
*/
|
|
2861
|
+
get isExternal() {
|
|
2862
|
+
return !this._type.metadata.has("db.view.for");
|
|
2863
|
+
}
|
|
2864
|
+
/**
|
|
2865
|
+
* Lazily resolves the view plan from `@db.view.*` metadata.
|
|
2866
|
+
*
|
|
2867
|
+
* - `db.view.for` → entry type ref (required)
|
|
2868
|
+
* - `db.view.joins` → array of `{ target, condition }` (optional, multiple)
|
|
2869
|
+
* - `db.view.filter` → query tree (optional)
|
|
2870
|
+
* - `db.view.materialized` → boolean (optional)
|
|
2871
|
+
*/
|
|
2872
|
+
get viewPlan() {
|
|
2873
|
+
if (this._viewPlan) return this._viewPlan;
|
|
2874
|
+
if (this.isExternal) throw new Error(`Cannot compute view plan for external view "${this.tableName}". External views (declared without @db.view.for) reference pre-existing DB views.`);
|
|
2875
|
+
const metadata = this._type.metadata;
|
|
2876
|
+
const forRef = metadata.get("db.view.for");
|
|
2877
|
+
const entryType = typeof forRef === "function" ? forRef : forRef.type;
|
|
2878
|
+
const entryTypeResolved = entryType();
|
|
2879
|
+
const entryTable = entryTypeResolved?.metadata?.get("db.table") || entryTypeResolved?.id || "";
|
|
2880
|
+
const rawJoins = metadata.get("db.view.joins");
|
|
2881
|
+
const joins = [];
|
|
2882
|
+
if (rawJoins) for (const join of rawJoins) {
|
|
2883
|
+
const targetRef = join.target;
|
|
2884
|
+
const targetType = typeof targetRef === "function" ? targetRef : targetRef.type;
|
|
2885
|
+
const targetTypeResolved = targetType();
|
|
2886
|
+
const targetTable = targetTypeResolved?.metadata?.get("db.table") || targetTypeResolved?.id || "";
|
|
2887
|
+
joins.push({
|
|
2888
|
+
targetType,
|
|
2889
|
+
targetTable,
|
|
2890
|
+
condition: join.condition
|
|
2891
|
+
});
|
|
2892
|
+
}
|
|
2893
|
+
this._viewPlan = {
|
|
2894
|
+
entryType,
|
|
2895
|
+
entryTable,
|
|
2896
|
+
joins,
|
|
2897
|
+
filter: metadata.get("db.view.filter"),
|
|
2898
|
+
having: metadata.get("db.view.having"),
|
|
2899
|
+
materialized: metadata.has("db.view.materialized")
|
|
2900
|
+
};
|
|
2901
|
+
return this._viewPlan;
|
|
2902
|
+
}
|
|
2903
|
+
/**
|
|
2904
|
+
* Resolves a query field ref to a quoted `table.column` SQL fragment.
|
|
2905
|
+
*
|
|
2906
|
+
* @param ref - The field reference from the query tree.
|
|
2907
|
+
* @param qi - Identifier quoting function (e.g. backtick for MySQL, double-quote for SQLite).
|
|
2908
|
+
* Defaults to double-quote wrapping for backwards compatibility.
|
|
2909
|
+
*/
|
|
2910
|
+
resolveFieldRef(ref, qi = (n) => `"${n}"`) {
|
|
2911
|
+
if (!ref.type) {
|
|
2912
|
+
const plan = this.viewPlan;
|
|
2913
|
+
return `${qi(plan.entryTable)}.${qi(ref.field)}`;
|
|
2914
|
+
}
|
|
2915
|
+
const resolved = ref.type();
|
|
2916
|
+
return `${qi(resolved?.metadata?.get("db.table") || resolved?.id || "")}.${qi(ref.field)}`;
|
|
2917
|
+
}
|
|
2918
|
+
/**
|
|
2919
|
+
* Maps each view field to its source table and column via ref chain.
|
|
2920
|
+
* Fields without refs (inline definitions) map to the entry table with the same name.
|
|
2921
|
+
*/
|
|
2922
|
+
getViewColumnMappings() {
|
|
2923
|
+
const plan = this.viewPlan;
|
|
2924
|
+
const mappings = [];
|
|
2925
|
+
if (this._type.type.kind !== "object") return mappings;
|
|
2926
|
+
const aggKeys = [
|
|
2927
|
+
"db.agg.sum",
|
|
2928
|
+
"db.agg.avg",
|
|
2929
|
+
"db.agg.count",
|
|
2930
|
+
"db.agg.min",
|
|
2931
|
+
"db.agg.max"
|
|
2932
|
+
];
|
|
2933
|
+
for (const [fieldName, fieldType] of this._type.type.props.entries()) {
|
|
2934
|
+
let aggFn;
|
|
2935
|
+
let aggField;
|
|
2936
|
+
for (const key of aggKeys) {
|
|
2937
|
+
const val = fieldType.metadata?.get(key);
|
|
2938
|
+
if (val !== void 0) {
|
|
2939
|
+
aggFn = key.split(".")[2];
|
|
2940
|
+
aggField = typeof val === "string" ? val : "*";
|
|
2941
|
+
break;
|
|
2942
|
+
}
|
|
2943
|
+
}
|
|
2944
|
+
if (fieldType.ref) {
|
|
2945
|
+
const resolved = fieldType.ref.type();
|
|
2946
|
+
const sourceTable = resolved?.metadata?.get("db.table") || resolved?.id || "";
|
|
2947
|
+
const sourceColumn = fieldType.ref.field || fieldName;
|
|
2948
|
+
mappings.push({
|
|
2949
|
+
viewColumn: fieldName,
|
|
2950
|
+
sourceTable,
|
|
2951
|
+
sourceColumn,
|
|
2952
|
+
aggFn,
|
|
2953
|
+
aggField
|
|
2954
|
+
});
|
|
2955
|
+
} else {
|
|
2956
|
+
const sourceColumn = aggField && aggField !== "*" ? aggField : fieldName;
|
|
2957
|
+
mappings.push({
|
|
2958
|
+
viewColumn: fieldName,
|
|
2959
|
+
sourceTable: plan.entryTable,
|
|
2960
|
+
sourceColumn,
|
|
2961
|
+
aggFn,
|
|
2962
|
+
aggField
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2966
|
+
return mappings;
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
//#endregion
|
|
2970
|
+
export { NoopLogger as _, getKeyProps as a, IntegrityStrategy as c, resolveDesignType as d, RelationalFieldMapper as f, TableMetadata as g, UniquSelect as h, createDbValidatorPlugin as i, NativeIntegrity as l, FieldMappingStrategy as m, AtscriptDbTable as n, ApplicationIntegrity as o, DocumentFieldMapper as p, decomposePatch as r, BaseDbAdapter as s, AtscriptDbView as t, AtscriptDbReadable as u };
|