@atscript/utils-db 0.1.30 → 0.1.31

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 CHANGED
@@ -245,9 +245,29 @@ These `@db.*` annotations are defined in `@atscript/core` and processed by `Atsc
245
245
  | `@db.index.plain "name"` | Field | B-tree index (optional sort: `"name", "desc"`) |
246
246
  | `@db.index.unique "name"` | Field | Unique index |
247
247
  | `@db.index.fulltext "name"` | Field | Full-text search index |
248
+ | `@db.json` | Field | Store as a single JSON column (skip flattening) |
248
249
 
249
250
  Multiple fields with the same index name form a **composite index**.
250
251
 
252
+ ## Type-Safe Queries with `__flat`
253
+
254
+ Interfaces annotated with `@db.table` get a `__flat` static property in the generated `.d.ts` file, mapping all dot-notation paths to their value types. This enables autocomplete and type checking for filter expressions and `$select`/`$sort` operations.
255
+
256
+ Use `FlatOf<T>` to extract the flat type:
257
+
258
+ ```typescript
259
+ import type { FlatOf } from '@atscript/utils-db'
260
+
261
+ type UserFlat = FlatOf<typeof User>
262
+ // → { id: number; name: string; contact: never; "contact.email": string; ... }
263
+ ```
264
+
265
+ `AtscriptDbTable` uses `FlatOf<T>` as the type parameter for query methods (`findOne`, `findMany`, `count`, `updateMany`, `replaceMany`, `deleteMany`), giving you autocomplete on filter keys and select paths. When `__flat` is not present (no `@db.table`), `FlatOf<T>` falls back to the regular data shape — fully backward-compatible.
266
+
267
+ ### `$select` Parent Path Expansion
268
+
269
+ Selecting a parent object path (e.g., `$select: ['contact']`) automatically expands to all its leaf physical columns for relational databases. Sorting by parent paths is silently ignored.
270
+
251
271
  ## Cross-Cutting Concerns
252
272
 
253
273
  Since `AtscriptDbTable` is concrete, extend it for cross-cutting concerns that work with any adapter:
@@ -305,6 +325,7 @@ export { decomposePatch } from './patch-decomposer'
305
325
  export { getKeyProps } from './patch-types'
306
326
 
307
327
  // Types
328
+ export type { FlatOf } from '@atscript/typescript/utils'
308
329
  export type {
309
330
  TDbFilter, TDbFindOptions,
310
331
  TDbInsertResult, TDbInsertManyResult, TDbUpdateResult, TDbDeleteResult,
package/dist/index.cjs CHANGED
@@ -156,6 +156,14 @@ var AtscriptDbTable = class {
156
156
  this._flatten();
157
157
  return this._uniqueProps;
158
158
  }
159
+ /** Precomputed logical dot-path → physical column name map. */ get pathToPhysical() {
160
+ this._flatten();
161
+ return this._pathToPhysical;
162
+ }
163
+ /** Precomputed physical column name → logical dot-path map (inverse). */ get physicalToPath() {
164
+ this._flatten();
165
+ return this._physicalToPath;
166
+ }
159
167
  /** Descriptor for the primary ID field(s). */ getIdDescriptor() {
160
168
  this._flatten();
161
169
  return {
@@ -171,17 +179,31 @@ var AtscriptDbTable = class {
171
179
  this._flatten();
172
180
  if (!this._fieldDescriptors) {
173
181
  this._fieldDescriptors = [];
182
+ const skipFlattening = this.adapter.supportsNestedObjects();
174
183
  for (const [path, type] of this._flatMap.entries()) {
175
184
  if (!path) continue;
185
+ if (!skipFlattening && this._flattenedParents.has(path)) continue;
186
+ if (!skipFlattening && this._findJsonParent(path) !== undefined) continue;
187
+ const isJson = this._jsonFields.has(path);
188
+ const isFlattened = !skipFlattening && this._findFlattenedParent(path) !== undefined;
189
+ const designType = isJson ? "json" : resolveDesignType(type);
190
+ let storage;
191
+ if (skipFlattening) storage = "column";
192
+ else if (isJson) storage = "json";
193
+ else if (isFlattened) storage = "flattened";
194
+ else storage = "column";
195
+ const physicalName = skipFlattening ? this._columnMap.get(path) ?? path : this._pathToPhysical.get(path) ?? this._columnMap.get(path) ?? path;
176
196
  this._fieldDescriptors.push({
177
197
  path,
178
198
  type,
179
- physicalName: this._columnMap.get(path) ?? path,
180
- designType: resolveDesignType(type),
199
+ physicalName,
200
+ designType,
181
201
  optional: type.optional === true,
182
202
  isPrimaryKey: this._primaryKeys.includes(path),
183
203
  ignored: this._ignoredFields.has(path),
184
- defaultValue: this._defaults.get(path)
204
+ defaultValue: this._defaults.get(path),
205
+ storage,
206
+ flattenedFrom: isFlattened ? path : undefined
185
207
  });
186
208
  }
187
209
  }
@@ -196,12 +218,19 @@ var AtscriptDbTable = class {
196
218
  */ resolveProjection(select) {
197
219
  if (!select) return undefined;
198
220
  if (Array.isArray(select)) return select.length > 0 ? select : undefined;
199
- const entries = Object.entries(select);
200
- if (entries.length === 0) return undefined;
201
- const firstVal = entries[0][1];
202
- if (firstVal === 1) return entries.filter(([, v]) => v === 1).map(([k]) => k);
203
- const excluded = new Set(entries.filter(([, v]) => v === 0).map(([k]) => k));
204
- return this.fieldDescriptors.filter((f) => !f.ignored && !excluded.has(f.path)).map((f) => f.path);
221
+ const selectObj = select;
222
+ const keys = Object.keys(selectObj);
223
+ if (keys.length === 0) return undefined;
224
+ if (selectObj[keys[0]] === 1) {
225
+ const result$1 = [];
226
+ for (const k of keys) if (selectObj[k] === 1) result$1.push(k);
227
+ return result$1;
228
+ }
229
+ const excluded = new Set();
230
+ for (const k of keys) if (selectObj[k] === 0) excluded.add(k);
231
+ const result = [];
232
+ for (const f of this.fieldDescriptors) if (!f.ignored && !excluded.has(f.path)) result.push(f.path);
233
+ return result;
205
234
  }
206
235
  /**
207
236
  * Creates a new validator with custom options.
@@ -266,9 +295,9 @@ var AtscriptDbTable = class {
266
295
  const validator = this.getValidator("patch");
267
296
  if (!validator.validate(payload)) throw new Error("Validation failed for update");
268
297
  const filter = this._extractPrimaryKeyFilter(payload);
269
- if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(filter, payload);
298
+ if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(this._translateFilter(filter), payload);
270
299
  const update = decomposePatch(payload, this);
271
- return this.adapter.updateOne(filter, update);
300
+ return this.adapter.updateOne(this._translateFilter(filter), this._translatePatchKeys(update));
272
301
  }
273
302
  /**
274
303
  * Deletes a single record by primary key value.
@@ -288,35 +317,45 @@ var AtscriptDbTable = class {
288
317
  filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
289
318
  }
290
319
  }
291
- return this.adapter.deleteOne(filter);
320
+ return this.adapter.deleteOne(this._translateFilter(filter));
292
321
  }
293
322
  /**
294
323
  * Finds a single record matching the query.
295
324
  */ async findOne(query) {
296
- return this.adapter.findOne(query);
325
+ this._flatten();
326
+ const translatedQuery = this._translateQuery(query);
327
+ const result = await this.adapter.findOne(translatedQuery);
328
+ return result ? this._reconstructFromRead(result) : null;
297
329
  }
298
330
  /**
299
331
  * Finds all records matching the query.
300
332
  */ async findMany(query) {
301
- return this.adapter.findMany(query);
333
+ this._flatten();
334
+ const translatedQuery = this._translateQuery(query);
335
+ const results = await this.adapter.findMany(translatedQuery);
336
+ return results.map((row) => this._reconstructFromRead(row));
302
337
  }
303
338
  /**
304
339
  * Counts records matching the query.
305
340
  */ async count(query) {
341
+ this._flatten();
306
342
  query ?? (query = {
307
343
  filter: {},
308
344
  controls: {}
309
345
  });
310
- return this.adapter.count(query);
346
+ return this.adapter.count(this._translateQuery(query));
311
347
  }
312
348
  async updateMany(filter, data) {
313
- return this.adapter.updateMany(filter, data);
349
+ this._flatten();
350
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
314
351
  }
315
352
  async replaceMany(filter, data) {
316
- return this.adapter.replaceMany(filter, data);
353
+ this._flatten();
354
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
317
355
  }
318
356
  async deleteMany(filter) {
319
- return this.adapter.deleteMany(filter);
357
+ this._flatten();
358
+ return this.adapter.deleteMany(this._translateFilter(filter));
320
359
  }
321
360
  /**
322
361
  * Synchronizes indexes between Atscript definitions and the database.
@@ -342,6 +381,7 @@ var AtscriptDbTable = class {
342
381
  this.adapter.onFieldScanned?.(path, type, metadata);
343
382
  }
344
383
  });
384
+ if (!this.adapter.supportsNestedObjects()) this._classifyFields();
345
385
  this._finalizeIndexes();
346
386
  this.adapter.onAfterFlatten?.();
347
387
  }
@@ -375,6 +415,11 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
375
415
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
376
416
  this._addIndexField("fulltext", name, fieldName);
377
417
  }
418
+ if (metadata.has("db.json")) {
419
+ this._jsonFields.add(fieldName);
420
+ const hasIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
421
+ if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
422
+ }
378
423
  }
379
424
  _addIndexField(type, name, field, sort = "asc") {
380
425
  const key = indexKey(type, name);
@@ -391,7 +436,66 @@ else this._indexes.set(key, {
391
436
  fields: [indexField]
392
437
  });
393
438
  }
439
+ /**
440
+ * Classifies each field as column, flattened, json, or parent-object.
441
+ * Builds the bidirectional _pathToPhysical / _physicalToPath maps.
442
+ * Only called when the adapter does NOT support nested objects natively.
443
+ */ _classifyFields() {
444
+ for (const [path, type] of this._flatMap.entries()) {
445
+ if (!path) continue;
446
+ const designType = resolveDesignType(type);
447
+ const isJson = this._jsonFields.has(path);
448
+ const isArray = designType === "array";
449
+ const isObject = designType === "object";
450
+ if (isArray) this._jsonFields.add(path);
451
+ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedParents.add(path);
452
+ }
453
+ for (const ignoredField of this._ignoredFields) if (this._flattenedParents.has(ignoredField)) {
454
+ const prefix = `${ignoredField}.`;
455
+ for (const path of this._flatMap.keys()) if (path.startsWith(prefix)) this._ignoredFields.add(path);
456
+ }
457
+ 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`);
458
+ for (const [path] of this._flatMap.entries()) {
459
+ if (!path) continue;
460
+ if (this._flattenedParents.has(path)) continue;
461
+ if (this._findJsonParent(path) !== undefined) continue;
462
+ const isFlattened = this._findFlattenedParent(path) !== undefined;
463
+ const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
464
+ this._pathToPhysical.set(path, physicalName);
465
+ this._physicalToPath.set(physicalName, path);
466
+ }
467
+ for (const parentPath of this._flattenedParents) {
468
+ const prefix = `${parentPath}.`;
469
+ const leaves = [];
470
+ for (const [path, physical] of this._pathToPhysical) if (path.startsWith(prefix)) leaves.push(physical);
471
+ if (leaves.length > 0) this._selectExpansion.set(parentPath, leaves);
472
+ }
473
+ this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
474
+ }
475
+ /**
476
+ * Finds the nearest ancestor that is a flattened parent object.
477
+ * Returns the parent path, or undefined if no ancestor is being flattened.
478
+ */ _findFlattenedParent(path) {
479
+ let pos = path.length;
480
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
481
+ const ancestor = path.slice(0, pos);
482
+ if (this._flattenedParents.has(ancestor)) return ancestor;
483
+ }
484
+ return undefined;
485
+ }
486
+ /**
487
+ * Finds the nearest ancestor that is a @db.json field.
488
+ * Children of JSON fields don't get their own columns.
489
+ */ _findJsonParent(path) {
490
+ let pos = path.length;
491
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
492
+ const ancestor = path.slice(0, pos);
493
+ if (this._jsonFields.has(ancestor)) return ancestor;
494
+ }
495
+ return undefined;
496
+ }
394
497
  _finalizeIndexes() {
498
+ 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;
395
499
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
396
500
  }
397
501
  /**
@@ -405,7 +509,7 @@ else this._indexes.set(key, {
405
509
  }
406
510
  /**
407
511
  * Prepares a payload for writing to the database:
408
- * prepares IDs, strips ignored fields, maps column names.
512
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
409
513
  * Defaults should be applied before this via `_applyDefaults`.
410
514
  */ _prepareForWrite(payload) {
411
515
  const data = { ...payload };
@@ -413,12 +517,213 @@ else this._indexes.set(key, {
413
517
  const fieldType = this._flatMap?.get(pk);
414
518
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
415
519
  }
416
- for (const field of this._ignoredFields) delete data[field];
417
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
418
- data[physical] = data[logical];
419
- delete data[logical];
520
+ for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
521
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) {
522
+ for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
523
+ data[physical] = data[logical];
524
+ delete data[logical];
525
+ }
526
+ return data;
420
527
  }
421
- return data;
528
+ return this._flattenPayload(data);
529
+ }
530
+ /**
531
+ * Flattens nested object fields into __-separated keys and
532
+ * JSON-stringifies @db.json / array fields.
533
+ * Uses _pathToPhysical for final key names.
534
+ */ _flattenPayload(data) {
535
+ const result = {};
536
+ const dataKeys = Object.keys(data);
537
+ for (const key of dataKeys) {
538
+ const value = data[key];
539
+ if (this._flattenedParents.has(key)) {
540
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(key, result);
541
+ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(key, value, result);
542
+ } else if (this._jsonFields.has(key)) {
543
+ const physical = this._pathToPhysical.get(key) ?? key;
544
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
545
+ } else {
546
+ const physical = this._pathToPhysical.get(key) ?? key;
547
+ result[physical] = value;
548
+ }
549
+ }
550
+ return result;
551
+ }
552
+ /**
553
+ * Recursively flattens a nested object, writing physical keys to result.
554
+ */ _flattenObject(prefix, obj, result) {
555
+ const objKeys = Object.keys(obj);
556
+ for (const key of objKeys) {
557
+ const value = obj[key];
558
+ const dotPath = `${prefix}.${key}`;
559
+ if (this._ignoredFields.has(dotPath)) continue;
560
+ if (this._flattenedParents.has(dotPath)) {
561
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(dotPath, result);
562
+ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(dotPath, value, result);
563
+ } else if (this._jsonFields.has(dotPath)) {
564
+ const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
565
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
566
+ } else {
567
+ const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
568
+ result[physical] = value;
569
+ }
570
+ }
571
+ }
572
+ /**
573
+ * When a parent object is null/undefined, set all its flattened children to null.
574
+ */ _setFlattenedChildrenNull(parentPath, result) {
575
+ const prefix = `${parentPath}.`;
576
+ for (const [path, physical] of this._pathToPhysical.entries()) if (path.startsWith(prefix)) result[physical] = null;
577
+ }
578
+ /**
579
+ * Reconstructs nested objects from flat __-separated column values.
580
+ * JSON fields are parsed from strings back to objects/arrays.
581
+ */ _reconstructFromRead(row) {
582
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return row;
583
+ const result = {};
584
+ const rowKeys = Object.keys(row);
585
+ for (const physical of rowKeys) {
586
+ const value = row[physical];
587
+ const logicalPath = this._physicalToPath.get(physical);
588
+ if (!logicalPath) {
589
+ result[physical] = value;
590
+ continue;
591
+ }
592
+ if (this._jsonFields.has(logicalPath)) {
593
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
594
+ this._setNestedValue(result, logicalPath, parsed);
595
+ } else if (logicalPath.includes(".")) this._setNestedValue(result, logicalPath, value);
596
+ else result[logicalPath] = value;
597
+ }
598
+ for (const parentPath of this._flattenedParents) this._reconstructNullParent(result, parentPath);
599
+ return result;
600
+ }
601
+ /**
602
+ * Sets a value at a dot-notation path, creating intermediate objects as needed.
603
+ */ _setNestedValue(obj, dotPath, value) {
604
+ const parts = dotPath.split(".");
605
+ let current = obj;
606
+ for (let i = 0; i < parts.length - 1; i++) {
607
+ const part = parts[i];
608
+ if (current[part] === undefined || current[part] === null) current[part] = {};
609
+ current = current[part];
610
+ }
611
+ current[parts[parts.length - 1]] = value;
612
+ }
613
+ /**
614
+ * If all children of a flattened parent are null, collapse the parent to null.
615
+ */ _reconstructNullParent(obj, parentPath) {
616
+ const parts = parentPath.split(".");
617
+ let current = obj;
618
+ for (let i = 0; i < parts.length - 1; i++) {
619
+ if (current[parts[i]] === undefined) return;
620
+ current = current[parts[i]];
621
+ }
622
+ const lastPart = parts[parts.length - 1];
623
+ const parentObj = current[lastPart];
624
+ if (typeof parentObj !== "object" || parentObj === null) return;
625
+ let allNull = true;
626
+ const parentKeys = Object.keys(parentObj);
627
+ for (const k of parentKeys) {
628
+ const v = parentObj[k];
629
+ if (v !== null && v !== undefined) {
630
+ allNull = false;
631
+ break;
632
+ }
633
+ }
634
+ if (allNull) {
635
+ const parentType = this._flatMap?.get(parentPath);
636
+ current[lastPart] = parentType?.optional ? null : {};
637
+ }
638
+ }
639
+ /**
640
+ * Translates a Uniquery's filter, sort, and projection from logical
641
+ * dot-notation paths to physical column names.
642
+ */ _translateQuery(query) {
643
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return query;
644
+ return {
645
+ filter: this._translateFilter(query.filter),
646
+ controls: query.controls ? this._translateControls(query.controls) : query.controls,
647
+ insights: query.insights
648
+ };
649
+ }
650
+ /**
651
+ * Recursively translates field names in a filter expression.
652
+ */ _translateFilter(filter) {
653
+ if (!filter || typeof filter !== "object") return filter;
654
+ if (!this._requiresMappings) return filter;
655
+ const result = {};
656
+ const filterKeys = Object.keys(filter);
657
+ for (const key of filterKeys) {
658
+ const value = filter[key];
659
+ if (key === "$and" || key === "$or") result[key] = value.map((f) => this._translateFilter(f));
660
+ else if (key === "$not") result[key] = this._translateFilter(value);
661
+ else if (key.startsWith("$")) result[key] = value;
662
+ else {
663
+ const physical = this._pathToPhysical.get(key) ?? key;
664
+ result[physical] = value;
665
+ }
666
+ }
667
+ return result;
668
+ }
669
+ /**
670
+ * Translates field names in sort and projection controls.
671
+ */ _translateControls(controls) {
672
+ if (!controls) return controls;
673
+ const result = { ...controls };
674
+ if (controls.$sort) {
675
+ const translated = {};
676
+ const sortObj = controls.$sort;
677
+ const sortKeys = Object.keys(sortObj);
678
+ for (const key of sortKeys) {
679
+ if (this._flattenedParents.has(key)) continue;
680
+ const physical = this._pathToPhysical.get(key) ?? key;
681
+ translated[physical] = sortObj[key];
682
+ }
683
+ result.$sort = translated;
684
+ }
685
+ if (controls.$select) if (Array.isArray(controls.$select)) {
686
+ const expanded = [];
687
+ for (const key of controls.$select) {
688
+ const expansion = this._selectExpansion.get(key);
689
+ if (expansion) expanded.push(...expansion);
690
+ else expanded.push(this._pathToPhysical.get(key) ?? key);
691
+ }
692
+ result.$select = expanded;
693
+ } else {
694
+ const translated = {};
695
+ const selectObj = controls.$select;
696
+ const selectKeys = Object.keys(selectObj);
697
+ for (const key of selectKeys) {
698
+ const val = selectObj[key];
699
+ const expansion = this._selectExpansion.get(key);
700
+ if (expansion) for (const leaf of expansion) translated[leaf] = val;
701
+ else {
702
+ const physical = this._pathToPhysical.get(key) ?? key;
703
+ translated[physical] = val;
704
+ }
705
+ }
706
+ result.$select = translated;
707
+ }
708
+ return result;
709
+ }
710
+ /**
711
+ * Translates dot-notation keys in a decomposed patch to physical column names.
712
+ */ _translatePatchKeys(update) {
713
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return update;
714
+ const result = {};
715
+ const updateKeys = Object.keys(update);
716
+ for (const key of updateKeys) {
717
+ const value = update[key];
718
+ const operatorMatch = key.match(/^(.+?)(\.__\$.+)$/);
719
+ const basePath = operatorMatch ? operatorMatch[1] : key;
720
+ const suffix = operatorMatch ? operatorMatch[2] : "";
721
+ const physical = this._pathToPhysical.get(basePath) ?? basePath;
722
+ const finalKey = physical + suffix;
723
+ if (this._jsonFields.has(basePath) && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
724
+ else result[finalKey] = value;
725
+ }
726
+ return result;
422
727
  }
423
728
  /**
424
729
  * Extracts primary key field(s) from a payload to build a filter.
@@ -470,6 +775,12 @@ else this._indexes.set(key, {
470
775
  _define_property$1(this, "_defaults", void 0);
471
776
  _define_property$1(this, "_ignoredFields", void 0);
472
777
  _define_property$1(this, "_uniqueProps", void 0);
778
+ /** Logical dot-path → physical column name. */ _define_property$1(this, "_pathToPhysical", void 0);
779
+ /** Physical column name → logical dot-path (inverse). */ _define_property$1(this, "_physicalToPath", void 0);
780
+ /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
781
+ /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
782
+ /** Intermediate paths → their leaf physical column names (for $select expansion in relational DBs). */ _define_property$1(this, "_selectExpansion", void 0);
783
+ /** Fast-path flag: skip all mapping when no nested/json fields exist. */ _define_property$1(this, "_requiresMappings", void 0);
473
784
  _define_property$1(this, "validators", void 0);
474
785
  this._type = _type;
475
786
  this.adapter = adapter;
@@ -480,6 +791,12 @@ else this._indexes.set(key, {
480
791
  this._defaults = new Map();
481
792
  this._ignoredFields = new Set();
482
793
  this._uniqueProps = new Set();
794
+ this._pathToPhysical = new Map();
795
+ this._physicalToPath = new Map();
796
+ this._flattenedParents = new Set();
797
+ this._jsonFields = new Set();
798
+ this._selectExpansion = new Map();
799
+ this._requiresMappings = false;
483
800
  this.validators = new Map();
484
801
  if (!(0, __atscript_typescript_utils.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
485
802
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -539,6 +856,14 @@ var BaseDbAdapter = class {
539
856
  return false;
540
857
  }
541
858
  /**
859
+ * Whether this adapter handles nested objects natively.
860
+ * When `true`, the generic layer skips flattening and
861
+ * passes nested objects as-is to the adapter.
862
+ * MongoDB returns `true`; relational adapters return `false` (default).
863
+ */ supportsNestedObjects() {
864
+ return false;
865
+ }
866
+ /**
542
867
  * Applies a patch payload using native database operations.
543
868
  * Only called when {@link supportsNativePatch} returns `true`.
544
869
  *
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { TAtscriptAnnotatedType, TValidatorPlugin, TMetadataMap, TAtscriptDataType, Validator, TAtscriptTypeObject, TValidatorOptions, TAtscriptTypeArray } from '@atscript/typescript/utils';
1
+ import { TAtscriptAnnotatedType, TValidatorPlugin, TMetadataMap, TAtscriptDataType, FlatOf, Validator, TAtscriptTypeObject, TValidatorOptions, TAtscriptTypeArray } from '@atscript/typescript/utils';
2
2
  import { FilterExpr, Uniquery, UniqueryControls } from '@uniqu/core';
3
3
  export { FieldOpsFor, FilterExpr, FilterVisitor, Uniquery, UniqueryControls, isPrimitive, walkFilter } from '@uniqu/core';
4
4
 
@@ -48,9 +48,9 @@ interface TDbFieldMeta {
48
48
  path: string;
49
49
  /** The annotated type for this field. */
50
50
  type: TAtscriptAnnotatedType;
51
- /** Physical column/field name (from @db.column, or same as path). */
51
+ /** Physical column/field name (from @db.column, __-separated for flattened, or same as path). */
52
52
  physicalName: string;
53
- /** Resolved design type: 'string', 'number', 'boolean', 'object', etc. */
53
+ /** Resolved design type: 'string', 'number', 'boolean', 'object', 'json', etc. */
54
54
  designType: string;
55
55
  /** Whether the field is optional. */
56
56
  optional: boolean;
@@ -60,6 +60,19 @@ interface TDbFieldMeta {
60
60
  ignored: boolean;
61
61
  /** Default value from @db.default.* */
62
62
  defaultValue?: TDbDefaultValue;
63
+ /**
64
+ * How this field is stored in the database.
65
+ * - 'column': a standard scalar column (default for primitives)
66
+ * - 'flattened': a leaf scalar from a flattened nested object
67
+ * - 'json': stored as a single JSON column (arrays, @db.json fields)
68
+ */
69
+ storage: 'column' | 'flattened' | 'json';
70
+ /**
71
+ * For flattened fields: the dot-notation path (same as `path`).
72
+ * E.g., for physicalName 'contact__email', this is 'contact.email'.
73
+ * Undefined for non-flattened fields.
74
+ */
75
+ flattenedFrom?: string;
63
76
  }
64
77
 
65
78
  /**
@@ -114,6 +127,13 @@ declare abstract class BaseDbAdapter {
114
127
  * {@link nativePatch} instead of using the generic decomposition.
115
128
  */
116
129
  supportsNativePatch(): boolean;
130
+ /**
131
+ * Whether this adapter handles nested objects natively.
132
+ * When `true`, the generic layer skips flattening and
133
+ * passes nested objects as-is to the adapter.
134
+ * MongoDB returns `true`; relational adapters return `false` (default).
135
+ */
136
+ supportsNestedObjects(): boolean;
117
137
  /**
118
138
  * Applies a patch payload using native database operations.
119
139
  * Only called when {@link supportsNativePatch} returns `true`.
@@ -247,7 +267,7 @@ declare function resolveDesignType(fieldType: TAtscriptAnnotatedType): string;
247
267
  * @typeParam T - The Atscript annotated type for this table.
248
268
  * @typeParam DataType - The inferred data shape from the annotated type.
249
269
  */
250
- declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>> {
270
+ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnotatedType, DataType = TAtscriptDataType<T>, FlatType = FlatOf<T>> {
251
271
  protected readonly _type: T;
252
272
  protected readonly adapter: BaseDbAdapter;
253
273
  protected readonly logger: TGenericLogger;
@@ -263,6 +283,18 @@ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnota
263
283
  protected _defaults: Map<string, TDbDefaultValue>;
264
284
  protected _ignoredFields: Set<string>;
265
285
  protected _uniqueProps: Set<string>;
286
+ /** Logical dot-path → physical column name. */
287
+ protected _pathToPhysical: Map<string, string>;
288
+ /** Physical column name → logical dot-path (inverse). */
289
+ protected _physicalToPath: Map<string, string>;
290
+ /** Object paths being flattened into __-separated columns (no column themselves). */
291
+ protected _flattenedParents: Set<string>;
292
+ /** Fields stored as JSON (@db.json + array fields). */
293
+ protected _jsonFields: Set<string>;
294
+ /** Intermediate paths → their leaf physical column names (for $select expansion in relational DBs). */
295
+ protected _selectExpansion: Map<string, string[]>;
296
+ /** Fast-path flag: skip all mapping when no nested/json fields exist. */
297
+ protected _requiresMappings: boolean;
266
298
  protected readonly validators: Map<string, Validator<T, DataType>>;
267
299
  constructor(_type: T, adapter: BaseDbAdapter, logger?: TGenericLogger);
268
300
  /** The raw annotated type. */
@@ -281,6 +313,10 @@ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnota
281
313
  get ignoredFields(): ReadonlySet<string>;
282
314
  /** Single-field unique index properties. */
283
315
  get uniqueProps(): ReadonlySet<string>;
316
+ /** Precomputed logical dot-path → physical column name map. */
317
+ get pathToPhysical(): ReadonlyMap<string, string>;
318
+ /** Precomputed physical column name → logical dot-path map (inverse). */
319
+ get physicalToPath(): ReadonlyMap<string, string>;
284
320
  /** Descriptor for the primary ID field(s). */
285
321
  getIdDescriptor(): TIdDescriptor;
286
322
  /**
@@ -338,18 +374,18 @@ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnota
338
374
  /**
339
375
  * Finds a single record matching the query.
340
376
  */
341
- findOne(query: Uniquery<DataType>): Promise<DataType | null>;
377
+ findOne(query: Uniquery<FlatType>): Promise<DataType | null>;
342
378
  /**
343
379
  * Finds all records matching the query.
344
380
  */
345
- findMany(query: Uniquery<DataType>): Promise<DataType[]>;
381
+ findMany(query: Uniquery<FlatType>): Promise<DataType[]>;
346
382
  /**
347
383
  * Counts records matching the query.
348
384
  */
349
- count(query?: Uniquery<DataType>): Promise<number>;
350
- updateMany(filter: FilterExpr<DataType>, data: Partial<DataType> & Record<string, unknown>): Promise<TDbUpdateResult>;
351
- replaceMany(filter: FilterExpr<DataType>, data: Record<string, unknown>): Promise<TDbUpdateResult>;
352
- deleteMany(filter: FilterExpr<DataType>): Promise<TDbDeleteResult>;
385
+ count(query?: Uniquery<FlatType>): Promise<number>;
386
+ updateMany(filter: FilterExpr<FlatType>, data: Partial<DataType> & Record<string, unknown>): Promise<TDbUpdateResult>;
387
+ replaceMany(filter: FilterExpr<FlatType>, data: Record<string, unknown>): Promise<TDbUpdateResult>;
388
+ deleteMany(filter: FilterExpr<FlatType>): Promise<TDbDeleteResult>;
353
389
  /**
354
390
  * Synchronizes indexes between Atscript definitions and the database.
355
391
  * Delegates to the adapter, which uses `this._table.indexes`.
@@ -365,6 +401,22 @@ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnota
365
401
  */
366
402
  private _scanGenericAnnotations;
367
403
  protected _addIndexField(type: TDbIndex['type'], name: string, field: string, sort?: 'asc' | 'desc'): void;
404
+ /**
405
+ * Classifies each field as column, flattened, json, or parent-object.
406
+ * Builds the bidirectional _pathToPhysical / _physicalToPath maps.
407
+ * Only called when the adapter does NOT support nested objects natively.
408
+ */
409
+ private _classifyFields;
410
+ /**
411
+ * Finds the nearest ancestor that is a flattened parent object.
412
+ * Returns the parent path, or undefined if no ancestor is being flattened.
413
+ */
414
+ private _findFlattenedParent;
415
+ /**
416
+ * Finds the nearest ancestor that is a @db.json field.
417
+ * Children of JSON fields don't get their own columns.
418
+ */
419
+ private _findJsonParent;
368
420
  private _finalizeIndexes;
369
421
  /**
370
422
  * Applies default values for fields that are missing from the payload.
@@ -373,10 +425,54 @@ declare class AtscriptDbTable<T extends TAtscriptAnnotatedType = TAtscriptAnnota
373
425
  protected _applyDefaults(data: Record<string, unknown>): Record<string, unknown>;
374
426
  /**
375
427
  * Prepares a payload for writing to the database:
376
- * prepares IDs, strips ignored fields, maps column names.
428
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
377
429
  * Defaults should be applied before this via `_applyDefaults`.
378
430
  */
379
431
  protected _prepareForWrite(payload: Record<string, unknown>): Record<string, unknown>;
432
+ /**
433
+ * Flattens nested object fields into __-separated keys and
434
+ * JSON-stringifies @db.json / array fields.
435
+ * Uses _pathToPhysical for final key names.
436
+ */
437
+ private _flattenPayload;
438
+ /**
439
+ * Recursively flattens a nested object, writing physical keys to result.
440
+ */
441
+ private _flattenObject;
442
+ /**
443
+ * When a parent object is null/undefined, set all its flattened children to null.
444
+ */
445
+ private _setFlattenedChildrenNull;
446
+ /**
447
+ * Reconstructs nested objects from flat __-separated column values.
448
+ * JSON fields are parsed from strings back to objects/arrays.
449
+ */
450
+ protected _reconstructFromRead(row: Record<string, unknown>): Record<string, unknown>;
451
+ /**
452
+ * Sets a value at a dot-notation path, creating intermediate objects as needed.
453
+ */
454
+ private _setNestedValue;
455
+ /**
456
+ * If all children of a flattened parent are null, collapse the parent to null.
457
+ */
458
+ private _reconstructNullParent;
459
+ /**
460
+ * Translates a Uniquery's filter, sort, and projection from logical
461
+ * dot-notation paths to physical column names.
462
+ */
463
+ private _translateQuery;
464
+ /**
465
+ * Recursively translates field names in a filter expression.
466
+ */
467
+ private _translateFilter;
468
+ /**
469
+ * Translates field names in sort and projection controls.
470
+ */
471
+ private _translateControls;
472
+ /**
473
+ * Translates dot-notation keys in a decomposed patch to physical column names.
474
+ */
475
+ private _translatePatchKeys;
380
476
  /**
381
477
  * Extracts primary key field(s) from a payload to build a filter.
382
478
  */
package/dist/index.mjs CHANGED
@@ -132,6 +132,14 @@ var AtscriptDbTable = class {
132
132
  this._flatten();
133
133
  return this._uniqueProps;
134
134
  }
135
+ /** Precomputed logical dot-path → physical column name map. */ get pathToPhysical() {
136
+ this._flatten();
137
+ return this._pathToPhysical;
138
+ }
139
+ /** Precomputed physical column name → logical dot-path map (inverse). */ get physicalToPath() {
140
+ this._flatten();
141
+ return this._physicalToPath;
142
+ }
135
143
  /** Descriptor for the primary ID field(s). */ getIdDescriptor() {
136
144
  this._flatten();
137
145
  return {
@@ -147,17 +155,31 @@ var AtscriptDbTable = class {
147
155
  this._flatten();
148
156
  if (!this._fieldDescriptors) {
149
157
  this._fieldDescriptors = [];
158
+ const skipFlattening = this.adapter.supportsNestedObjects();
150
159
  for (const [path, type] of this._flatMap.entries()) {
151
160
  if (!path) continue;
161
+ if (!skipFlattening && this._flattenedParents.has(path)) continue;
162
+ if (!skipFlattening && this._findJsonParent(path) !== undefined) continue;
163
+ const isJson = this._jsonFields.has(path);
164
+ const isFlattened = !skipFlattening && this._findFlattenedParent(path) !== undefined;
165
+ const designType = isJson ? "json" : resolveDesignType(type);
166
+ let storage;
167
+ if (skipFlattening) storage = "column";
168
+ else if (isJson) storage = "json";
169
+ else if (isFlattened) storage = "flattened";
170
+ else storage = "column";
171
+ const physicalName = skipFlattening ? this._columnMap.get(path) ?? path : this._pathToPhysical.get(path) ?? this._columnMap.get(path) ?? path;
152
172
  this._fieldDescriptors.push({
153
173
  path,
154
174
  type,
155
- physicalName: this._columnMap.get(path) ?? path,
156
- designType: resolveDesignType(type),
175
+ physicalName,
176
+ designType,
157
177
  optional: type.optional === true,
158
178
  isPrimaryKey: this._primaryKeys.includes(path),
159
179
  ignored: this._ignoredFields.has(path),
160
- defaultValue: this._defaults.get(path)
180
+ defaultValue: this._defaults.get(path),
181
+ storage,
182
+ flattenedFrom: isFlattened ? path : undefined
161
183
  });
162
184
  }
163
185
  }
@@ -172,12 +194,19 @@ var AtscriptDbTable = class {
172
194
  */ resolveProjection(select) {
173
195
  if (!select) return undefined;
174
196
  if (Array.isArray(select)) return select.length > 0 ? select : undefined;
175
- const entries = Object.entries(select);
176
- if (entries.length === 0) return undefined;
177
- const firstVal = entries[0][1];
178
- if (firstVal === 1) return entries.filter(([, v]) => v === 1).map(([k]) => k);
179
- const excluded = new Set(entries.filter(([, v]) => v === 0).map(([k]) => k));
180
- return this.fieldDescriptors.filter((f) => !f.ignored && !excluded.has(f.path)).map((f) => f.path);
197
+ const selectObj = select;
198
+ const keys = Object.keys(selectObj);
199
+ if (keys.length === 0) return undefined;
200
+ if (selectObj[keys[0]] === 1) {
201
+ const result$1 = [];
202
+ for (const k of keys) if (selectObj[k] === 1) result$1.push(k);
203
+ return result$1;
204
+ }
205
+ const excluded = new Set();
206
+ for (const k of keys) if (selectObj[k] === 0) excluded.add(k);
207
+ const result = [];
208
+ for (const f of this.fieldDescriptors) if (!f.ignored && !excluded.has(f.path)) result.push(f.path);
209
+ return result;
181
210
  }
182
211
  /**
183
212
  * Creates a new validator with custom options.
@@ -242,9 +271,9 @@ var AtscriptDbTable = class {
242
271
  const validator = this.getValidator("patch");
243
272
  if (!validator.validate(payload)) throw new Error("Validation failed for update");
244
273
  const filter = this._extractPrimaryKeyFilter(payload);
245
- if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(filter, payload);
274
+ if (this.adapter.supportsNativePatch()) return this.adapter.nativePatch(this._translateFilter(filter), payload);
246
275
  const update = decomposePatch(payload, this);
247
- return this.adapter.updateOne(filter, update);
276
+ return this.adapter.updateOne(this._translateFilter(filter), this._translatePatchKeys(update));
248
277
  }
249
278
  /**
250
279
  * Deletes a single record by primary key value.
@@ -264,35 +293,45 @@ var AtscriptDbTable = class {
264
293
  filter[field] = fieldType ? this.adapter.prepareId(idObj[field], fieldType) : idObj[field];
265
294
  }
266
295
  }
267
- return this.adapter.deleteOne(filter);
296
+ return this.adapter.deleteOne(this._translateFilter(filter));
268
297
  }
269
298
  /**
270
299
  * Finds a single record matching the query.
271
300
  */ async findOne(query) {
272
- return this.adapter.findOne(query);
301
+ this._flatten();
302
+ const translatedQuery = this._translateQuery(query);
303
+ const result = await this.adapter.findOne(translatedQuery);
304
+ return result ? this._reconstructFromRead(result) : null;
273
305
  }
274
306
  /**
275
307
  * Finds all records matching the query.
276
308
  */ async findMany(query) {
277
- return this.adapter.findMany(query);
309
+ this._flatten();
310
+ const translatedQuery = this._translateQuery(query);
311
+ const results = await this.adapter.findMany(translatedQuery);
312
+ return results.map((row) => this._reconstructFromRead(row));
278
313
  }
279
314
  /**
280
315
  * Counts records matching the query.
281
316
  */ async count(query) {
317
+ this._flatten();
282
318
  query ?? (query = {
283
319
  filter: {},
284
320
  controls: {}
285
321
  });
286
- return this.adapter.count(query);
322
+ return this.adapter.count(this._translateQuery(query));
287
323
  }
288
324
  async updateMany(filter, data) {
289
- return this.adapter.updateMany(filter, data);
325
+ this._flatten();
326
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
290
327
  }
291
328
  async replaceMany(filter, data) {
292
- return this.adapter.replaceMany(filter, data);
329
+ this._flatten();
330
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
293
331
  }
294
332
  async deleteMany(filter) {
295
- return this.adapter.deleteMany(filter);
333
+ this._flatten();
334
+ return this.adapter.deleteMany(this._translateFilter(filter));
296
335
  }
297
336
  /**
298
337
  * Synchronizes indexes between Atscript definitions and the database.
@@ -318,6 +357,7 @@ var AtscriptDbTable = class {
318
357
  this.adapter.onFieldScanned?.(path, type, metadata);
319
358
  }
320
359
  });
360
+ if (!this.adapter.supportsNestedObjects()) this._classifyFields();
321
361
  this._finalizeIndexes();
322
362
  this.adapter.onAfterFlatten?.();
323
363
  }
@@ -351,6 +391,11 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
351
391
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
352
392
  this._addIndexField("fulltext", name, fieldName);
353
393
  }
394
+ if (metadata.has("db.json")) {
395
+ this._jsonFields.add(fieldName);
396
+ const hasIndex = metadata.has("db.index.plain") || metadata.has("db.index.unique") || metadata.has("db.index.fulltext");
397
+ if (hasIndex) this.logger.warn(`@db.index on a @db.json field "${fieldName}" — most databases cannot index into JSON columns`);
398
+ }
354
399
  }
355
400
  _addIndexField(type, name, field, sort = "asc") {
356
401
  const key = indexKey(type, name);
@@ -367,7 +412,66 @@ else this._indexes.set(key, {
367
412
  fields: [indexField]
368
413
  });
369
414
  }
415
+ /**
416
+ * Classifies each field as column, flattened, json, or parent-object.
417
+ * Builds the bidirectional _pathToPhysical / _physicalToPath maps.
418
+ * Only called when the adapter does NOT support nested objects natively.
419
+ */ _classifyFields() {
420
+ for (const [path, type] of this._flatMap.entries()) {
421
+ if (!path) continue;
422
+ const designType = resolveDesignType(type);
423
+ const isJson = this._jsonFields.has(path);
424
+ const isArray = designType === "array";
425
+ const isObject = designType === "object";
426
+ if (isArray) this._jsonFields.add(path);
427
+ else if (isObject && isJson) {} else if (isObject && !isJson) this._flattenedParents.add(path);
428
+ }
429
+ for (const ignoredField of this._ignoredFields) if (this._flattenedParents.has(ignoredField)) {
430
+ const prefix = `${ignoredField}.`;
431
+ for (const path of this._flatMap.keys()) if (path.startsWith(prefix)) this._ignoredFields.add(path);
432
+ }
433
+ 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`);
434
+ for (const [path] of this._flatMap.entries()) {
435
+ if (!path) continue;
436
+ if (this._flattenedParents.has(path)) continue;
437
+ if (this._findJsonParent(path) !== undefined) continue;
438
+ const isFlattened = this._findFlattenedParent(path) !== undefined;
439
+ const physicalName = this._columnMap.get(path) ?? (isFlattened ? path.replace(/\./g, "__") : path);
440
+ this._pathToPhysical.set(path, physicalName);
441
+ this._physicalToPath.set(physicalName, path);
442
+ }
443
+ for (const parentPath of this._flattenedParents) {
444
+ const prefix = `${parentPath}.`;
445
+ const leaves = [];
446
+ for (const [path, physical] of this._pathToPhysical) if (path.startsWith(prefix)) leaves.push(physical);
447
+ if (leaves.length > 0) this._selectExpansion.set(parentPath, leaves);
448
+ }
449
+ this._requiresMappings = this._flattenedParents.size > 0 || this._jsonFields.size > 0;
450
+ }
451
+ /**
452
+ * Finds the nearest ancestor that is a flattened parent object.
453
+ * Returns the parent path, or undefined if no ancestor is being flattened.
454
+ */ _findFlattenedParent(path) {
455
+ let pos = path.length;
456
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
457
+ const ancestor = path.slice(0, pos);
458
+ if (this._flattenedParents.has(ancestor)) return ancestor;
459
+ }
460
+ return undefined;
461
+ }
462
+ /**
463
+ * Finds the nearest ancestor that is a @db.json field.
464
+ * Children of JSON fields don't get their own columns.
465
+ */ _findJsonParent(path) {
466
+ let pos = path.length;
467
+ while ((pos = path.lastIndexOf(".", pos - 1)) !== -1) {
468
+ const ancestor = path.slice(0, pos);
469
+ if (this._jsonFields.has(ancestor)) return ancestor;
470
+ }
471
+ return undefined;
472
+ }
370
473
  _finalizeIndexes() {
474
+ 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;
371
475
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
372
476
  }
373
477
  /**
@@ -381,7 +485,7 @@ else this._indexes.set(key, {
381
485
  }
382
486
  /**
383
487
  * Prepares a payload for writing to the database:
384
- * prepares IDs, strips ignored fields, maps column names.
488
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
385
489
  * Defaults should be applied before this via `_applyDefaults`.
386
490
  */ _prepareForWrite(payload) {
387
491
  const data = { ...payload };
@@ -389,12 +493,213 @@ else this._indexes.set(key, {
389
493
  const fieldType = this._flatMap?.get(pk);
390
494
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
391
495
  }
392
- for (const field of this._ignoredFields) delete data[field];
393
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
394
- data[physical] = data[logical];
395
- delete data[logical];
496
+ for (const field of this._ignoredFields) if (!field.includes(".")) delete data[field];
497
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) {
498
+ for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
499
+ data[physical] = data[logical];
500
+ delete data[logical];
501
+ }
502
+ return data;
396
503
  }
397
- return data;
504
+ return this._flattenPayload(data);
505
+ }
506
+ /**
507
+ * Flattens nested object fields into __-separated keys and
508
+ * JSON-stringifies @db.json / array fields.
509
+ * Uses _pathToPhysical for final key names.
510
+ */ _flattenPayload(data) {
511
+ const result = {};
512
+ const dataKeys = Object.keys(data);
513
+ for (const key of dataKeys) {
514
+ const value = data[key];
515
+ if (this._flattenedParents.has(key)) {
516
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(key, result);
517
+ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(key, value, result);
518
+ } else if (this._jsonFields.has(key)) {
519
+ const physical = this._pathToPhysical.get(key) ?? key;
520
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
521
+ } else {
522
+ const physical = this._pathToPhysical.get(key) ?? key;
523
+ result[physical] = value;
524
+ }
525
+ }
526
+ return result;
527
+ }
528
+ /**
529
+ * Recursively flattens a nested object, writing physical keys to result.
530
+ */ _flattenObject(prefix, obj, result) {
531
+ const objKeys = Object.keys(obj);
532
+ for (const key of objKeys) {
533
+ const value = obj[key];
534
+ const dotPath = `${prefix}.${key}`;
535
+ if (this._ignoredFields.has(dotPath)) continue;
536
+ if (this._flattenedParents.has(dotPath)) {
537
+ if (value === null || value === undefined) this._setFlattenedChildrenNull(dotPath, result);
538
+ else if (typeof value === "object" && !Array.isArray(value)) this._flattenObject(dotPath, value, result);
539
+ } else if (this._jsonFields.has(dotPath)) {
540
+ const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
541
+ result[physical] = value !== undefined && value !== null ? JSON.stringify(value) : value;
542
+ } else {
543
+ const physical = this._pathToPhysical.get(dotPath) ?? dotPath.replace(/\./g, "__");
544
+ result[physical] = value;
545
+ }
546
+ }
547
+ }
548
+ /**
549
+ * When a parent object is null/undefined, set all its flattened children to null.
550
+ */ _setFlattenedChildrenNull(parentPath, result) {
551
+ const prefix = `${parentPath}.`;
552
+ for (const [path, physical] of this._pathToPhysical.entries()) if (path.startsWith(prefix)) result[physical] = null;
553
+ }
554
+ /**
555
+ * Reconstructs nested objects from flat __-separated column values.
556
+ * JSON fields are parsed from strings back to objects/arrays.
557
+ */ _reconstructFromRead(row) {
558
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return row;
559
+ const result = {};
560
+ const rowKeys = Object.keys(row);
561
+ for (const physical of rowKeys) {
562
+ const value = row[physical];
563
+ const logicalPath = this._physicalToPath.get(physical);
564
+ if (!logicalPath) {
565
+ result[physical] = value;
566
+ continue;
567
+ }
568
+ if (this._jsonFields.has(logicalPath)) {
569
+ const parsed = typeof value === "string" ? JSON.parse(value) : value;
570
+ this._setNestedValue(result, logicalPath, parsed);
571
+ } else if (logicalPath.includes(".")) this._setNestedValue(result, logicalPath, value);
572
+ else result[logicalPath] = value;
573
+ }
574
+ for (const parentPath of this._flattenedParents) this._reconstructNullParent(result, parentPath);
575
+ return result;
576
+ }
577
+ /**
578
+ * Sets a value at a dot-notation path, creating intermediate objects as needed.
579
+ */ _setNestedValue(obj, dotPath, value) {
580
+ const parts = dotPath.split(".");
581
+ let current = obj;
582
+ for (let i = 0; i < parts.length - 1; i++) {
583
+ const part = parts[i];
584
+ if (current[part] === undefined || current[part] === null) current[part] = {};
585
+ current = current[part];
586
+ }
587
+ current[parts[parts.length - 1]] = value;
588
+ }
589
+ /**
590
+ * If all children of a flattened parent are null, collapse the parent to null.
591
+ */ _reconstructNullParent(obj, parentPath) {
592
+ const parts = parentPath.split(".");
593
+ let current = obj;
594
+ for (let i = 0; i < parts.length - 1; i++) {
595
+ if (current[parts[i]] === undefined) return;
596
+ current = current[parts[i]];
597
+ }
598
+ const lastPart = parts[parts.length - 1];
599
+ const parentObj = current[lastPart];
600
+ if (typeof parentObj !== "object" || parentObj === null) return;
601
+ let allNull = true;
602
+ const parentKeys = Object.keys(parentObj);
603
+ for (const k of parentKeys) {
604
+ const v = parentObj[k];
605
+ if (v !== null && v !== undefined) {
606
+ allNull = false;
607
+ break;
608
+ }
609
+ }
610
+ if (allNull) {
611
+ const parentType = this._flatMap?.get(parentPath);
612
+ current[lastPart] = parentType?.optional ? null : {};
613
+ }
614
+ }
615
+ /**
616
+ * Translates a Uniquery's filter, sort, and projection from logical
617
+ * dot-notation paths to physical column names.
618
+ */ _translateQuery(query) {
619
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return query;
620
+ return {
621
+ filter: this._translateFilter(query.filter),
622
+ controls: query.controls ? this._translateControls(query.controls) : query.controls,
623
+ insights: query.insights
624
+ };
625
+ }
626
+ /**
627
+ * Recursively translates field names in a filter expression.
628
+ */ _translateFilter(filter) {
629
+ if (!filter || typeof filter !== "object") return filter;
630
+ if (!this._requiresMappings) return filter;
631
+ const result = {};
632
+ const filterKeys = Object.keys(filter);
633
+ for (const key of filterKeys) {
634
+ const value = filter[key];
635
+ if (key === "$and" || key === "$or") result[key] = value.map((f) => this._translateFilter(f));
636
+ else if (key === "$not") result[key] = this._translateFilter(value);
637
+ else if (key.startsWith("$")) result[key] = value;
638
+ else {
639
+ const physical = this._pathToPhysical.get(key) ?? key;
640
+ result[physical] = value;
641
+ }
642
+ }
643
+ return result;
644
+ }
645
+ /**
646
+ * Translates field names in sort and projection controls.
647
+ */ _translateControls(controls) {
648
+ if (!controls) return controls;
649
+ const result = { ...controls };
650
+ if (controls.$sort) {
651
+ const translated = {};
652
+ const sortObj = controls.$sort;
653
+ const sortKeys = Object.keys(sortObj);
654
+ for (const key of sortKeys) {
655
+ if (this._flattenedParents.has(key)) continue;
656
+ const physical = this._pathToPhysical.get(key) ?? key;
657
+ translated[physical] = sortObj[key];
658
+ }
659
+ result.$sort = translated;
660
+ }
661
+ if (controls.$select) if (Array.isArray(controls.$select)) {
662
+ const expanded = [];
663
+ for (const key of controls.$select) {
664
+ const expansion = this._selectExpansion.get(key);
665
+ if (expansion) expanded.push(...expansion);
666
+ else expanded.push(this._pathToPhysical.get(key) ?? key);
667
+ }
668
+ result.$select = expanded;
669
+ } else {
670
+ const translated = {};
671
+ const selectObj = controls.$select;
672
+ const selectKeys = Object.keys(selectObj);
673
+ for (const key of selectKeys) {
674
+ const val = selectObj[key];
675
+ const expansion = this._selectExpansion.get(key);
676
+ if (expansion) for (const leaf of expansion) translated[leaf] = val;
677
+ else {
678
+ const physical = this._pathToPhysical.get(key) ?? key;
679
+ translated[physical] = val;
680
+ }
681
+ }
682
+ result.$select = translated;
683
+ }
684
+ return result;
685
+ }
686
+ /**
687
+ * Translates dot-notation keys in a decomposed patch to physical column names.
688
+ */ _translatePatchKeys(update) {
689
+ if (!this._requiresMappings || this.adapter.supportsNestedObjects()) return update;
690
+ const result = {};
691
+ const updateKeys = Object.keys(update);
692
+ for (const key of updateKeys) {
693
+ const value = update[key];
694
+ const operatorMatch = key.match(/^(.+?)(\.__\$.+)$/);
695
+ const basePath = operatorMatch ? operatorMatch[1] : key;
696
+ const suffix = operatorMatch ? operatorMatch[2] : "";
697
+ const physical = this._pathToPhysical.get(basePath) ?? basePath;
698
+ const finalKey = physical + suffix;
699
+ if (this._jsonFields.has(basePath) && typeof value === "object" && value !== null && !suffix) result[finalKey] = JSON.stringify(value);
700
+ else result[finalKey] = value;
701
+ }
702
+ return result;
398
703
  }
399
704
  /**
400
705
  * Extracts primary key field(s) from a payload to build a filter.
@@ -446,6 +751,12 @@ else this._indexes.set(key, {
446
751
  _define_property$1(this, "_defaults", void 0);
447
752
  _define_property$1(this, "_ignoredFields", void 0);
448
753
  _define_property$1(this, "_uniqueProps", void 0);
754
+ /** Logical dot-path → physical column name. */ _define_property$1(this, "_pathToPhysical", void 0);
755
+ /** Physical column name → logical dot-path (inverse). */ _define_property$1(this, "_physicalToPath", void 0);
756
+ /** Object paths being flattened into __-separated columns (no column themselves). */ _define_property$1(this, "_flattenedParents", void 0);
757
+ /** Fields stored as JSON (@db.json + array fields). */ _define_property$1(this, "_jsonFields", void 0);
758
+ /** Intermediate paths → their leaf physical column names (for $select expansion in relational DBs). */ _define_property$1(this, "_selectExpansion", void 0);
759
+ /** Fast-path flag: skip all mapping when no nested/json fields exist. */ _define_property$1(this, "_requiresMappings", void 0);
449
760
  _define_property$1(this, "validators", void 0);
450
761
  this._type = _type;
451
762
  this.adapter = adapter;
@@ -456,6 +767,12 @@ else this._indexes.set(key, {
456
767
  this._defaults = new Map();
457
768
  this._ignoredFields = new Set();
458
769
  this._uniqueProps = new Set();
770
+ this._pathToPhysical = new Map();
771
+ this._physicalToPath = new Map();
772
+ this._flattenedParents = new Set();
773
+ this._jsonFields = new Set();
774
+ this._selectExpansion = new Map();
775
+ this._requiresMappings = false;
459
776
  this.validators = new Map();
460
777
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
461
778
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -515,6 +832,14 @@ var BaseDbAdapter = class {
515
832
  return false;
516
833
  }
517
834
  /**
835
+ * Whether this adapter handles nested objects natively.
836
+ * When `true`, the generic layer skips flattening and
837
+ * passes nested objects as-is to the adapter.
838
+ * MongoDB returns `true`; relational adapters return `false` (default).
839
+ */ supportsNestedObjects() {
840
+ return false;
841
+ }
842
+ /**
518
843
  * Applies a patch payload using native database operations.
519
844
  * Only called when {@link supportsNativePatch} returns `true`.
520
845
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/utils-db",
3
- "version": "0.1.30",
3
+ "version": "0.1.31",
4
4
  "description": "Database adapter utilities for atscript.",
5
5
  "keywords": [
6
6
  "atscript",
@@ -37,8 +37,8 @@
37
37
  },
38
38
  "peerDependencies": {
39
39
  "@uniqu/core": "^0.0.1",
40
- "@atscript/core": "^0.1.30",
41
- "@atscript/typescript": "^0.1.30"
40
+ "@atscript/core": "^0.1.31",
41
+ "@atscript/typescript": "^0.1.31"
42
42
  },
43
43
  "scripts": {
44
44
  "pub": "pnpm publish --access public",