metal-orm 1.0.5 → 1.0.6

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.
Files changed (36) hide show
  1. package/README.md +299 -113
  2. package/docs/CHANGES.md +104 -0
  3. package/docs/advanced-features.md +92 -1
  4. package/docs/api-reference.md +13 -4
  5. package/docs/dml-operations.md +156 -0
  6. package/docs/getting-started.md +122 -55
  7. package/docs/hydration.md +78 -13
  8. package/docs/index.md +19 -14
  9. package/docs/multi-dialect-support.md +25 -0
  10. package/docs/query-builder.md +60 -0
  11. package/docs/runtime.md +105 -0
  12. package/docs/schema-definition.md +52 -1
  13. package/package.json +1 -1
  14. package/src/ast/expression.ts +38 -18
  15. package/src/builder/hydration-planner.ts +74 -74
  16. package/src/builder/select.ts +427 -395
  17. package/src/constants/sql-operator-config.ts +3 -0
  18. package/src/constants/sql.ts +38 -32
  19. package/src/index.ts +16 -8
  20. package/src/playground/features/playground/data/scenarios/types.ts +18 -15
  21. package/src/playground/features/playground/data/schema.ts +10 -10
  22. package/src/playground/features/playground/services/QueryExecutionService.ts +2 -1
  23. package/src/runtime/entity-meta.ts +52 -0
  24. package/src/runtime/entity.ts +252 -0
  25. package/src/runtime/execute.ts +36 -0
  26. package/src/runtime/hydration.ts +99 -49
  27. package/src/runtime/lazy-batch.ts +205 -0
  28. package/src/runtime/orm-context.ts +539 -0
  29. package/src/runtime/relations/belongs-to.ts +92 -0
  30. package/src/runtime/relations/has-many.ts +111 -0
  31. package/src/runtime/relations/many-to-many.ts +149 -0
  32. package/src/schema/column.ts +15 -1
  33. package/src/schema/relation.ts +82 -58
  34. package/src/schema/table.ts +34 -22
  35. package/src/schema/types.ts +76 -0
  36. package/tests/orm-runtime.test.ts +254 -0
@@ -0,0 +1,105 @@
1
+ # Runtime & Unit of Work
2
+
3
+ This page describes MetalORM's optional entity runtime:
4
+
5
+ - `OrmContext` – the Unit of Work.
6
+ - entities – proxies wrapping hydrated rows.
7
+ - relation wrappers – lazy, batched collections and references.
8
+
9
+ ## OrmContext
10
+
11
+ `OrmContext` owns:
12
+
13
+ - a SQL dialect,
14
+ - a DB executor (`executeSql(sql, params)`),
15
+ - an identity map (`table + primaryKey → entity`),
16
+ - change tracking for entities and relations,
17
+ - hooks and (optionally) domain event dispatch.
18
+
19
+ ```ts
20
+ const ctx = new OrmContext({
21
+ dialect: new MySqlDialect(),
22
+ db: {
23
+ async executeSql(sql, params) {
24
+ // call your DB driver here
25
+ }
26
+ }
27
+ });
28
+ ```
29
+
30
+ ## Entities
31
+
32
+ Entities are created when you call `.execute(ctx)` on a SelectQueryBuilder.
33
+
34
+ They:
35
+
36
+ - expose table columns as properties (user.id, user.name, …)
37
+ - expose relations as wrappers:
38
+ - HasManyCollection<T> (e.g. user.posts)
39
+ - BelongsToReference<T> (e.g. post.author)
40
+ - ManyToManyCollection<T> (e.g. user.roles)
41
+ - track changes to fields and collections for the Unit of Work.
42
+
43
+ ```ts
44
+ const [user] = await new SelectQueryBuilder(users)
45
+ .select({ id: users.columns.id, name: users.columns.name })
46
+ .includeLazy('posts')
47
+ .execute(ctx);
48
+
49
+ user.name = 'Updated Name'; // marks entity as Dirty
50
+ const posts = await user.posts.load(); // lazy-batched load
51
+ ```
52
+
53
+ ## Unit of Work
54
+
55
+ Each entity in an OrmContext has a status:
56
+
57
+ - New – created in memory and not yet persisted.
58
+ - Managed – loaded from the database and unchanged.
59
+ - Dirty – modified scalar properties.
60
+ - Removed – scheduled for deletion.
61
+
62
+ Relations track:
63
+
64
+ - additions (add, attach, syncByIds),
65
+ - removals (remove, detach).
66
+
67
+ `ctx.saveChanges()`:
68
+
69
+ - runs hooks / interceptors,
70
+ - flushes entity changes as INSERT / UPDATE / DELETE,
71
+ - flushes relation changes (FK / pivot),
72
+ - dispatches domain events (optional),
73
+ - resets tracking.
74
+
75
+ ```ts
76
+ user.posts.add({ title: 'From entities' });
77
+ user.posts.remove(posts[0]);
78
+
79
+ await ctx.saveChanges();
80
+ ```
81
+
82
+ ## Hooks & Domain Events
83
+
84
+ Each TableDef can define hooks:
85
+
86
+ ```ts
87
+ const users = defineTable('users', { /* ... */ }, undefined, {
88
+ hooks: {
89
+ beforeInsert(ctx, user) {
90
+ user.createdAt = new Date();
91
+ },
92
+ afterUpdate(ctx, user) {
93
+ // log audit event
94
+ },
95
+ },
96
+ });
97
+ ```
98
+
99
+ Entities may accumulate domain events:
100
+
101
+ ```ts
102
+ addDomainEvent(user, new UserRegisteredEvent(user.id));
103
+ ```
104
+
105
+ After flushing, the context dispatches these events to registered handlers or writes them to an outbox table.
@@ -37,7 +37,9 @@ You can also chain modifiers to define column constraints:
37
37
 
38
38
  ## Relations
39
39
 
40
- You can define relations between tables using `hasMany` and `belongsTo`:
40
+ You can define relations between tables using `hasMany`, `belongsTo`, and `belongsToMany`:
41
+
42
+ ### One-to-Many Relations
41
43
 
42
44
  ```typescript
43
45
  import { defineTable, col, hasMany } from 'metal-orm';
@@ -59,3 +61,52 @@ const users = defineTable(
59
61
  }
60
62
  );
61
63
  ```
64
+
65
+ ### Many-to-One Relations
66
+
67
+ ```typescript
68
+ const posts = defineTable('posts', {
69
+ id: col.int().primaryKey(),
70
+ title: col.varchar(255).notNull(),
71
+ userId: col.int().notNull(),
72
+ }, {
73
+ author: belongsTo(users, 'userId')
74
+ });
75
+ ```
76
+
77
+ ### Many-to-Many Relations
78
+
79
+ ```typescript
80
+ const projects = defineTable('projects', {
81
+ id: col.int().primaryKey(),
82
+ name: col.varchar(255).notNull(),
83
+ });
84
+
85
+ const projectAssignments = defineTable('project_assignments', {
86
+ id: col.int().primaryKey(),
87
+ userId: col.int().notNull(),
88
+ projectId: col.int().notNull(),
89
+ role: col.varchar(50),
90
+ assignedAt: col.timestamp(),
91
+ });
92
+
93
+ const users = defineTable('users', {
94
+ id: col.int().primaryKey(),
95
+ name: col.varchar(255).notNull(),
96
+ }, {
97
+ projects: belongsToMany(
98
+ projects,
99
+ projectAssignments,
100
+ {
101
+ pivotForeignKeyToRoot: 'userId',
102
+ pivotForeignKeyToTarget: 'projectId',
103
+ defaultPivotColumns: ['role', 'assignedAt']
104
+ }
105
+ )
106
+ });
107
+
108
+ > **Note**: When using the runtime, relation definitions (`hasMany`, `belongsTo`, `belongsToMany`) are also used to:
109
+ > - generate hydration plans for eager loading
110
+ > - configure lazy relation loaders
111
+ > - control cascade behavior in `OrmContext.saveChanges()`.
112
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metal-orm",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -270,30 +270,50 @@ const createBinaryExpression = (
270
270
  * @param right - Right operand
271
271
  * @returns Binary expression node with equality operator
272
272
  */
273
- export const eq = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
274
- createBinaryExpression('=', left, right);
275
-
273
+ export const eq = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
274
+ createBinaryExpression('=', left, right);
275
+
276
+ /**
277
+ * Creates a not equal expression (left != right)
278
+ */
279
+ export const neq = (
280
+ left: OperandNode | ColumnDef,
281
+ right: OperandNode | ColumnDef | string | number
282
+ ): BinaryExpressionNode => createBinaryExpression('!=', left, right);
283
+
276
284
  /**
277
285
  * Creates a greater-than expression (left > right)
278
286
  * @param left - Left operand
279
287
  * @param right - Right operand
280
288
  * @returns Binary expression node with greater-than operator
281
289
  */
282
- export const gt = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
283
- createBinaryExpression('>', left, right);
284
-
285
- /**
286
- * Creates a less-than expression (left < right)
287
- * @param left - Left operand
288
- * @param right - Right operand
289
- * @returns Binary expression node with less-than operator
290
- */
291
- export const lt = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
292
- createBinaryExpression('<', left, right);
293
-
294
- /**
295
- * Creates a LIKE pattern matching expression
296
- * @param left - Left operand
290
+ export const gt = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
291
+ createBinaryExpression('>', left, right);
292
+
293
+ /**
294
+ * Creates a greater than or equal expression (left >= right)
295
+ */
296
+ export const gte = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
297
+ createBinaryExpression('>=', left, right);
298
+
299
+ /**
300
+ * Creates a less-than expression (left < right)
301
+ * @param left - Left operand
302
+ * @param right - Right operand
303
+ * @returns Binary expression node with less-than operator
304
+ */
305
+ export const lt = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
306
+ createBinaryExpression('<', left, right);
307
+
308
+ /**
309
+ * Creates a less than or equal expression (left <= right)
310
+ */
311
+ export const lte = (left: OperandNode | ColumnDef, right: OperandNode | ColumnDef | string | number): BinaryExpressionNode =>
312
+ createBinaryExpression('<=', left, right);
313
+
314
+ /**
315
+ * Creates a LIKE pattern matching expression
316
+ * @param left - Left operand
297
317
  * @param pattern - Pattern to match
298
318
  * @param escape - Optional escape character
299
319
  * @returns Binary expression node with LIKE operator
@@ -14,55 +14,55 @@ export const findPrimaryKey = (table: TableDef): string => {
14
14
  const pk = Object.values(table.columns).find(c => c.primary);
15
15
  return pk?.name || 'id';
16
16
  };
17
-
18
- /**
19
- * Manages hydration planning for query results
20
- */
21
- export class HydrationPlanner {
22
- /**
23
- * Creates a new HydrationPlanner instance
24
- * @param table - Table definition
25
- * @param plan - Optional existing hydration plan
26
- */
27
- constructor(private readonly table: TableDef, private readonly plan?: HydrationPlan) { }
28
-
29
- /**
30
- * Captures root table columns for hydration planning
31
- * @param columns - Columns to capture
32
- * @returns Updated HydrationPlanner with captured columns
33
- */
17
+
18
+ /**
19
+ * Manages hydration planning for query results
20
+ */
21
+ export class HydrationPlanner {
22
+ /**
23
+ * Creates a new HydrationPlanner instance
24
+ * @param table - Table definition
25
+ * @param plan - Optional existing hydration plan
26
+ */
27
+ constructor(private readonly table: TableDef, private readonly plan?: HydrationPlan) { }
28
+
29
+ /**
30
+ * Captures root table columns for hydration planning
31
+ * @param columns - Columns to capture
32
+ * @returns Updated HydrationPlanner with captured columns
33
+ */
34
34
  captureRootColumns(columns: ProjectionNode[]): HydrationPlanner {
35
35
  const currentPlan = this.getPlanOrDefault();
36
- const rootCols = new Set(currentPlan.rootColumns);
37
- let changed = false;
38
-
39
- columns.forEach(node => {
40
- if (node.type !== 'Column') return;
41
- if (node.table !== this.table.name) return;
42
-
43
- const alias = node.alias || node.name;
44
- if (isRelationAlias(alias)) return;
45
- if (!rootCols.has(alias)) {
46
- rootCols.add(alias);
47
- changed = true;
48
- }
49
- });
50
-
51
- if (!changed) return this;
52
- return new HydrationPlanner(this.table, {
53
- ...currentPlan,
54
- rootColumns: Array.from(rootCols)
55
- });
56
- }
57
-
58
- /**
59
- * Includes a relation in the hydration plan
60
- * @param rel - Relation definition
61
- * @param relationName - Name of the relation
62
- * @param aliasPrefix - Alias prefix for relation columns
63
- * @param columns - Columns to include from the relation
64
- * @returns Updated HydrationPlanner with included relation
65
- */
36
+ const rootCols = new Set(currentPlan.rootColumns);
37
+ let changed = false;
38
+
39
+ columns.forEach(node => {
40
+ if (node.type !== 'Column') return;
41
+ if (node.table !== this.table.name) return;
42
+
43
+ const alias = node.alias || node.name;
44
+ if (isRelationAlias(alias)) return;
45
+ if (!rootCols.has(alias)) {
46
+ rootCols.add(alias);
47
+ changed = true;
48
+ }
49
+ });
50
+
51
+ if (!changed) return this;
52
+ return new HydrationPlanner(this.table, {
53
+ ...currentPlan,
54
+ rootColumns: Array.from(rootCols)
55
+ });
56
+ }
57
+
58
+ /**
59
+ * Includes a relation in the hydration plan
60
+ * @param rel - Relation definition
61
+ * @param relationName - Name of the relation
62
+ * @param aliasPrefix - Alias prefix for relation columns
63
+ * @param columns - Columns to include from the relation
64
+ * @returns Updated HydrationPlanner with included relation
65
+ */
66
66
  includeRelation(
67
67
  rel: RelationDef,
68
68
  relationName: string,
@@ -78,31 +78,31 @@ export class HydrationPlanner {
78
78
  relations
79
79
  });
80
80
  }
81
-
82
- /**
83
- * Gets the current hydration plan
84
- * @returns Current hydration plan or undefined
85
- */
86
- getPlan(): HydrationPlan | undefined {
87
- return this.plan;
88
- }
89
-
90
- /**
91
- * Gets the current hydration plan or creates a default one
92
- * @returns Current hydration plan or default plan
93
- */
94
- private getPlanOrDefault(): HydrationPlan {
95
- return this.plan ?? buildDefaultHydrationPlan(this.table);
96
- }
97
-
98
- /**
99
- * Builds a relation plan for hydration
100
- * @param rel - Relation definition
101
- * @param relationName - Name of the relation
102
- * @param aliasPrefix - Alias prefix for relation columns
103
- * @param columns - Columns to include from the relation
104
- * @returns Hydration relation plan
105
- */
81
+
82
+ /**
83
+ * Gets the current hydration plan
84
+ * @returns Current hydration plan or undefined
85
+ */
86
+ getPlan(): HydrationPlan | undefined {
87
+ return this.plan;
88
+ }
89
+
90
+ /**
91
+ * Gets the current hydration plan or creates a default one
92
+ * @returns Current hydration plan or default plan
93
+ */
94
+ private getPlanOrDefault(): HydrationPlan {
95
+ return this.plan ?? buildDefaultHydrationPlan(this.table);
96
+ }
97
+
98
+ /**
99
+ * Builds a relation plan for hydration
100
+ * @param rel - Relation definition
101
+ * @param relationName - Name of the relation
102
+ * @param aliasPrefix - Alias prefix for relation columns
103
+ * @param columns - Columns to include from the relation
104
+ * @returns Hydration relation plan
105
+ */
106
106
  private buildRelationPlan(
107
107
  rel: RelationDef,
108
108
  relationName: string,
@@ -167,8 +167,8 @@ export class HydrationPlanner {
167
167
  }
168
168
  }
169
169
  }
170
- }
171
-
170
+ }
171
+
172
172
  /**
173
173
  * Builds a default hydration plan for a table
174
174
  * @param table - Table definition