metal-orm 1.0.50 → 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.
@@ -54,12 +54,18 @@ type ForeignKeyEntry = {
54
54
  onUpdate?: ReferentialAction;
55
55
  };
56
56
 
57
- type ColumnCommentRow = {
58
- table_schema: string;
59
- table_name: string;
60
- column_name: string;
61
- description: string | null;
62
- };
57
+ type ColumnCommentRow = {
58
+ table_schema: string;
59
+ table_name: string;
60
+ column_name: string;
61
+ description: string | null;
62
+ };
63
+
64
+ type TableCommentRow = {
65
+ table_schema: string;
66
+ table_name: string;
67
+ description: string | null;
68
+ };
63
69
 
64
70
  /** Row type for PostgreSQL index query results from pg_catalog tables. */
65
71
  type IndexQueryRow = {
@@ -111,32 +117,55 @@ export const postgresIntrospector: SchemaIntrospector = {
111
117
  .orderBy(PgInformationSchemaColumns.columns.ordinal_position);
112
118
 
113
119
  const columnRows = await runSelect<ColumnIntrospectRow>(qbColumns, ctx);
114
- const columnCommentRows = (await queryRows(
115
- ctx.executor,
116
- `
117
- SELECT
118
- ns.nspname AS table_schema,
119
- cls.relname AS table_name,
120
- att.attname AS column_name,
121
- pg_catalog.col_description(cls.oid, att.attnum) AS description
122
- FROM pg_catalog.pg_attribute att
123
- JOIN pg_catalog.pg_class cls ON cls.oid = att.attrelid
124
- JOIN pg_catalog.pg_namespace ns ON ns.oid = cls.relnamespace
125
- WHERE ns.nspname = $1
126
- AND att.attnum > 0
127
- AND NOT att.attisdropped
128
- `,
129
- [schema]
130
- )) as ColumnCommentRow[];
131
- const columnComments = new Map<string, string>();
132
- columnCommentRows.forEach(r => {
133
- if (!shouldIncludeTable(r.table_name, options)) return;
134
- if (!r.description) return;
135
- const key = `${r.table_schema}.${r.table_name}.${r.column_name}`;
136
- const trimmed = r.description.trim();
137
- if (!trimmed) return;
138
- columnComments.set(key, trimmed);
139
- });
120
+ const columnCommentRows = (await queryRows(
121
+ ctx.executor,
122
+ `
123
+ SELECT
124
+ ns.nspname AS table_schema,
125
+ cls.relname AS table_name,
126
+ att.attname AS column_name,
127
+ pg_catalog.col_description(cls.oid, att.attnum) AS description
128
+ FROM pg_catalog.pg_attribute att
129
+ JOIN pg_catalog.pg_class cls ON cls.oid = att.attrelid
130
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = cls.relnamespace
131
+ WHERE ns.nspname = $1
132
+ AND att.attnum > 0
133
+ AND NOT att.attisdropped
134
+ `,
135
+ [schema]
136
+ )) as ColumnCommentRow[];
137
+ const columnComments = new Map<string, string>();
138
+ columnCommentRows.forEach(r => {
139
+ if (!shouldIncludeTable(r.table_name, options)) return;
140
+ if (!r.description) return;
141
+ const key = `${r.table_schema}.${r.table_name}.${r.column_name}`;
142
+ const trimmed = r.description.trim();
143
+ if (!trimmed) return;
144
+ columnComments.set(key, trimmed);
145
+ });
146
+ const tableCommentRows = (await queryRows(
147
+ ctx.executor,
148
+ `
149
+ SELECT
150
+ ns.nspname AS table_schema,
151
+ cls.relname AS table_name,
152
+ pg_catalog.obj_description(cls.oid) AS description
153
+ FROM pg_catalog.pg_class cls
154
+ JOIN pg_catalog.pg_namespace ns ON ns.oid = cls.relnamespace
155
+ WHERE ns.nspname = $1
156
+ AND cls.relkind IN ('r', 'p')
157
+ `,
158
+ [schema]
159
+ )) as TableCommentRow[];
160
+ const tableComments = new Map<string, string>();
161
+ tableCommentRows.forEach(r => {
162
+ if (!shouldIncludeTable(r.table_name, options)) return;
163
+ if (!r.description) return;
164
+ const key = `${r.table_schema}.${r.table_name}`;
165
+ const trimmed = r.description.trim();
166
+ if (!trimmed) return;
167
+ tableComments.set(key, trimmed);
168
+ });
140
169
 
141
170
  // Primary key columns query
142
171
  const qbPk = new SelectQueryBuilder(PgKeyColumnUsage)
@@ -303,13 +332,14 @@ export const postgresIntrospector: SchemaIntrospector = {
303
332
  return;
304
333
  }
305
334
  if (!tablesByKey.has(key)) {
306
- tablesByKey.set(key, {
307
- name: r.table_name,
308
- schema: r.table_schema,
309
- columns: [],
310
- primaryKey: pkMap.get(key) || [],
311
- indexes: []
312
- });
335
+ tablesByKey.set(key, {
336
+ name: r.table_name,
337
+ schema: r.table_schema,
338
+ columns: [],
339
+ primaryKey: pkMap.get(key) || [],
340
+ indexes: [],
341
+ comment: tableComments.get(key)
342
+ });
313
343
  }
314
344
  const cols = tablesByKey.get(key)!;
315
345
  const commentKey = `${r.table_schema}.${r.table_name}.${r.column_name}`;
@@ -1,6 +1,6 @@
1
1
  import { SchemaIntrospector, IntrospectOptions } from './types.js';
2
- import { shouldIncludeTable } from './utils.js';
3
- import { DatabaseSchema, DatabaseTable, DatabaseIndex } from '../schema-types.js';
2
+ import { shouldIncludeTable, queryRows } from './utils.js';
3
+ import { DatabaseSchema, DatabaseTable, DatabaseIndex, DatabaseColumn } from '../schema-types.js';
4
4
  import type { IntrospectContext } from './context.js';
5
5
  import { runSelectNode } from './run-select.js';
6
6
  import type { SelectQueryNode, TableNode } from '../../ast/query.js';
@@ -89,6 +89,58 @@ const runPragma = async <T>(
89
89
  return (await runSelectNode<T>(query, ctx)) as T[];
90
90
  };
91
91
 
92
+ const loadSqliteSchemaComments = async (ctx: IntrospectContext) => {
93
+ const tableComments = new Map<string, string>();
94
+ const columnComments = new Map<string, string>();
95
+ const tableExists = await queryRows(
96
+ ctx.executor,
97
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='schema_comments' LIMIT 1`
98
+ );
99
+ if (!tableExists.length) {
100
+ return { tableComments, columnComments };
101
+ }
102
+
103
+ const commentRows = await queryRows(
104
+ ctx.executor,
105
+ `SELECT object_type, schema_name, table_name, column_name, comment FROM schema_comments`
106
+ );
107
+ for (const row of commentRows) {
108
+ const objectType = typeof row.object_type === 'string' ? row.object_type.toLowerCase() : '';
109
+ const tableName = typeof row.table_name === 'string' ? row.table_name : '';
110
+ if (!tableName) continue;
111
+ const columnName = typeof row.column_name === 'string' ? row.column_name : '';
112
+ const schemaName = typeof row.schema_name === 'string' ? row.schema_name : '';
113
+ const rawComment = row.comment;
114
+ if (rawComment == null) continue;
115
+ const commentText = String(rawComment).trim();
116
+ if (!commentText) continue;
117
+
118
+ const addTableComment = () => {
119
+ tableComments.set(tableName, commentText);
120
+ if (schemaName) {
121
+ tableComments.set(`${schemaName}.${tableName}`, commentText);
122
+ }
123
+ };
124
+ const addColumnComment = () => {
125
+ columnComments.set(`${tableName}.${columnName}`, commentText);
126
+ if (schemaName) {
127
+ columnComments.set(`${schemaName}.${tableName}.${columnName}`, commentText);
128
+ }
129
+ };
130
+
131
+ if (objectType === 'table') {
132
+ addTableComment();
133
+ } else if (objectType === 'column' && columnName) {
134
+ addColumnComment();
135
+ }
136
+ }
137
+
138
+ return {
139
+ tableComments,
140
+ columnComments
141
+ };
142
+ };
143
+
92
144
  export const sqliteIntrospector: SchemaIntrospector = {
93
145
  async introspect(ctx: IntrospectContext, options: IntrospectOptions): Promise<DatabaseSchema> {
94
146
  const alias = 'sqlite_master';
@@ -103,6 +155,7 @@ export const sqliteIntrospector: SchemaIntrospector = {
103
155
  )
104
156
  };
105
157
 
158
+ const { tableComments, columnComments } = await loadSqliteSchemaComments(ctx);
106
159
  const tableRows = (await runSelectNode<SqliteTableRow>(tablesQuery, ctx)) as SqliteTableRow[];
107
160
  const tables: DatabaseTable[] = [];
108
161
 
@@ -134,16 +187,27 @@ export const sqliteIntrospector: SchemaIntrospector = {
134
187
  ctx
135
188
  );
136
189
 
137
- const tableEntry: DatabaseTable = { name: tableName, columns: [], primaryKey: [], indexes: [] };
190
+ const tableEntry: DatabaseTable = {
191
+ name: tableName,
192
+ columns: [],
193
+ primaryKey: [],
194
+ indexes: [],
195
+ comment: tableComments.get(tableName)
196
+ };
138
197
 
139
198
  tableInfo.forEach(info => {
140
- tableEntry.columns.push({
199
+ const column: DatabaseColumn = {
141
200
  name: info.name,
142
201
  type: info.type,
143
202
  notNull: info.notnull === 1,
144
203
  default: info.dflt_value ?? undefined,
145
204
  autoIncrement: false
146
- });
205
+ };
206
+ const columnComment = columnComments.get(`${tableName}.${info.name}`);
207
+ if (columnComment) {
208
+ column.comment = columnComment;
209
+ }
210
+ tableEntry.columns.push(column);
147
211
  if (info.pk && info.pk > 0) {
148
212
  tableEntry.primaryKey = tableEntry.primaryKey || [];
149
213
  tableEntry.primaryKey.push(info.name);
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>;