metal-orm 1.0.12 → 1.0.13

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.
@@ -1,20 +1,21 @@
1
1
  import type { Dialect } from '../core/dialect/abstract.js';
2
2
  import type { RelationDef } from '../schema/relation.js';
3
3
  import type { TableDef } from '../schema/table.js';
4
- import type { DbExecutor, QueryResult } from './db-executor.js';
5
- import { DomainEventBus, DomainEventHandler as DomainEventHandlerFn, addDomainEvent } from './domain-event-bus.js';
6
- import { IdentityMap } from './identity-map.js';
7
- import { RelationChangeProcessor } from './relation-change-processor.js';
8
- import { runInTransaction } from './transaction-runner.js';
9
- import { UnitOfWork } from './unit-of-work.js';
10
- import {
11
- EntityStatus,
12
- HasDomainEvents,
13
- RelationChange,
14
- RelationChangeEntry,
15
- RelationKey,
16
- TrackedEntity
17
- } from './runtime-types.js';
4
+ import type { DbExecutor, QueryResult } from './db-executor.js';
5
+ import { DomainEventBus, DomainEventHandler as DomainEventHandlerFn, addDomainEvent } from './domain-event-bus.js';
6
+ import { IdentityMap } from './identity-map.js';
7
+ import { RelationChangeProcessor } from './relation-change-processor.js';
8
+ import { runInTransaction } from './transaction-runner.js';
9
+ import { UnitOfWork } from './unit-of-work.js';
10
+ import {
11
+ EntityStatus,
12
+ HasDomainEvents,
13
+ RelationChange,
14
+ RelationChangeEntry,
15
+ RelationKey,
16
+ TrackedEntity
17
+ } from './runtime-types.js';
18
+ import { createQueryLoggingExecutor, QueryLogger } from './query-logger.js';
18
19
 
19
20
  export interface OrmInterceptor {
20
21
  beforeFlush?(ctx: OrmContext): Promise<void> | void;
@@ -23,43 +24,46 @@ export interface OrmInterceptor {
23
24
 
24
25
  export type DomainEventHandler = DomainEventHandlerFn<OrmContext>;
25
26
 
26
- export interface OrmContextOptions {
27
- dialect: Dialect;
28
- executor: DbExecutor;
29
- interceptors?: OrmInterceptor[];
30
- domainEventHandlers?: Record<string, DomainEventHandler[]>;
31
- }
32
-
33
- export class OrmContext {
34
- private readonly identityMap = new IdentityMap();
35
- private readonly unitOfWork: UnitOfWork;
36
- private readonly relationChanges: RelationChangeProcessor;
37
- private readonly interceptors: OrmInterceptor[];
38
- private readonly domainEvents: DomainEventBus<OrmContext>;
39
-
40
- constructor(private readonly options: OrmContextOptions) {
41
- this.interceptors = [...(options.interceptors ?? [])];
42
- this.unitOfWork = new UnitOfWork(
43
- options.dialect,
44
- options.executor,
45
- this.identityMap,
46
- () => this
47
- );
48
- this.relationChanges = new RelationChangeProcessor(
49
- this.unitOfWork,
50
- options.dialect,
51
- options.executor
52
- );
53
- this.domainEvents = new DomainEventBus<OrmContext>(options.domainEventHandlers);
54
- }
27
+ export interface OrmContextOptions {
28
+ dialect: Dialect;
29
+ executor: DbExecutor;
30
+ interceptors?: OrmInterceptor[];
31
+ domainEventHandlers?: Record<string, DomainEventHandler[]>;
32
+ queryLogger?: QueryLogger;
33
+ }
34
+
35
+ export class OrmContext {
36
+ private readonly identityMap = new IdentityMap();
37
+ private readonly executorWithLogging: DbExecutor;
38
+ private readonly unitOfWork: UnitOfWork;
39
+ private readonly relationChanges: RelationChangeProcessor;
40
+ private readonly interceptors: OrmInterceptor[];
41
+ private readonly domainEvents: DomainEventBus<OrmContext>;
42
+
43
+ constructor(private readonly options: OrmContextOptions) {
44
+ this.interceptors = [...(options.interceptors ?? [])];
45
+ this.executorWithLogging = createQueryLoggingExecutor(options.executor, options.queryLogger);
46
+ this.unitOfWork = new UnitOfWork(
47
+ options.dialect,
48
+ this.executorWithLogging,
49
+ this.identityMap,
50
+ () => this
51
+ );
52
+ this.relationChanges = new RelationChangeProcessor(
53
+ this.unitOfWork,
54
+ options.dialect,
55
+ this.executorWithLogging
56
+ );
57
+ this.domainEvents = new DomainEventBus<OrmContext>(options.domainEventHandlers);
58
+ }
55
59
 
56
60
  get dialect(): Dialect {
57
61
  return this.options.dialect;
58
62
  }
59
63
 
60
- get executor(): DbExecutor {
61
- return this.options.executor;
62
- }
64
+ get executor(): DbExecutor {
65
+ return this.executorWithLogging;
66
+ }
63
67
 
64
68
  get identityBuckets(): Map<string, Map<string, TrackedEntity>> {
65
69
  return this.unitOfWork.identityBuckets;
@@ -143,12 +147,13 @@ export class OrmContext {
143
147
  }
144
148
  }
145
149
 
146
- export { addDomainEvent };
147
- export { EntityStatus };
148
- export type {
149
- QueryResult,
150
- DbExecutor,
151
- RelationKey,
152
- RelationChange,
153
- HasDomainEvents
154
- };
150
+ export { addDomainEvent };
151
+ export { EntityStatus };
152
+ export type {
153
+ QueryResult,
154
+ DbExecutor,
155
+ RelationKey,
156
+ RelationChange,
157
+ HasDomainEvents
158
+ };
159
+ export type { QueryLogEntry, QueryLogger } from './query-logger.js';
@@ -0,0 +1,38 @@
1
+ import type { DbExecutor } from './db-executor.js';
2
+
3
+ export interface QueryLogEntry {
4
+ sql: string;
5
+ params?: unknown[];
6
+ }
7
+
8
+ export type QueryLogger = (entry: QueryLogEntry) => void;
9
+
10
+ export const createQueryLoggingExecutor = (
11
+ executor: DbExecutor,
12
+ logger?: QueryLogger
13
+ ): DbExecutor => {
14
+ if (!logger) {
15
+ return executor;
16
+ }
17
+
18
+ const wrapped: DbExecutor = {
19
+ async executeSql(sql, params) {
20
+ logger({ sql, params });
21
+ return executor.executeSql(sql, params);
22
+ }
23
+ };
24
+
25
+ if (executor.beginTransaction) {
26
+ wrapped.beginTransaction = executor.beginTransaction.bind(executor);
27
+ }
28
+
29
+ if (executor.commitTransaction) {
30
+ wrapped.commitTransaction = executor.commitTransaction.bind(executor);
31
+ }
32
+
33
+ if (executor.rollbackTransaction) {
34
+ wrapped.rollbackTransaction = executor.rollbackTransaction.bind(executor);
35
+ }
36
+
37
+ return wrapped;
38
+ };
@@ -4,15 +4,26 @@ import { BelongsToRelation } from '../../schema/relation.js';
4
4
  import { TableDef } from '../../schema/table.js';
5
5
  import { EntityMeta, getHydrationRecord, hasEntityMeta } from '../entity-meta.js';
6
6
 
7
- type Rows = Record<string, any>;
8
-
9
- const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
10
-
11
- export class DefaultBelongsToReference<TParent> implements BelongsToReference<TParent> {
12
- private loaded = false;
13
- private current: TParent | null = null;
14
-
15
- constructor(
7
+ type Rows = Record<string, any>;
8
+
9
+ const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
10
+
11
+ const hideInternal = (obj: any, keys: string[]): void => {
12
+ for (const key of keys) {
13
+ Object.defineProperty(obj, key, {
14
+ value: obj[key],
15
+ writable: false,
16
+ configurable: false,
17
+ enumerable: false
18
+ });
19
+ }
20
+ };
21
+
22
+ export class DefaultBelongsToReference<TParent> implements BelongsToReference<TParent> {
23
+ private loaded = false;
24
+ private current: TParent | null = null;
25
+
26
+ constructor(
16
27
  private readonly ctx: OrmContext,
17
28
  private readonly meta: EntityMeta<any>,
18
29
  private readonly root: any,
@@ -20,14 +31,15 @@ export class DefaultBelongsToReference<TParent> implements BelongsToReference<TP
20
31
  private readonly relation: BelongsToRelation,
21
32
  private readonly rootTable: TableDef,
22
33
  private readonly loader: () => Promise<Map<string, Rows>>,
23
- private readonly createEntity: (row: Record<string, any>) => TParent,
24
- private readonly targetKey: string
25
- ) {
26
- this.populateFromHydrationCache();
27
- }
28
-
29
- async load(): Promise<TParent | null> {
30
- if (this.loaded) return this.current;
34
+ private readonly createEntity: (row: Record<string, any>) => TParent,
35
+ private readonly targetKey: string
36
+ ) {
37
+ hideInternal(this, ['ctx', 'meta', 'root', 'relationName', 'relation', 'rootTable', 'loader', 'createEntity', 'targetKey']);
38
+ this.populateFromHydrationCache();
39
+ }
40
+
41
+ async load(): Promise<TParent | null> {
42
+ if (this.loaded) return this.current;
31
43
  const map = await this.loader();
32
44
  const fkValue = this.root[this.relation.foreignKey];
33
45
  if (fkValue === null || fkValue === undefined) {
@@ -81,12 +93,16 @@ export class DefaultBelongsToReference<TParent> implements BelongsToReference<TP
81
93
  return `${this.rootTable.name}.${this.relationName}`;
82
94
  }
83
95
 
84
- private populateFromHydrationCache(): void {
85
- const fkValue = this.root[this.relation.foreignKey];
86
- if (fkValue === undefined || fkValue === null) return;
87
- const row = getHydrationRecord(this.meta, this.relationName, fkValue);
88
- if (!row) return;
89
- this.current = this.createEntity(row);
90
- this.loaded = true;
91
- }
92
- }
96
+ private populateFromHydrationCache(): void {
97
+ const fkValue = this.root[this.relation.foreignKey];
98
+ if (fkValue === undefined || fkValue === null) return;
99
+ const row = getHydrationRecord(this.meta, this.relationName, fkValue);
100
+ if (!row) return;
101
+ this.current = this.createEntity(row);
102
+ this.loaded = true;
103
+ }
104
+
105
+ toJSON(): TParent | null {
106
+ return this.current;
107
+ }
108
+ }
@@ -4,15 +4,26 @@ import { HasManyRelation } from '../../schema/relation.js';
4
4
  import { TableDef } from '../../schema/table.js';
5
5
  import { EntityMeta, getHydrationRows } from '../entity-meta.js';
6
6
 
7
- type Rows = Record<string, any>[];
8
-
9
- const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
10
-
11
- export class DefaultHasManyCollection<TChild> implements HasManyCollection<TChild> {
12
- private loaded = false;
13
- private items: TChild[] = [];
14
- private readonly added = new Set<TChild>();
15
- private readonly removed = new Set<TChild>();
7
+ type Rows = Record<string, any>[];
8
+
9
+ const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
10
+
11
+ const hideInternal = (obj: any, keys: string[]): void => {
12
+ for (const key of keys) {
13
+ Object.defineProperty(obj, key, {
14
+ value: obj[key],
15
+ writable: false,
16
+ configurable: false,
17
+ enumerable: false
18
+ });
19
+ }
20
+ };
21
+
22
+ export class DefaultHasManyCollection<TChild> implements HasManyCollection<TChild> {
23
+ private loaded = false;
24
+ private items: TChild[] = [];
25
+ private readonly added = new Set<TChild>();
26
+ private readonly removed = new Set<TChild>();
16
27
 
17
28
  constructor(
18
29
  private readonly ctx: OrmContext,
@@ -23,13 +34,14 @@ export class DefaultHasManyCollection<TChild> implements HasManyCollection<TChil
23
34
  private readonly rootTable: TableDef,
24
35
  private readonly loader: () => Promise<Map<string, Rows>>,
25
36
  private readonly createEntity: (row: Record<string, any>) => TChild,
26
- private readonly localKey: string
27
- ) {
28
- this.hydrateFromCache();
29
- }
30
-
31
- async load(): Promise<TChild[]> {
32
- if (this.loaded) return this.items;
37
+ private readonly localKey: string
38
+ ) {
39
+ hideInternal(this, ['ctx', 'meta', 'root', 'relationName', 'relation', 'rootTable', 'loader', 'createEntity', 'localKey']);
40
+ this.hydrateFromCache();
41
+ }
42
+
43
+ async load(): Promise<TChild[]> {
44
+ if (this.loaded) return this.items;
33
45
  const map = await this.loader();
34
46
  const key = toKey(this.root[this.localKey]);
35
47
  const rows = map.get(key) ?? [];
@@ -100,12 +112,16 @@ export class DefaultHasManyCollection<TChild> implements HasManyCollection<TChil
100
112
  return `${this.rootTable.name}.${this.relationName}`;
101
113
  }
102
114
 
103
- private hydrateFromCache(): void {
104
- const keyValue = this.root[this.localKey];
105
- if (keyValue === undefined || keyValue === null) return;
106
- const rows = getHydrationRows(this.meta, this.relationName, keyValue);
107
- if (!rows?.length) return;
108
- this.items = rows.map(row => this.createEntity(row));
109
- this.loaded = true;
110
- }
111
- }
115
+ private hydrateFromCache(): void {
116
+ const keyValue = this.root[this.localKey];
117
+ if (keyValue === undefined || keyValue === null) return;
118
+ const rows = getHydrationRows(this.meta, this.relationName, keyValue);
119
+ if (!rows?.length) return;
120
+ this.items = rows.map(row => this.createEntity(row));
121
+ this.loaded = true;
122
+ }
123
+
124
+ toJSON(): TChild[] {
125
+ return this.items;
126
+ }
127
+ }
@@ -5,15 +5,26 @@ import { TableDef } from '../../schema/table.js';
5
5
  import { findPrimaryKey } from '../../query-builder/hydration-planner.js';
6
6
  import { EntityMeta, getHydrationRows } from '../entity-meta.js';
7
7
 
8
- type Rows = Record<string, any>[];
9
-
10
- const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
11
-
12
- export class DefaultManyToManyCollection<TTarget> implements ManyToManyCollection<TTarget> {
13
- private loaded = false;
14
- private items: TTarget[] = [];
15
-
16
- constructor(
8
+ type Rows = Record<string, any>[];
9
+
10
+ const toKey = (value: unknown): string => (value === null || value === undefined ? '' : String(value));
11
+
12
+ const hideInternal = (obj: any, keys: string[]): void => {
13
+ for (const key of keys) {
14
+ Object.defineProperty(obj, key, {
15
+ value: obj[key],
16
+ writable: false,
17
+ configurable: false,
18
+ enumerable: false
19
+ });
20
+ }
21
+ };
22
+
23
+ export class DefaultManyToManyCollection<TTarget> implements ManyToManyCollection<TTarget> {
24
+ private loaded = false;
25
+ private items: TTarget[] = [];
26
+
27
+ constructor(
17
28
  private readonly ctx: OrmContext,
18
29
  private readonly meta: EntityMeta<any>,
19
30
  private readonly root: any,
@@ -21,14 +32,15 @@ export class DefaultManyToManyCollection<TTarget> implements ManyToManyCollectio
21
32
  private readonly relation: BelongsToManyRelation,
22
33
  private readonly rootTable: TableDef,
23
34
  private readonly loader: () => Promise<Map<string, Rows>>,
24
- private readonly createEntity: (row: Record<string, any>) => TTarget,
25
- private readonly localKey: string
26
- ) {
27
- this.hydrateFromCache();
28
- }
29
-
30
- async load(): Promise<TTarget[]> {
31
- if (this.loaded) return this.items;
35
+ private readonly createEntity: (row: Record<string, any>) => TTarget,
36
+ private readonly localKey: string
37
+ ) {
38
+ hideInternal(this, ['ctx', 'meta', 'root', 'relationName', 'relation', 'rootTable', 'loader', 'createEntity', 'localKey']);
39
+ this.hydrateFromCache();
40
+ }
41
+
42
+ async load(): Promise<TTarget[]> {
43
+ if (this.loaded) return this.items;
32
44
  const map = await this.loader();
33
45
  const key = toKey(this.root[this.localKey]);
34
46
  const rows = map.get(key) ?? [];
@@ -132,18 +144,22 @@ export class DefaultManyToManyCollection<TTarget> implements ManyToManyCollectio
132
144
  return this.relation.targetKey || findPrimaryKey(this.relation.target);
133
145
  }
134
146
 
135
- private hydrateFromCache(): void {
136
- const keyValue = this.root[this.localKey];
137
- if (keyValue === undefined || keyValue === null) return;
138
- const rows = getHydrationRows(this.meta, this.relationName, keyValue);
139
- if (!rows?.length) return;
147
+ private hydrateFromCache(): void {
148
+ const keyValue = this.root[this.localKey];
149
+ if (keyValue === undefined || keyValue === null) return;
150
+ const rows = getHydrationRows(this.meta, this.relationName, keyValue);
151
+ if (!rows?.length) return;
140
152
  this.items = rows.map(row => {
141
153
  const entity = this.createEntity(row);
142
154
  if ((row as any)._pivot) {
143
155
  (entity as any)._pivot = (row as any)._pivot;
144
156
  }
145
- return entity;
146
- });
147
- this.loaded = true;
148
- }
149
- }
157
+ return entity;
158
+ });
159
+ this.loaded = true;
160
+ }
161
+
162
+ toJSON(): TTarget[] {
163
+ return this.items;
164
+ }
165
+ }
@@ -1,11 +1,11 @@
1
- import { eq } from '../core/ast/expression.js';
2
- import type { Dialect, CompiledQuery } from '../core/dialect/abstract.js';
1
+ import { ColumnNode, eq } from '../core/ast/expression.js';
2
+ import type { Dialect, CompiledQuery } from '../core/dialect/abstract.js';
3
3
  import { InsertQueryBuilder } from '../query-builder/insert.js';
4
4
  import { UpdateQueryBuilder } from '../query-builder/update.js';
5
5
  import { DeleteQueryBuilder } from '../query-builder/delete.js';
6
6
  import { findPrimaryKey } from '../query-builder/hydration-planner.js';
7
7
  import type { TableDef, TableHooks } from '../schema/table.js';
8
- import type { DbExecutor } from './db-executor.js';
8
+ import type { DbExecutor, QueryResult } from './db-executor.js';
9
9
  import { IdentityMap } from './identity-map.js';
10
10
  import { EntityStatus } from './runtime-types.js';
11
11
  import type { TrackedEntity } from './runtime-types.js';
@@ -120,10 +120,14 @@ export class UnitOfWork {
120
120
  private async flushInsert(tracked: TrackedEntity): Promise<void> {
121
121
  await this.runHook(tracked.table.hooks?.beforeInsert, tracked);
122
122
 
123
- const payload = this.extractColumns(tracked.table, tracked.entity);
124
- const builder = new InsertQueryBuilder(tracked.table).values(payload);
125
- const compiled = builder.compile(this.dialect);
126
- await this.executeCompiled(compiled);
123
+ const payload = this.extractColumns(tracked.table, tracked.entity);
124
+ let builder = new InsertQueryBuilder(tracked.table).values(payload);
125
+ if (this.dialect.supportsReturning()) {
126
+ builder = builder.returning(...this.getReturningColumns(tracked.table));
127
+ }
128
+ const compiled = builder.compile(this.dialect);
129
+ const results = await this.executeCompiled(compiled);
130
+ this.applyReturningResults(tracked, results);
127
131
 
128
132
  tracked.status = EntityStatus.Managed;
129
133
  tracked.original = this.createSnapshot(tracked.table, tracked.entity);
@@ -146,12 +150,17 @@ export class UnitOfWork {
146
150
  const pkColumn = tracked.table.columns[findPrimaryKey(tracked.table)];
147
151
  if (!pkColumn) return;
148
152
 
149
- const builder = new UpdateQueryBuilder(tracked.table)
150
- .set(changes)
151
- .where(eq(pkColumn, tracked.pk));
152
-
153
- const compiled = builder.compile(this.dialect);
154
- await this.executeCompiled(compiled);
153
+ let builder = new UpdateQueryBuilder(tracked.table)
154
+ .set(changes)
155
+ .where(eq(pkColumn, tracked.pk));
156
+
157
+ if (this.dialect.supportsReturning()) {
158
+ builder = builder.returning(...this.getReturningColumns(tracked.table));
159
+ }
160
+
161
+ const compiled = builder.compile(this.dialect);
162
+ const results = await this.executeCompiled(compiled);
163
+ this.applyReturningResults(tracked, results);
155
164
 
156
165
  tracked.status = EntityStatus.Managed;
157
166
  tracked.original = this.createSnapshot(tracked.table, tracked.entity);
@@ -206,16 +215,44 @@ export class UnitOfWork {
206
215
  return payload;
207
216
  }
208
217
 
209
- private async executeCompiled(compiled: CompiledQuery): Promise<void> {
210
- await this.executor.executeSql(compiled.sql, compiled.params);
211
- }
212
-
213
- private registerIdentity(tracked: TrackedEntity): void {
214
- if (tracked.pk == null) return;
215
- this.identityMap.register(tracked);
216
- }
217
-
218
- private createSnapshot(table: TableDef, entity: any): Record<string, any> {
218
+ private async executeCompiled(compiled: CompiledQuery): Promise<QueryResult[]> {
219
+ return this.executor.executeSql(compiled.sql, compiled.params);
220
+ }
221
+
222
+ private getReturningColumns(table: TableDef): ColumnNode[] {
223
+ return Object.values(table.columns).map(column => ({
224
+ type: 'Column',
225
+ table: table.name,
226
+ name: column.name,
227
+ alias: column.name
228
+ }));
229
+ }
230
+
231
+ private applyReturningResults(tracked: TrackedEntity, results: QueryResult[]): void {
232
+ if (!this.dialect.supportsReturning()) return;
233
+ const first = results[0];
234
+ if (!first || first.values.length === 0) return;
235
+
236
+ const row = first.values[0];
237
+ for (let i = 0; i < first.columns.length; i++) {
238
+ const columnName = this.normalizeColumnName(first.columns[i]);
239
+ if (!(columnName in tracked.table.columns)) continue;
240
+ tracked.entity[columnName] = row[i];
241
+ }
242
+ }
243
+
244
+ private normalizeColumnName(column: string): string {
245
+ const parts = column.split('.');
246
+ const candidate = parts[parts.length - 1];
247
+ return candidate.replace(/^["`[\]]+|["`[\]]+$/g, '');
248
+ }
249
+
250
+ private registerIdentity(tracked: TrackedEntity): void {
251
+ if (tracked.pk == null) return;
252
+ this.identityMap.register(tracked);
253
+ }
254
+
255
+ private createSnapshot(table: TableDef, entity: any): Record<string, any> {
219
256
  const snapshot: Record<string, any> = {};
220
257
  for (const column of Object.keys(table.columns)) {
221
258
  snapshot[column] = entity[column];