metal-orm 1.0.5 → 1.0.7
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/README.md +299 -113
- package/docs/CHANGES.md +104 -0
- package/docs/advanced-features.md +92 -1
- package/docs/api-reference.md +13 -4
- package/docs/dml-operations.md +156 -0
- package/docs/getting-started.md +122 -55
- package/docs/hydration.md +78 -13
- package/docs/index.md +19 -14
- package/docs/multi-dialect-support.md +25 -0
- package/docs/query-builder.md +60 -0
- package/docs/runtime.md +105 -0
- package/docs/schema-definition.md +52 -1
- package/package.json +1 -1
- package/src/ast/expression.ts +38 -18
- package/src/builder/hydration-planner.ts +74 -74
- package/src/builder/select.ts +427 -395
- package/src/constants/sql-operator-config.ts +3 -0
- package/src/constants/sql.ts +38 -32
- package/src/index.ts +16 -8
- package/src/playground/features/playground/data/scenarios/types.ts +18 -15
- package/src/playground/features/playground/data/schema.ts +10 -10
- package/src/playground/features/playground/services/QueryExecutionService.ts +2 -1
- package/src/runtime/entity-meta.ts +52 -0
- package/src/runtime/entity.ts +252 -0
- package/src/runtime/execute.ts +36 -0
- package/src/runtime/hydration.ts +99 -49
- package/src/runtime/lazy-batch.ts +205 -0
- package/src/runtime/orm-context.ts +539 -0
- package/src/runtime/relations/belongs-to.ts +92 -0
- package/src/runtime/relations/has-many.ts +111 -0
- package/src/runtime/relations/many-to-many.ts +149 -0
- package/src/schema/column.ts +15 -1
- package/src/schema/relation.ts +82 -58
- package/src/schema/table.ts +34 -22
- package/src/schema/types.ts +76 -0
- package/tests/orm-runtime.test.ts +254 -0
|
@@ -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
|
+
});
|