metal-orm 1.0.51 → 1.0.52

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.0.51",
3
+ "version": "1.0.52",
4
4
  "type": "module",
5
5
  "types": "./dist/index.d.ts",
6
6
  "engines": {
package/src/index.ts CHANGED
@@ -38,8 +38,10 @@ export * from './orm/execution-context.js';
38
38
  export * from './orm/hydration-context.js';
39
39
  export * from './orm/domain-event-bus.js';
40
40
  export * from './orm/runtime-types.js';
41
- export * from './orm/query-logger.js';
42
- export * from './decorators/index.js';
41
+ export * from './orm/query-logger.js';
42
+ export * from './orm/jsonify.js';
43
+ export * from './orm/save-graph-types.js';
44
+ export * from './decorators/index.js';
43
45
 
44
46
  // NEW: execution abstraction + helpers
45
47
  export * from './core/execution/db-executor.js';
@@ -0,0 +1,27 @@
1
+ export type JsonifyScalar<T> = T extends Date ? string : T;
2
+
3
+ /**
4
+ * Shallow JSON-friendly mapping:
5
+ * - Date -> ISO string
6
+ * - Everything else unchanged
7
+ */
8
+ export type Jsonify<T> = {
9
+ [K in keyof T]: JsonifyScalar<T[K]>;
10
+ };
11
+
12
+ /**
13
+ * Creates a shallow, JSON-friendly copy of an object by converting `Date` values to ISO strings.
14
+ * This intentionally does not deep-walk nested objects/relations.
15
+ */
16
+ export const jsonify = <T extends object>(value: T): Jsonify<T> => {
17
+ const record = value as Record<string, unknown>;
18
+ const result: Record<string, unknown> = {};
19
+
20
+ for (const key of Object.keys(record)) {
21
+ const entry = record[key];
22
+ result[key] = entry instanceof Date ? entry.toISOString() : entry;
23
+ }
24
+
25
+ return result as Jsonify<T>;
26
+ };
27
+
@@ -27,9 +27,10 @@ import {
27
27
  RelationKey,
28
28
  TrackedEntity
29
29
  } from './runtime-types.js';
30
- import { executeHydrated } from './execute.js';
31
- import { runInTransaction } from './transaction-runner.js';
32
- import { saveGraphInternal, SaveGraphOptions } from './save-graph.js';
30
+ import { executeHydrated } from './execute.js';
31
+ import { runInTransaction } from './transaction-runner.js';
32
+ import { saveGraphInternal, SaveGraphOptions } from './save-graph.js';
33
+ import type { SaveGraphInputPayload } from './save-graph-types.js';
33
34
 
34
35
  /**
35
36
  * Interface for ORM interceptors that allow hooking into the flush lifecycle.
@@ -297,25 +298,30 @@ export class OrmSession<E extends DomainEvent = OrmDomainEvent> implements Entit
297
298
  return executeHydrated(this, qb);
298
299
  }
299
300
 
300
- /**
301
- * Saves an entity graph (root + nested relations) based on a DTO-like payload.
302
- * @param entityClass - Root entity constructor
303
- * @param payload - DTO payload containing column values and nested relations
304
- * @param options - Graph save options
305
- * @returns The root entity instance
306
- */
307
- async saveGraph<TCtor extends EntityConstructor<object>>(
308
- entityClass: TCtor,
309
- payload: Record<string, unknown>,
310
- options?: SaveGraphOptions & { transactional?: boolean }
311
- ): Promise<InstanceType<TCtor>> {
312
- const { transactional = true, ...graphOptions } = options ?? {};
313
- const execute = () => saveGraphInternal(this, entityClass, payload, graphOptions);
314
- if (!transactional) {
315
- return execute();
316
- }
317
- return this.transaction(() => execute());
318
- }
301
+ /**
302
+ * Saves an entity graph (root + nested relations) based on a DTO-like payload.
303
+ * @param entityClass - Root entity constructor
304
+ * @param payload - DTO payload containing column values and nested relations
305
+ * @param options - Graph save options
306
+ * @returns The root entity instance
307
+ */
308
+ async saveGraph<TCtor extends EntityConstructor<object>>(
309
+ entityClass: TCtor,
310
+ payload: SaveGraphInputPayload<InstanceType<TCtor>>,
311
+ options?: SaveGraphOptions & { transactional?: boolean }
312
+ ): Promise<InstanceType<TCtor>>;
313
+ async saveGraph<TCtor extends EntityConstructor<object>>(
314
+ entityClass: TCtor,
315
+ payload: Record<string, unknown>,
316
+ options?: SaveGraphOptions & { transactional?: boolean }
317
+ ): Promise<InstanceType<TCtor>> {
318
+ const { transactional = true, ...graphOptions } = options ?? {};
319
+ const execute = () => saveGraphInternal(this, entityClass, payload, graphOptions);
320
+ if (!transactional) {
321
+ return execute();
322
+ }
323
+ return this.transaction(() => execute());
324
+ }
319
325
 
320
326
  /**
321
327
  * Persists an entity (either inserts or updates).
@@ -0,0 +1,57 @@
1
+ import type {
2
+ BelongsToReference,
3
+ HasManyCollection,
4
+ HasOneReference,
5
+ ManyToManyCollection
6
+ } from '../schema/types.js';
7
+
8
+ type AnyId = number | string;
9
+ type AnyFn = (...args: unknown[]) => unknown;
10
+
11
+ type RelationWrapper =
12
+ | HasManyCollection<unknown>
13
+ | HasOneReference<unknown>
14
+ | BelongsToReference<unknown>
15
+ | ManyToManyCollection<unknown>;
16
+
17
+ type FunctionKeys<T> = {
18
+ [K in keyof T & string]-?: T[K] extends AnyFn ? K : never;
19
+ }[keyof T & string];
20
+
21
+ type RelationKeys<T> = {
22
+ [K in keyof T & string]-?: NonNullable<T[K]> extends RelationWrapper ? K : never;
23
+ }[keyof T & string];
24
+
25
+ type ColumnKeys<T> = Exclude<keyof T & string, FunctionKeys<T> | RelationKeys<T>>;
26
+
27
+ export type SaveGraphJsonScalar<T> = T extends Date ? string : T;
28
+
29
+ /**
30
+ * Input scalar type that accepts JSON-friendly values for common runtime types.
31
+ * Currently:
32
+ * - Date fields accept `Date | string` (ISO string recommended)
33
+ */
34
+ export type SaveGraphInputScalar<T> =
35
+ T extends Date ? Date | string : T;
36
+
37
+ type ColumnInput<TEntity> = {
38
+ [K in ColumnKeys<TEntity>]?: SaveGraphInputScalar<TEntity[K]>;
39
+ };
40
+
41
+ type RelationInputValue<T> =
42
+ T extends HasManyCollection<infer C> ? Array<SaveGraphInputPayload<C> | AnyId> :
43
+ T extends HasOneReference<infer C> ? SaveGraphInputPayload<C> | AnyId | null :
44
+ T extends BelongsToReference<infer P> ? SaveGraphInputPayload<P> | AnyId | null :
45
+ T extends ManyToManyCollection<infer Tgt> ? Array<SaveGraphInputPayload<Tgt> | AnyId> :
46
+ never;
47
+
48
+ type RelationInput<TEntity> = {
49
+ [K in RelationKeys<TEntity>]?: RelationInputValue<NonNullable<TEntity[K]>>;
50
+ };
51
+
52
+ /**
53
+ * Typed payload accepted by `OrmSession.saveGraph`:
54
+ * - Only entity scalar keys + relation keys are accepted.
55
+ * - Scalars can use JSON-friendly values (e.g., Date fields accept ISO strings).
56
+ */
57
+ export type SaveGraphInputPayload<TEntity> = ColumnInput<TEntity> & RelationInput<TEntity>;
@@ -1,15 +1,16 @@
1
- import type {
2
- EntityInstance,
3
- HasManyCollection,
4
- HasOneReference,
5
- BelongsToReference,
6
- ManyToManyCollection
7
- } from '../schema/types.js';
8
- import {
9
- RelationKinds,
10
- type BelongsToManyRelation,
11
- type BelongsToRelation,
12
- type HasManyRelation,
1
+ import type {
2
+ EntityInstance,
3
+ HasManyCollection,
4
+ HasOneReference,
5
+ BelongsToReference,
6
+ ManyToManyCollection
7
+ } from '../schema/types.js';
8
+ import { normalizeColumnType, type ColumnDef } from '../schema/column-types.js';
9
+ import {
10
+ RelationKinds,
11
+ type BelongsToManyRelation,
12
+ type BelongsToRelation,
13
+ type HasManyRelation,
13
14
  type HasOneRelation,
14
15
  type RelationDef
15
16
  } from '../schema/relation.js';
@@ -23,10 +24,16 @@ import type { OrmSession } from './orm-session.js';
23
24
  /**
24
25
  * Options for controlling the behavior of save graph operations.
25
26
  */
26
- export interface SaveGraphOptions {
27
- /** Remove existing collection members that are not present in the payload */
28
- pruneMissing?: boolean;
29
- }
27
+ export interface SaveGraphOptions {
28
+ /** Remove existing collection members that are not present in the payload */
29
+ pruneMissing?: boolean;
30
+ /**
31
+ * Coerce JSON-friendly input values into DB-friendly primitives.
32
+ * Currently:
33
+ * - Date -> ISO string (for DATE/DATETIME/TIMESTAMP/TIMESTAMPTZ columns)
34
+ */
35
+ coerce?: 'json';
36
+ }
30
37
 
31
38
  /** Represents an entity object with arbitrary properties. */
32
39
 
@@ -44,26 +51,55 @@ type AnyEntity = Record<string, unknown>;
44
51
 
45
52
  */
46
53
 
47
- const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
48
-
49
- const pickColumns = (table: TableDef, payload: AnyEntity): Record<string, unknown> => {
50
- const columns: Record<string, unknown> = {};
51
- for (const key of Object.keys(table.columns)) {
52
- if (payload[key] !== undefined) {
53
- columns[key] = payload[key];
54
- }
55
- }
56
- return columns;
57
- };
58
-
59
- const ensureEntity = <TTable extends TableDef>(
60
- session: OrmSession,
61
- table: TTable,
62
- payload: AnyEntity
63
- ): EntityInstance<TTable> => {
64
- const pk = findPrimaryKey(table);
65
- const row = pickColumns(table, payload);
66
- const pkValue = payload[pk];
54
+ const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
55
+
56
+ const coerceColumnValue = (
57
+ table: TableDef,
58
+ columnName: string,
59
+ value: unknown,
60
+ options: SaveGraphOptions
61
+ ): unknown => {
62
+ if (options.coerce !== 'json') return value;
63
+ if (value === null || value === undefined) return value;
64
+
65
+ const column = table.columns[columnName] as unknown as ColumnDef | undefined;
66
+ if (!column) return value;
67
+
68
+ const normalized = normalizeColumnType(column.type);
69
+
70
+ const isDateLikeColumn =
71
+ normalized === 'date' ||
72
+ normalized === 'datetime' ||
73
+ normalized === 'timestamp' ||
74
+ normalized === 'timestamptz';
75
+
76
+ if (isDateLikeColumn && value instanceof Date) {
77
+ return value.toISOString();
78
+ }
79
+
80
+ // Future coercions can be added here based on `normalized`.
81
+ return value;
82
+ };
83
+
84
+ const pickColumns = (table: TableDef, payload: AnyEntity, options: SaveGraphOptions): Record<string, unknown> => {
85
+ const columns: Record<string, unknown> = {};
86
+ for (const key of Object.keys(table.columns)) {
87
+ if (payload[key] !== undefined) {
88
+ columns[key] = coerceColumnValue(table, key, payload[key], options);
89
+ }
90
+ }
91
+ return columns;
92
+ };
93
+
94
+ const ensureEntity = <TTable extends TableDef>(
95
+ session: OrmSession,
96
+ table: TTable,
97
+ payload: AnyEntity,
98
+ options: SaveGraphOptions
99
+ ): EntityInstance<TTable> => {
100
+ const pk = findPrimaryKey(table);
101
+ const row = pickColumns(table, payload, options);
102
+ const pkValue = payload[pk];
67
103
 
68
104
  if (pkValue !== undefined && pkValue !== null) {
69
105
  const tracked = session.getEntity(table, pkValue);
@@ -76,16 +112,16 @@ const ensureEntity = <TTable extends TableDef>(
76
112
  }
77
113
  }
78
114
 
79
- return createEntityFromRow(session, table, row) as EntityInstance<TTable>;
80
- };
81
-
82
- const assignColumns = (table: TableDef, entity: AnyEntity, payload: AnyEntity): void => {
83
- for (const key of Object.keys(table.columns)) {
84
- if (payload[key] !== undefined) {
85
- entity[key] = payload[key];
86
- }
87
- }
88
- };
115
+ return createEntityFromRow(session, table, row) as EntityInstance<TTable>;
116
+ };
117
+
118
+ const assignColumns = (table: TableDef, entity: AnyEntity, payload: AnyEntity, options: SaveGraphOptions): void => {
119
+ for (const key of Object.keys(table.columns)) {
120
+ if (payload[key] !== undefined) {
121
+ entity[key] = coerceColumnValue(table, key, payload[key], options);
122
+ }
123
+ }
124
+ };
89
125
 
90
126
  const isEntityInCollection = (items: AnyEntity[], pkName: string, entity: AnyEntity): boolean => {
91
127
  if (items.includes(entity)) return true;
@@ -121,13 +157,13 @@ const handleHasMany = async (
121
157
  const asObj = typeof item === 'object' ? (item as AnyEntity) : { [targetPk]: item };
122
158
  const pkValue = asObj[targetPk];
123
159
 
124
- const current =
125
- findInCollectionByPk(existing, targetPk, pkValue) ??
126
- (pkValue !== undefined && pkValue !== null ? session.getEntity(targetTable, pkValue) : undefined);
127
-
128
- const entity = current ?? ensureEntity(session, targetTable, asObj);
129
- assignColumns(targetTable, entity as AnyEntity, asObj);
130
- await applyGraphToEntity(session, targetTable, entity as AnyEntity, asObj, options);
160
+ const current =
161
+ findInCollectionByPk(existing, targetPk, pkValue) ??
162
+ (pkValue !== undefined && pkValue !== null ? session.getEntity(targetTable, pkValue) : undefined);
163
+
164
+ const entity = current ?? ensureEntity(session, targetTable, asObj, options);
165
+ assignColumns(targetTable, entity as AnyEntity, asObj, options);
166
+ await applyGraphToEntity(session, targetTable, entity as AnyEntity, asObj, options);
131
167
 
132
168
  if (!isEntityInCollection(collection.getItems() as unknown as AnyEntity[], targetPk, entity as unknown as AnyEntity)) {
133
169
  collection.attach(entity);
@@ -170,11 +206,11 @@ const handleHasOne = async (
170
206
  }
171
207
  return;
172
208
  }
173
- const attached = ref.set(payload as AnyEntity);
174
- if (attached) {
175
- await applyGraphToEntity(session, relation.target, attached as AnyEntity, payload as AnyEntity, options);
176
- }
177
- };
209
+ const attached = ref.set(payload as AnyEntity);
210
+ if (attached) {
211
+ await applyGraphToEntity(session, relation.target, attached as AnyEntity, payload as AnyEntity, options);
212
+ }
213
+ };
178
214
 
179
215
  const handleBelongsTo = async (
180
216
  session: OrmSession,
@@ -198,11 +234,11 @@ const handleBelongsTo = async (
198
234
  }
199
235
  return;
200
236
  }
201
- const attached = ref.set(payload as AnyEntity);
202
- if (attached) {
203
- await applyGraphToEntity(session, relation.target, attached as AnyEntity, payload as AnyEntity, options);
204
- }
205
- };
237
+ const attached = ref.set(payload as AnyEntity);
238
+ if (attached) {
239
+ await applyGraphToEntity(session, relation.target, attached as AnyEntity, payload as AnyEntity, options);
240
+ }
241
+ };
206
242
 
207
243
  const handleBelongsToMany = async (
208
244
  session: OrmSession,
@@ -229,14 +265,14 @@ const handleBelongsToMany = async (
229
265
  continue;
230
266
  }
231
267
 
232
- const asObj = item as AnyEntity;
233
- const pkValue = asObj[targetPk];
234
- const entity = pkValue !== undefined && pkValue !== null
235
- ? session.getEntity(targetTable, pkValue) ?? ensureEntity(session, targetTable, asObj)
236
- : ensureEntity(session, targetTable, asObj);
237
-
238
- assignColumns(targetTable, entity as AnyEntity, asObj);
239
- await applyGraphToEntity(session, targetTable, entity as AnyEntity, asObj, options);
268
+ const asObj = item as AnyEntity;
269
+ const pkValue = asObj[targetPk];
270
+ const entity = pkValue !== undefined && pkValue !== null
271
+ ? session.getEntity(targetTable, pkValue) ?? ensureEntity(session, targetTable, asObj, options)
272
+ : ensureEntity(session, targetTable, asObj, options);
273
+
274
+ assignColumns(targetTable, entity as AnyEntity, asObj, options);
275
+ await applyGraphToEntity(session, targetTable, entity as AnyEntity, asObj, options);
240
276
 
241
277
  if (!isEntityInCollection(collection.getItems() as unknown as AnyEntity[], targetPk, entity as unknown as AnyEntity)) {
242
278
  collection.attach(entity);
@@ -278,36 +314,36 @@ const applyRelation = async (
278
314
  }
279
315
  };
280
316
 
281
- const applyGraphToEntity = async (
282
- session: OrmSession,
283
- table: TableDef,
284
- entity: AnyEntity,
285
- payload: AnyEntity,
286
- options: SaveGraphOptions
287
- ): Promise<void> => {
288
- assignColumns(table, entity, payload);
289
-
290
- for (const [relationName, relation] of Object.entries(table.relations)) {
291
- if (!(relationName in payload)) continue;
292
- await applyRelation(session, table, entity, relationName, relation as RelationDef, payload[relationName], options);
293
- }
294
- };
295
-
296
- export const saveGraph = async <TTable extends TableDef>(
297
- session: OrmSession,
298
- entityClass: EntityConstructor,
299
- payload: AnyEntity,
300
- options: SaveGraphOptions = {}
301
- ): Promise<EntityInstance<TTable>> => {
317
+ const applyGraphToEntity = async (
318
+ session: OrmSession,
319
+ table: TableDef,
320
+ entity: AnyEntity,
321
+ payload: AnyEntity,
322
+ options: SaveGraphOptions
323
+ ): Promise<void> => {
324
+ assignColumns(table, entity, payload, options);
325
+
326
+ for (const [relationName, relation] of Object.entries(table.relations)) {
327
+ if (!(relationName in payload)) continue;
328
+ await applyRelation(session, table, entity, relationName, relation as RelationDef, payload[relationName], options);
329
+ }
330
+ };
331
+
332
+ export const saveGraph = async <TTable extends TableDef>(
333
+ session: OrmSession,
334
+ entityClass: EntityConstructor,
335
+ payload: AnyEntity,
336
+ options: SaveGraphOptions = {}
337
+ ): Promise<EntityInstance<TTable>> => {
302
338
  const table = getTableDefFromEntity(entityClass);
303
339
  if (!table) {
304
340
  throw new Error('Entity metadata has not been bootstrapped');
305
341
  }
306
342
 
307
- const root = ensureEntity<TTable>(session, table as TTable, payload);
308
- await applyGraphToEntity(session, table, root as AnyEntity, payload, options);
309
- return root;
310
- };
343
+ const root = ensureEntity<TTable>(session, table as TTable, payload, options);
344
+ await applyGraphToEntity(session, table, root as AnyEntity, payload, options);
345
+ return root;
346
+ };
311
347
 
312
348
  /**
313
349
 
@@ -325,7 +361,7 @@ export const saveGraph = async <TTable extends TableDef>(
325
361
 
326
362
  */
327
363
 
328
- export const saveGraphInternal = async <TCtor extends EntityConstructor>(
364
+ export const saveGraphInternal = async <TCtor extends EntityConstructor>(
329
365
 
330
366
  session: OrmSession,
331
367
 
@@ -337,7 +373,7 @@ export const saveGraphInternal = async <TCtor extends EntityConstructor>(
337
373
 
338
374
  ): Promise<InstanceType<TCtor>> => {
339
375
 
340
- const table = getTableDefFromEntity(entityClass);
376
+ const table = getTableDefFromEntity(entityClass);
341
377
 
342
378
  if (!table) {
343
379
 
@@ -345,10 +381,10 @@ export const saveGraphInternal = async <TCtor extends EntityConstructor>(
345
381
 
346
382
  }
347
383
 
348
- const root = ensureEntity(session, table, payload);
349
-
350
- await applyGraphToEntity(session, table, root as AnyEntity, payload, options);
351
-
352
- return root as unknown as InstanceType<TCtor>;
353
-
354
- };
384
+ const root = ensureEntity(session, table, payload, options);
385
+
386
+ await applyGraphToEntity(session, table, root as AnyEntity, payload, options);
387
+
388
+ return root as unknown as InstanceType<TCtor>;
389
+
390
+ };