@atscript/utils-db 0.1.29 → 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,34 +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
- */ async count(query = {
306
- filter: {},
307
- controls: {}
308
- }) {
309
- return this.adapter.count(query);
340
+ */ async count(query) {
341
+ this._flatten();
342
+ query ?? (query = {
343
+ filter: {},
344
+ controls: {}
345
+ });
346
+ return this.adapter.count(this._translateQuery(query));
310
347
  }
311
348
  async updateMany(filter, data) {
312
- return this.adapter.updateMany(filter, data);
349
+ this._flatten();
350
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
313
351
  }
314
352
  async replaceMany(filter, data) {
315
- return this.adapter.replaceMany(filter, data);
353
+ this._flatten();
354
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
316
355
  }
317
356
  async deleteMany(filter) {
318
- return this.adapter.deleteMany(filter);
357
+ this._flatten();
358
+ return this.adapter.deleteMany(this._translateFilter(filter));
319
359
  }
320
360
  /**
321
361
  * Synchronizes indexes between Atscript definitions and the database.
@@ -341,6 +381,7 @@ var AtscriptDbTable = class {
341
381
  this.adapter.onFieldScanned?.(path, type, metadata);
342
382
  }
343
383
  });
384
+ if (!this.adapter.supportsNestedObjects()) this._classifyFields();
344
385
  this._finalizeIndexes();
345
386
  this.adapter.onAfterFlatten?.();
346
387
  }
@@ -374,6 +415,11 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
374
415
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
375
416
  this._addIndexField("fulltext", name, fieldName);
376
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
+ }
377
423
  }
378
424
  _addIndexField(type, name, field, sort = "asc") {
379
425
  const key = indexKey(type, name);
@@ -390,7 +436,66 @@ else this._indexes.set(key, {
390
436
  fields: [indexField]
391
437
  });
392
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
+ }
393
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;
394
499
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
395
500
  }
396
501
  /**
@@ -404,7 +509,7 @@ else this._indexes.set(key, {
404
509
  }
405
510
  /**
406
511
  * Prepares a payload for writing to the database:
407
- * prepares IDs, strips ignored fields, maps column names.
512
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
408
513
  * Defaults should be applied before this via `_applyDefaults`.
409
514
  */ _prepareForWrite(payload) {
410
515
  const data = { ...payload };
@@ -412,12 +517,213 @@ else this._indexes.set(key, {
412
517
  const fieldType = this._flatMap?.get(pk);
413
518
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
414
519
  }
415
- for (const field of this._ignoredFields) delete data[field];
416
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
417
- data[physical] = data[logical];
418
- 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;
419
527
  }
420
- 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;
421
727
  }
422
728
  /**
423
729
  * Extracts primary key field(s) from a payload to build a filter.
@@ -469,6 +775,12 @@ else this._indexes.set(key, {
469
775
  _define_property$1(this, "_defaults", void 0);
470
776
  _define_property$1(this, "_ignoredFields", void 0);
471
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);
472
784
  _define_property$1(this, "validators", void 0);
473
785
  this._type = _type;
474
786
  this.adapter = adapter;
@@ -479,6 +791,12 @@ else this._indexes.set(key, {
479
791
  this._defaults = new Map();
480
792
  this._ignoredFields = new Set();
481
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;
482
800
  this.validators = new Map();
483
801
  if (!(0, __atscript_typescript_utils.isAnnotatedType)(_type)) throw new Error("Atscript Annotated Type expected");
484
802
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -538,6 +856,14 @@ var BaseDbAdapter = class {
538
856
  return false;
539
857
  }
540
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
+ /**
541
867
  * Applies a patch payload using native database operations.
542
868
  * Only called when {@link supportsNativePatch} returns `true`.
543
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,34 +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
- */ async count(query = {
282
- filter: {},
283
- controls: {}
284
- }) {
285
- return this.adapter.count(query);
316
+ */ async count(query) {
317
+ this._flatten();
318
+ query ?? (query = {
319
+ filter: {},
320
+ controls: {}
321
+ });
322
+ return this.adapter.count(this._translateQuery(query));
286
323
  }
287
324
  async updateMany(filter, data) {
288
- return this.adapter.updateMany(filter, data);
325
+ this._flatten();
326
+ return this.adapter.updateMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
289
327
  }
290
328
  async replaceMany(filter, data) {
291
- return this.adapter.replaceMany(filter, data);
329
+ this._flatten();
330
+ return this.adapter.replaceMany(this._translateFilter(filter), this._prepareForWrite({ ...data }));
292
331
  }
293
332
  async deleteMany(filter) {
294
- return this.adapter.deleteMany(filter);
333
+ this._flatten();
334
+ return this.adapter.deleteMany(this._translateFilter(filter));
295
335
  }
296
336
  /**
297
337
  * Synchronizes indexes between Atscript definitions and the database.
@@ -317,6 +357,7 @@ var AtscriptDbTable = class {
317
357
  this.adapter.onFieldScanned?.(path, type, metadata);
318
358
  }
319
359
  });
360
+ if (!this.adapter.supportsNestedObjects()) this._classifyFields();
320
361
  this._finalizeIndexes();
321
362
  this.adapter.onAfterFlatten?.();
322
363
  }
@@ -350,6 +391,11 @@ else if (defaultFn !== undefined) this._defaults.set(fieldName, {
350
391
  const name = index === true ? fieldName : typeof index === "string" ? index : index?.name || fieldName;
351
392
  this._addIndexField("fulltext", name, fieldName);
352
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
+ }
353
399
  }
354
400
  _addIndexField(type, name, field, sort = "asc") {
355
401
  const key = indexKey(type, name);
@@ -366,7 +412,66 @@ else this._indexes.set(key, {
366
412
  fields: [indexField]
367
413
  });
368
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
+ }
369
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;
370
475
  for (const index of this._indexes.values()) if (index.type === "unique" && index.fields.length === 1) this._uniqueProps.add(index.fields[0].name);
371
476
  }
372
477
  /**
@@ -380,7 +485,7 @@ else this._indexes.set(key, {
380
485
  }
381
486
  /**
382
487
  * Prepares a payload for writing to the database:
383
- * prepares IDs, strips ignored fields, maps column names.
488
+ * prepares IDs, strips ignored fields, flattens nested objects, maps column names.
384
489
  * Defaults should be applied before this via `_applyDefaults`.
385
490
  */ _prepareForWrite(payload) {
386
491
  const data = { ...payload };
@@ -388,12 +493,213 @@ else this._indexes.set(key, {
388
493
  const fieldType = this._flatMap?.get(pk);
389
494
  if (fieldType) data[pk] = this.adapter.prepareId(data[pk], fieldType);
390
495
  }
391
- for (const field of this._ignoredFields) delete data[field];
392
- for (const [logical, physical] of this._columnMap.entries()) if (logical in data) {
393
- data[physical] = data[logical];
394
- 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;
395
503
  }
396
- 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;
397
703
  }
398
704
  /**
399
705
  * Extracts primary key field(s) from a payload to build a filter.
@@ -445,6 +751,12 @@ else this._indexes.set(key, {
445
751
  _define_property$1(this, "_defaults", void 0);
446
752
  _define_property$1(this, "_ignoredFields", void 0);
447
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);
448
760
  _define_property$1(this, "validators", void 0);
449
761
  this._type = _type;
450
762
  this.adapter = adapter;
@@ -455,6 +767,12 @@ else this._indexes.set(key, {
455
767
  this._defaults = new Map();
456
768
  this._ignoredFields = new Set();
457
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;
458
776
  this.validators = new Map();
459
777
  if (!isAnnotatedType(_type)) throw new Error("Atscript Annotated Type expected");
460
778
  if (_type.type.kind !== "object") throw new Error("Database table type must be an object type");
@@ -514,6 +832,14 @@ var BaseDbAdapter = class {
514
832
  return false;
515
833
  }
516
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
+ /**
517
843
  * Applies a patch payload using native database operations.
518
844
  * Only called when {@link supportsNativePatch} returns `true`.
519
845
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atscript/utils-db",
3
- "version": "0.1.29",
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.29",
41
- "@atscript/typescript": "^0.1.29"
40
+ "@atscript/core": "^0.1.31",
41
+ "@atscript/typescript": "^0.1.31"
42
42
  },
43
43
  "scripts": {
44
44
  "pub": "pnpm publish --access public",