metal-orm 1.0.4 → 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 (57) 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 +77 -3
  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 +630 -592
  15. package/src/ast/query.ts +110 -49
  16. package/src/builder/delete-query-state.ts +42 -0
  17. package/src/builder/delete.ts +57 -0
  18. package/src/builder/hydration-manager.ts +3 -2
  19. package/src/builder/hydration-planner.ts +163 -107
  20. package/src/builder/insert-query-state.ts +62 -0
  21. package/src/builder/insert.ts +59 -0
  22. package/src/builder/operations/relation-manager.ts +1 -23
  23. package/src/builder/relation-conditions.ts +45 -1
  24. package/src/builder/relation-service.ts +81 -18
  25. package/src/builder/relation-types.ts +15 -0
  26. package/src/builder/relation-utils.ts +12 -0
  27. package/src/builder/select.ts +427 -394
  28. package/src/builder/update-query-state.ts +59 -0
  29. package/src/builder/update.ts +61 -0
  30. package/src/constants/sql-operator-config.ts +3 -0
  31. package/src/constants/sql.ts +38 -32
  32. package/src/dialect/abstract.ts +107 -47
  33. package/src/dialect/mssql/index.ts +31 -6
  34. package/src/dialect/mysql/index.ts +31 -6
  35. package/src/dialect/postgres/index.ts +45 -6
  36. package/src/dialect/sqlite/index.ts +45 -6
  37. package/src/index.ts +22 -11
  38. package/src/playground/features/playground/data/scenarios/hydration.ts +23 -11
  39. package/src/playground/features/playground/data/scenarios/types.ts +18 -15
  40. package/src/playground/features/playground/data/schema.ts +6 -2
  41. package/src/playground/features/playground/services/QueryExecutionService.ts +2 -1
  42. package/src/runtime/entity-meta.ts +52 -0
  43. package/src/runtime/entity.ts +252 -0
  44. package/src/runtime/execute.ts +36 -0
  45. package/src/runtime/hydration.ts +100 -38
  46. package/src/runtime/lazy-batch.ts +205 -0
  47. package/src/runtime/orm-context.ts +539 -0
  48. package/src/runtime/relations/belongs-to.ts +92 -0
  49. package/src/runtime/relations/has-many.ts +111 -0
  50. package/src/runtime/relations/many-to-many.ts +149 -0
  51. package/src/schema/column.ts +15 -1
  52. package/src/schema/relation.ts +105 -40
  53. package/src/schema/table.ts +34 -22
  54. package/src/schema/types.ts +76 -0
  55. package/tests/belongs-to-many.test.ts +57 -0
  56. package/tests/dml.test.ts +206 -0
  57. package/tests/orm-runtime.test.ts +254 -0
@@ -0,0 +1,206 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { InsertQueryBuilder, UpdateQueryBuilder, DeleteQueryBuilder } from '../src';
3
+ import { Dialect } from '../src/dialect/abstract';
4
+ import { MySqlDialect } from '../src/dialect/mysql';
5
+ import { PostgresDialect } from '../src/dialect/postgres';
6
+ import { SqliteDialect } from '../src/dialect/sqlite';
7
+ import { SqlServerDialect } from '../src/dialect/mssql';
8
+ import { Users } from '../src/playground/features/playground/data/schema';
9
+ import { eq } from '../src/ast/expression';
10
+ import type { ColumnDef } from '../src/schema/column';
11
+
12
+ type Row = {
13
+ name: string;
14
+ role: string;
15
+ };
16
+
17
+ interface DialectCase {
18
+ name: string;
19
+ dialect: Dialect;
20
+ placeholder: (index: number) => string;
21
+ supportsReturning: boolean;
22
+ }
23
+
24
+ const rowOrder: (keyof Row)[] = ['name', 'role'];
25
+ const insertRows: Row[] = [
26
+ { name: 'alice', role: 'admin' },
27
+ { name: 'bob', role: 'user' }
28
+ ];
29
+
30
+ const returningColumns: ColumnDef[] = [Users.columns.id, Users.columns.name];
31
+ const columnColumns: ColumnDef[] = [Users.columns.name, Users.columns.role];
32
+
33
+ const dialectCases: DialectCase[] = [
34
+ {
35
+ name: 'MySQL',
36
+ dialect: new MySqlDialect(),
37
+ placeholder: () => '?',
38
+ supportsReturning: false
39
+ },
40
+ {
41
+ name: 'Postgres',
42
+ dialect: new PostgresDialect(),
43
+ placeholder: () => '?',
44
+ supportsReturning: true
45
+ },
46
+ {
47
+ name: 'SQLite',
48
+ dialect: new SqliteDialect(),
49
+ placeholder: () => '?',
50
+ supportsReturning: true
51
+ },
52
+ {
53
+ name: 'SQL Server',
54
+ dialect: new SqlServerDialect(),
55
+ placeholder: index => `@p${index}`,
56
+ supportsReturning: false
57
+ }
58
+ ];
59
+
60
+ const qualifyColumn = (dialect: Dialect, column: ColumnDef): string =>
61
+ `${dialect.quoteIdentifier(column.table || Users.name)}.${dialect.quoteIdentifier(column.name)}`;
62
+
63
+ const buildColumnList = (dialect: Dialect, columns: ColumnDef[]): string =>
64
+ columns.map(column => qualifyColumn(dialect, column)).join(', ');
65
+
66
+ const buildReturningClause = (dialect: Dialect, columns: ColumnDef[]): string =>
67
+ columns.length === 0 ? '' : ` RETURNING ${buildColumnList(dialect, columns)}`;
68
+
69
+ const buildValuesClause = (dialectCase: DialectCase, columnCount: number, rowCount: number): string => {
70
+ let index = 1;
71
+ const segments: string[] = [];
72
+ for (let row = 0; row < rowCount; row += 1) {
73
+ const placeholders: string[] = [];
74
+ for (let col = 0; col < columnCount; col += 1) {
75
+ placeholders.push(dialectCase.placeholder(index));
76
+ index += 1;
77
+ }
78
+ segments.push(`(${placeholders.join(', ')})`);
79
+ }
80
+ return segments.join(', ');
81
+ };
82
+
83
+ const buildPlaceholderSequence = (dialectCase: DialectCase, count: number, startIndex = 1): string[] =>
84
+ Array.from({ length: count }, (_, idx) => dialectCase.placeholder(startIndex + idx));
85
+
86
+ const flattenRowValues = (rows: Row[], order: (keyof Row)[]): unknown[] =>
87
+ rows.flatMap(row => order.map(key => row[key]));
88
+
89
+ describe('DML builders', () => {
90
+ dialectCases.forEach(dialectCase => {
91
+ describe(dialectCase.name, () => {
92
+ const dialect = dialectCase.dialect;
93
+ const tableName = Users.name;
94
+ const qualifiedColumns = buildColumnList(dialect, columnColumns);
95
+ const returningSql = buildReturningClause(dialect, returningColumns);
96
+
97
+ it('compiles single-row insert', () => {
98
+ const query = new InsertQueryBuilder(Users).values(insertRows[0]);
99
+ const compiled = query.compile(dialect);
100
+ const valueClause = `(${dialectCase.placeholder(1)}, ${dialectCase.placeholder(2)})`;
101
+ const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valueClause};`;
102
+ expect(compiled.sql).toBe(expectedSql);
103
+ expect(compiled.params).toEqual([insertRows[0].name, insertRows[0].role]);
104
+ });
105
+
106
+ it('compiles multi-row insert with consistent parameter order', () => {
107
+ const query = new InsertQueryBuilder(Users).values(insertRows);
108
+ const compiled = query.compile(dialect);
109
+ const valuesClause = buildValuesClause(dialectCase, columnColumns.length, insertRows.length);
110
+ const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valuesClause};`;
111
+ expect(compiled.sql).toBe(expectedSql);
112
+ expect(compiled.params).toEqual(flattenRowValues(insertRows, rowOrder));
113
+ });
114
+
115
+ if (dialectCase.supportsReturning) {
116
+ it('appends RETURNING for insert when requested', () => {
117
+ const query = new InsertQueryBuilder(Users)
118
+ .values(insertRows[0])
119
+ .returning(Users.columns.id, Users.columns.name);
120
+ const compiled = query.compile(dialect);
121
+ const valueClause = `(${dialectCase.placeholder(1)}, ${dialectCase.placeholder(2)})`;
122
+ const expectedSql = `INSERT INTO ${dialect.quoteIdentifier(tableName)} (${qualifiedColumns}) VALUES ${valueClause}${returningSql};`;
123
+ expect(compiled.sql).toBe(expectedSql);
124
+ expect(compiled.params).toEqual([insertRows[0].name, insertRows[0].role]);
125
+ });
126
+ }
127
+
128
+ it('compiles update with SET values', () => {
129
+ const updateValues = { name: 'ali', role: 'builder' };
130
+ const query = new UpdateQueryBuilder(Users).set(updateValues);
131
+ const compiled = query.compile(dialect);
132
+ const placeholderSeq = buildPlaceholderSequence(dialectCase, columnColumns.length);
133
+ const assignments = columnColumns
134
+ .map((column, idx) => `${qualifyColumn(dialect, column)} = ${placeholderSeq[idx]}`)
135
+ .join(', ');
136
+ const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignments};`;
137
+ expect(compiled.sql).toBe(expectedSql);
138
+ expect(compiled.params).toEqual([updateValues.name, updateValues.role]);
139
+ });
140
+
141
+ it('compiles update with WHERE clause', () => {
142
+ const updateValues = { name: 'gold', role: 'star' };
143
+ const query = new UpdateQueryBuilder(Users)
144
+ .set(updateValues)
145
+ .where(eq(Users.columns.id, 1));
146
+ const compiled = query.compile(dialect);
147
+ const assignmentPlaceholders = buildPlaceholderSequence(dialectCase, columnColumns.length);
148
+ const assignments = columnColumns
149
+ .map((column, idx) => `${qualifyColumn(dialect, column)} = ${assignmentPlaceholders[idx]}`)
150
+ .join(', ');
151
+ const wherePlaceholder = dialectCase.placeholder(columnColumns.length + 1);
152
+ const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
153
+ const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignments}${whereClause};`;
154
+ expect(compiled.sql).toBe(expectedSql);
155
+ expect(compiled.params).toEqual([updateValues.name, updateValues.role, 1]);
156
+ });
157
+
158
+ if (dialectCase.supportsReturning) {
159
+ it('appends RETURNING for update when requested', () => {
160
+ const query = new UpdateQueryBuilder(Users)
161
+ .set({ name: 'return' })
162
+ .returning(Users.columns.id, Users.columns.name);
163
+ const compiled = query.compile(dialect);
164
+ const placeholder = dialectCase.placeholder(1);
165
+ const assignment = `${qualifyColumn(dialect, Users.columns.name)} = ${placeholder}`;
166
+ const expectedSql = `UPDATE ${dialect.quoteIdentifier(tableName)} SET ${assignment}${returningSql};`;
167
+ expect(compiled.sql).toBe(expectedSql);
168
+ expect(compiled.params).toEqual(['return']);
169
+ });
170
+ }
171
+
172
+ it('compiles DELETE without WHERE', () => {
173
+ const query = new DeleteQueryBuilder(Users);
174
+ const compiled = query.compile(dialect);
175
+ const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)};`;
176
+ expect(compiled.sql).toBe(expectedSql);
177
+ expect(compiled.params).toEqual([]);
178
+ });
179
+
180
+ it('compiles DELETE with WHERE clause', () => {
181
+ const query = new DeleteQueryBuilder(Users).where(eq(Users.columns.id, 7));
182
+ const compiled = query.compile(dialect);
183
+ const wherePlaceholder = dialectCase.placeholder(1);
184
+ const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
185
+ const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)}${whereClause};`;
186
+ expect(compiled.sql).toBe(expectedSql);
187
+ expect(compiled.params).toEqual([7]);
188
+ });
189
+
190
+ if (dialectCase.supportsReturning) {
191
+ it('appends RETURNING for delete when requested', () => {
192
+ const query = new DeleteQueryBuilder(Users)
193
+ .where(eq(Users.columns.id, 11))
194
+ .returning(Users.columns.id, Users.columns.name);
195
+ const compiled = query.compile(dialect);
196
+ const wherePlaceholder = dialectCase.placeholder(1);
197
+ const whereClause = ` WHERE ${qualifyColumn(dialect, Users.columns.id)} = ${wherePlaceholder}`;
198
+ const expectedSql = `DELETE FROM ${dialect.quoteIdentifier(tableName)}${whereClause}${returningSql};`;
199
+ expect(compiled.sql).toBe(expectedSql);
200
+ expect(compiled.params).toEqual([11]);
201
+ });
202
+ }
203
+ });
204
+ });
205
+ });
206
+
@@ -0,0 +1,254 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { SqliteDialect } from '../src/dialect/sqlite';
3
+ import { SelectQueryBuilder } from '../src/builder/select';
4
+ import { OrmContext, DbExecutor, QueryResult } from '../src/runtime/orm-context';
5
+ import { createEntityFromRow } from '../src/runtime/entity';
6
+ import { Users, Orders } from '../src/playground/features/playground/data/schema';
7
+ import { defineTable } from '../src/schema/table';
8
+ import { col } from '../src/schema/column';
9
+ import { hasMany, belongsTo, belongsToMany } from '../src/schema/relation';
10
+
11
+ const createMockExecutor = (responses: QueryResult[][]): {
12
+ executor: DbExecutor;
13
+ executed: Array<{ sql: string; params?: unknown[] }>;
14
+ } => {
15
+ const executed: Array<{ sql: string; params?: unknown[] }> = [];
16
+ let callIndex = 0;
17
+ const executor: DbExecutor = {
18
+ async executeSql(sql, params) {
19
+ executed.push({ sql, params });
20
+ const result = responses[callIndex] ?? [];
21
+ callIndex += 1;
22
+ return result;
23
+ }
24
+ };
25
+
26
+ return { executor, executed };
27
+ };
28
+
29
+ const TestOrders = defineTable('test_orders', {
30
+ id: col.primaryKey(col.int()),
31
+ user_id: col.int(),
32
+ total: col.int(),
33
+ status: col.varchar(50)
34
+ });
35
+
36
+ const TestProjects = defineTable('test_projects', {
37
+ id: col.primaryKey(col.int()),
38
+ name: col.varchar(255),
39
+ client: col.varchar(255)
40
+ });
41
+
42
+ const TestUserProjects = defineTable('test_user_projects', {
43
+ id: col.primaryKey(col.int()),
44
+ user_id: col.int(),
45
+ project_id: col.int()
46
+ });
47
+
48
+ const TestUsers = defineTable('test_users', {
49
+ id: col.primaryKey(col.int()),
50
+ name: col.varchar(255)
51
+ });
52
+
53
+ TestUsers.relations = {
54
+ orders: hasMany(TestOrders, 'user_id', undefined, 'remove'),
55
+ projects: belongsToMany(TestProjects, TestUserProjects, {
56
+ pivotForeignKeyToRoot: 'user_id',
57
+ pivotForeignKeyToTarget: 'project_id',
58
+ cascade: 'link'
59
+ })
60
+ };
61
+
62
+ TestOrders.relations = {
63
+ user: belongsTo(TestUsers, 'user_id')
64
+ };
65
+
66
+ TestProjects.relations = {};
67
+ TestUserProjects.relations = {};
68
+
69
+ describe('OrmContext entity graphs', () => {
70
+ it('lazy loads has-many relations in batches', async () => {
71
+ const responses: QueryResult[][] = [
72
+ [
73
+ {
74
+ columns: ['id', 'name'],
75
+ values: [[1, 'Alice']]
76
+ }
77
+ ],
78
+ [
79
+ {
80
+ columns: ['id', 'user_id', 'total', 'status'],
81
+ values: [
82
+ [10, 1, 100, 'open'],
83
+ [11, 1, 200, 'shipped']
84
+ ]
85
+ }
86
+ ]
87
+ ];
88
+ const { executor, executed } = createMockExecutor(responses);
89
+ const ctx = new OrmContext({ dialect: new SqliteDialect(), executor });
90
+
91
+ const builder = new SelectQueryBuilder(Users)
92
+ .select({
93
+ id: Users.columns.id,
94
+ name: Users.columns.name
95
+ })
96
+ .includeLazy('orders');
97
+
98
+ const [user] = await builder.execute(ctx);
99
+ expect(user).toBeDefined();
100
+ expect(executed).toHaveLength(1);
101
+
102
+ const orders = await user.orders.load();
103
+ expect(orders).toHaveLength(2);
104
+ expect(orders.map(order => order.total)).toEqual([100, 200]);
105
+ expect(executed[1].sql).toContain('"orders"');
106
+ });
107
+
108
+ it('preloads eager hydration data for has-many relations without extra queries', async () => {
109
+ const { executor, executed } = createMockExecutor([]);
110
+ const ctx = new OrmContext({ dialect: new SqliteDialect(), executor });
111
+
112
+ const row = {
113
+ id: 1,
114
+ name: 'Alice',
115
+ role: 'admin',
116
+ settings: '{}',
117
+ deleted_at: null,
118
+ orders: [
119
+ { id: 10, user_id: 1, total: 120, status: 'open' },
120
+ { id: 11, user_id: 1, total: 230, status: 'completed' }
121
+ ]
122
+ };
123
+
124
+ const user = createEntityFromRow(ctx, Users, row);
125
+ expect(user.orders.getItems()).toHaveLength(2);
126
+
127
+ const orders = await user.orders.load();
128
+ expect(orders).toHaveLength(2);
129
+ expect(orders.map(order => order.total)).toEqual([120, 230]);
130
+ expect(executed).toHaveLength(0);
131
+ });
132
+
133
+ it('reuses eagerly hydrated belongs-to data and lazily loads belongs-to-many', async () => {
134
+ const rootRow: QueryResult = {
135
+ columns: [
136
+ 'id',
137
+ 'user_id',
138
+ 'total',
139
+ 'status',
140
+ 'user__id',
141
+ 'user__name',
142
+ 'user__role',
143
+ 'user__settings',
144
+ 'user__deleted_at'
145
+ ],
146
+ values: [[42, 1, 15, 'pending', 1, 'Alice', 'admin', '{}', null]]
147
+ };
148
+
149
+ const responses: QueryResult[][] = [
150
+ [rootRow],
151
+ [
152
+ {
153
+ columns: ['id', 'project_id', 'user_id', 'role_id', 'assigned_at'],
154
+ values: [[1, 10, 1, 3, '2025-12-03']]
155
+ }
156
+ ],
157
+ [
158
+ {
159
+ columns: ['id', 'name', 'client'],
160
+ values: [[10, 'Apollo', 'Acme Corp']]
161
+ }
162
+ ]
163
+ ];
164
+
165
+ const { executor, executed } = createMockExecutor(responses);
166
+ const ctx = new OrmContext({ dialect: new SqliteDialect(), executor });
167
+
168
+ const builder = new SelectQueryBuilder(Orders)
169
+ .select({
170
+ id: Orders.columns.id,
171
+ user_id: Orders.columns.user_id,
172
+ total: Orders.columns.total,
173
+ status: Orders.columns.status
174
+ })
175
+ .include('user');
176
+
177
+ const [order] = await builder.execute(ctx);
178
+ expect(order).toBeDefined();
179
+ expect(executed).toHaveLength(1);
180
+
181
+ const user = await order.user.load();
182
+ expect(user).toBeDefined();
183
+ expect(user?.name).toBe('Alice');
184
+ expect(executed).toHaveLength(1);
185
+
186
+ const projects = await user!.projects.load();
187
+ expect(executed).toHaveLength(3);
188
+ expect(projects).toHaveLength(1);
189
+ expect(projects[0].name).toBe('Apollo');
190
+ expect((projects[0] as any)._pivot).toMatchObject({
191
+ project_id: 10,
192
+ user_id: 1,
193
+ id: 1
194
+ });
195
+ expect(executed[1].sql).toContain('"project_assignments"');
196
+ expect(executed[2].sql).toContain('"projects"');
197
+ });
198
+
199
+ it('flushes relation mutations through saveChanges with cascading SQL', async () => {
200
+ const responses: QueryResult[][] = [
201
+ [
202
+ {
203
+ columns: ['id', 'name'],
204
+ values: [[1, 'Manager']]
205
+ }
206
+ ],
207
+ [
208
+ {
209
+ columns: ['id', 'user_id', 'total', 'status'],
210
+ values: [[10, 1, 180, 'open']]
211
+ }
212
+ ],
213
+ [
214
+ {
215
+ columns: ['id', 'project_id', 'user_id'],
216
+ values: [[1, 100, 1]]
217
+ }
218
+ ],
219
+ [
220
+ {
221
+ columns: ['id', 'name', 'client'],
222
+ values: [[100, 'Apollo', 'Acme Corp']]
223
+ }
224
+ ]
225
+ ];
226
+
227
+ const { executor, executed } = createMockExecutor(responses);
228
+ const ctx = new OrmContext({ dialect: new SqliteDialect(), executor });
229
+
230
+ const [user] = await new SelectQueryBuilder(TestUsers)
231
+ .select({
232
+ id: TestUsers.columns.id,
233
+ name: TestUsers.columns.name
234
+ })
235
+ .execute(ctx);
236
+
237
+ const orders = await user.orders.load();
238
+ expect(orders).toHaveLength(1);
239
+
240
+ const removedOrder = orders[0];
241
+ user.orders.remove(removedOrder);
242
+ user.orders.add({ total: 999, status: 'pending' });
243
+
244
+ await user.projects.syncByIds([200]);
245
+
246
+ await ctx.saveChanges();
247
+
248
+ const payload = executed.slice(-4);
249
+ expect(payload[0].sql).toContain('INSERT INTO "test_orders"');
250
+ expect(payload[1].sql).toContain('INSERT INTO "test_user_projects"');
251
+ expect(payload[2].sql).toContain('DELETE FROM "test_user_projects"');
252
+ expect(payload[3].sql).toContain('DELETE FROM "test_orders"');
253
+ });
254
+ });