@pylonts/dsl 1.1.11 → 1.1.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.
- package/dist/convert.d.ts +6 -8
- package/dist/curd.js +1 -1
- package/dist/dao.d.ts +10 -7
- package/dist/dao.js +20 -7
- package/dist/dsl.d.ts +18 -1
- package/dist/dsl.js +40 -0
- package/dist/dto.d.ts +13 -8
- package/dist/dto.js +68 -13
- package/dist/entity.d.ts +4 -3
- package/dist/entity.js +1 -1
- package/dist/filter.d.ts +6 -4
- package/dist/filter.js +1 -1
- package/dist/flow-script.js +8 -2
- package/dist/flow.d.ts +10 -2
- package/dist/flow.js +44 -4
- package/dist/mermaid-driver.js +2 -2
- package/dist/project.d.ts +5 -2
- package/dist/project.js +21 -2
- package/dist/service.d.ts +13 -8
- package/dist/service.js +1 -1
- package/dist/third-service.d.ts +10 -53
- package/dist/third-service.js +3 -78
- package/dist/typebox-driver.d.ts +0 -6
- package/dist/typebox-driver.js +8 -36
- package/dist/utils.d.ts +2 -2
- package/docs/curd.md +55 -20
- package/docs/dao-generation.md +477 -477
- package/docs/project.md +32 -24
- package/docs/token.md +326 -326
- package/package.json +1 -1
- package/src/action.ts +51 -51
- package/src/controller.ts +53 -53
- package/src/convert.ts +76 -78
- package/src/curd.ts +104 -104
- package/src/dao.ts +504 -485
- package/src/dsl.ts +296 -257
- package/src/dto.ts +323 -266
- package/src/entity.ts +43 -42
- package/src/expr.ts +64 -64
- package/src/filter.ts +71 -69
- package/src/flow-script.ts +702 -695
- package/src/flow.ts +1272 -1226
- package/src/index.ts +46 -46
- package/src/mermaid-driver.ts +339 -339
- package/src/mysql-driver.ts +108 -108
- package/src/project.ts +138 -114
- package/src/service.ts +112 -107
- package/src/third-service.ts +68 -191
- package/src/typebox-driver.ts +234 -268
- package/src/utils.ts +74 -74
package/src/dao.ts
CHANGED
|
@@ -1,486 +1,505 @@
|
|
|
1
|
-
import { SchemaBase, Field } from './dsl.js';
|
|
2
|
-
import type { FrontAppSchema, ProjectApiSchema } from './project.js';
|
|
3
|
-
import type { DtoMessage } from './dto.js';
|
|
4
|
-
import { TableSchema } from './db.js';
|
|
5
|
-
import type { EntitySchema } from './entity.js';
|
|
6
|
-
import type { FilterSchema } from './filter.js';
|
|
7
|
-
import type { SetExpr, ValueExpr } from './expr.js';
|
|
8
|
-
|
|
9
|
-
/** A data-access layer bound to
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
api
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
/** The
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| Omit<
|
|
30
|
-
| Omit<
|
|
31
|
-
| Omit<
|
|
32
|
-
| Omit<
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
*
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return;
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
//
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
/**
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
/**
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
/**
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
/**
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
/**
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
args
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
1
|
+
import { CollectionSchemaBase, SchemaBase, Field, isAggregate } from './dsl.js';
|
|
2
|
+
import type { FrontAppSchema, ProjectApiSchema } from './project.js';
|
|
3
|
+
import type { DtoMessage } from './dto.js';
|
|
4
|
+
import { TableSchema } from './db.js';
|
|
5
|
+
import type { EntitySchema } from './entity.js';
|
|
6
|
+
import type { FilterSchema } from './filter.js';
|
|
7
|
+
import type { SetExpr, ValueExpr } from './expr.js';
|
|
8
|
+
|
|
9
|
+
/** A data-access layer bound to one frontend app, or to the api-level common
|
|
10
|
+
* domain layer (app unset — shared across modules). */
|
|
11
|
+
export interface DaoSchema extends CollectionSchemaBase {
|
|
12
|
+
type: 'dao';
|
|
13
|
+
/** The backend api module this DAO belongs to (shared instance from
|
|
14
|
+
* project.config.ts apis). DAOs are always backend-side, so storage is
|
|
15
|
+
* dao_schema/{api.name}/{app.name}/dao/ — app unset = the api-level common
|
|
16
|
+
* domain layer, stored at dao_schema/{api.name}/common/dao/. */
|
|
17
|
+
api: ProjectApiSchema;
|
|
18
|
+
/** The frontend app this DAO belongs to (shared instance from project.config).
|
|
19
|
+
* Unset = api-level common domain DAO shared by all modules of the api. */
|
|
20
|
+
app?: FrontAppSchema;
|
|
21
|
+
/** The table this DAO operates on (single-table atomicity). */
|
|
22
|
+
table: TableSchema;
|
|
23
|
+
/** Methods keyed by name — the map key is written back as the method name. */
|
|
24
|
+
methods: Record<string, DaoMethodSchema>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Method input for defineDao: name is written back from the methods map key. */
|
|
28
|
+
export type DaoMethodDef =
|
|
29
|
+
| Omit<FindSchema, 'schema' | 'name'>
|
|
30
|
+
| Omit<GetSchema, 'schema' | 'name'>
|
|
31
|
+
| Omit<InsertSchema, 'schema' | 'name'>
|
|
32
|
+
| Omit<UpdateSchema, 'schema' | 'name'>
|
|
33
|
+
| Omit<DeleteSchema, 'schema' | 'name'>
|
|
34
|
+
| Omit<UpsertSchema, 'schema' | 'name'>
|
|
35
|
+
| Omit<AggregateSchema, 'schema' | 'name'>;
|
|
36
|
+
|
|
37
|
+
/** The tenant column of the dao table when the app declares a tenant.
|
|
38
|
+
* Deterministic name derivation: `{tenant.phrase}_{tenant.pk}` (e.g. shop
|
|
39
|
+
* with pk id → `shop_id`) — checked directly against the table columns,
|
|
40
|
+
* no FK traversal. Tables without that column are global tables (valid:
|
|
41
|
+
* system config tables carry no tenant id). Exported for generator/linter. */
|
|
42
|
+
export function tenantFkOf(dao: DaoSchema): Field | undefined {
|
|
43
|
+
const app = dao.app;
|
|
44
|
+
const tenant = app?.tenant;
|
|
45
|
+
if (!tenant || !app) return undefined;
|
|
46
|
+
const pk = tenant.primaryKey;
|
|
47
|
+
const phrase = tenant.phrase;
|
|
48
|
+
if (!pk || Array.isArray(pk) || !phrase) {
|
|
49
|
+
throw new Error(`app '${app.name}' tenant table '${tenant.name}' must declare a single-column primaryKey and a phrase (tenant column name = {phrase}_{pk})`);
|
|
50
|
+
}
|
|
51
|
+
return dao.table.columns[`${phrase.name}_${pk.name}`];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The optimistic-lock version column of the dao table (table-level
|
|
55
|
+
* declaration), machine-enforced: update args must carry it, set must not
|
|
56
|
+
* touch it. */
|
|
57
|
+
function versionOf(dao: DaoSchema): Field | undefined {
|
|
58
|
+
return dao.table.version;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Validates a ValueExpr: column refs belong to the dao table, param names
|
|
62
|
+
* are unique, literal/bin types are numeric-compatible. */
|
|
63
|
+
function validateValueExpr(dao: DaoSchema, expr: ValueExpr, params: Set<string>, methodName: string): void {
|
|
64
|
+
const table = dao.table;
|
|
65
|
+
const cols = Object.values(table.columns);
|
|
66
|
+
switch (expr.kind) {
|
|
67
|
+
case 'col':
|
|
68
|
+
if (!cols.includes(expr.field)) {
|
|
69
|
+
throw new Error(`dao ${dao.name}.${methodName}: expr references column '${expr.field.name}' which is not a column of table ${table.name}`);
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
case 'lit':
|
|
73
|
+
if (typeof expr.value === 'string') {
|
|
74
|
+
throw new Error(`dao ${dao.name}.${methodName}: string literal in a numeric expression — use a param instead`);
|
|
75
|
+
}
|
|
76
|
+
return;
|
|
77
|
+
case 'param':
|
|
78
|
+
if (params.has(expr.name)) {
|
|
79
|
+
throw new Error(`dao ${dao.name}.${methodName}: duplicate param '${expr.name}' in set expressions`);
|
|
80
|
+
}
|
|
81
|
+
params.add(expr.name);
|
|
82
|
+
return;
|
|
83
|
+
case 'bin':
|
|
84
|
+
validateValueExpr(dao, expr.left, params, methodName);
|
|
85
|
+
validateValueExpr(dao, expr.right, params, methodName);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function validateUpdateSet(dao: DaoSchema, methodName: string, m: UpdateSchema): void {
|
|
91
|
+
const argCols = new Set(m.args.columns.map((c) => c.name));
|
|
92
|
+
const version = versionOf(dao);
|
|
93
|
+
// Where criteria columns are locators carried by args, not direct assignments —
|
|
94
|
+
// expression-setting the same column is the conditional-update idiom
|
|
95
|
+
// (WHERE state = row.state, SET state = ?).
|
|
96
|
+
const whereCols = new Set((m.where?.conditions ?? []).map((c) => c.field.name));
|
|
97
|
+
for (const setExpr of m.set ?? []) {
|
|
98
|
+
if (version && setExpr.col === version) {
|
|
99
|
+
throw new Error(`dao ${dao.name}.${methodName}: set must not touch version column '${version.name}' — the optimistic lock manages it`);
|
|
100
|
+
}
|
|
101
|
+
if (!Object.values(dao.table.columns).includes(setExpr.col)) {
|
|
102
|
+
throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' is not a column of table ${dao.table.name}`);
|
|
103
|
+
}
|
|
104
|
+
if (argCols.has(setExpr.col.name) && !whereCols.has(setExpr.col.name)) {
|
|
105
|
+
throw new Error(`dao ${dao.name}.${methodName}: set column '${setExpr.col.name}' also appears in args '${m.args.name}' — a column is either directly assigned or expression-set, never both`);
|
|
106
|
+
}
|
|
107
|
+
validateValueExpr(dao, setExpr.expr, new Set(), methodName);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Validates read-method result columns: external reference columns must be
|
|
112
|
+
* reachable through a main-table foreign key (FK = join condition). */
|
|
113
|
+
function assertColumnReachable(dao: DaoSchema, methodName: string, c: Field): void {
|
|
114
|
+
const table = dao.table;
|
|
115
|
+
if (!c.schema) {
|
|
116
|
+
throw new Error(`dao ${dao.name}.${methodName}: row column '${c.name}' has no schema`);
|
|
117
|
+
}
|
|
118
|
+
if (c.schema === table) return;
|
|
119
|
+
const srcTable = c.schema as TableSchema;
|
|
120
|
+
const reachable = Object.values(table.foreignKeys ?? {}).some((fk) => {
|
|
121
|
+
const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
|
|
122
|
+
return refs.some((r) => r.schema === srcTable);
|
|
123
|
+
});
|
|
124
|
+
if (!reachable) {
|
|
125
|
+
throw new Error(`dao ${dao.name}.${methodName}: row column '${c.name}' belongs to table ${srcTable.name} but ${table.name} has no foreign key pointing to it`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Validates read-method result columns: external reference columns must be
|
|
130
|
+
* reachable through a main-table foreign key (FK = join condition). */
|
|
131
|
+
function validateRowColumns(dao: DaoSchema, methodName: string, row: EntitySchema): void {
|
|
132
|
+
for (const c of row.columns) assertColumnReachable(dao, methodName, c);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Validates key args (get/delete): every key field must be a main-table
|
|
136
|
+
* column; the tenant column is injected as a separate parameter and can
|
|
137
|
+
* never be a key (a duplicate parameter would render). */
|
|
138
|
+
function validateKeyArgs(dao: DaoSchema, methodName: string, args: Field | Field[]): void {
|
|
139
|
+
const cols = Object.values(dao.table.columns);
|
|
140
|
+
const keys = Array.isArray(args) ? args : [args];
|
|
141
|
+
for (const k of keys) {
|
|
142
|
+
if (!cols.includes(k)) {
|
|
143
|
+
throw new Error(`dao ${dao.name}.${methodName}: key column '${k.name}' is not a column of table ${dao.table.name}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const tenant = tenantFkOf(dao);
|
|
147
|
+
if (tenant && keys.includes(tenant)) {
|
|
148
|
+
throw new Error(`dao ${dao.name}.${methodName}: key '${tenant.name}' is the tenant column — it is injected as a separate parameter, never a key`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Update runs without JOINs, so its where criteria must all reference
|
|
153
|
+
* main-table columns — a cross-table criterion would render a raw column
|
|
154
|
+
* name that no table in the query provides. Also requires at least one
|
|
155
|
+
* locating criterion: without a pk/tenant/version/filter the generated
|
|
156
|
+
* UPDATE would have an empty WHERE and touch every row. */
|
|
157
|
+
function validateUpdateWhere(dao: DaoSchema, methodName: string, m: UpdateSchema): void {
|
|
158
|
+
if (m.where) {
|
|
159
|
+
for (const c of m.where.conditions) {
|
|
160
|
+
if (c.field.schema !== dao.table) {
|
|
161
|
+
const srcName = (c.field.schema as TableSchema | undefined)?.name ?? 'unknown';
|
|
162
|
+
throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' belongs to table '${srcName}' — update is single-table and cannot join`);
|
|
163
|
+
}
|
|
164
|
+
if (c.optional) {
|
|
165
|
+
throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' is optional — update criteria must be required (a missing value would silently drop the criterion)`);
|
|
166
|
+
}
|
|
167
|
+
if (!m.args.columns.includes(c.field)) {
|
|
168
|
+
throw new Error(`dao ${dao.name}.${methodName}: where criterion on '${c.field.name}' is not carried by args '${m.args.name}' — the generated UPDATE reads it from the row`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const pk = dao.table.primaryKey;
|
|
173
|
+
const hasPk = pk !== undefined && (Array.isArray(pk) ? pk.length > 0 : true);
|
|
174
|
+
const hasLocator = hasPk || tenantFkOf(dao) !== undefined || versionOf(dao) !== undefined || (m.where !== undefined && m.where.conditions.length > 0);
|
|
175
|
+
if (!hasLocator) {
|
|
176
|
+
throw new Error(`dao ${dao.name}.${methodName}: update on table '${dao.table.name}' has no locating criteria — declare a primaryKey, tenant, version column, or a where filter (an empty WHERE updates every row)`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Order-by columns must be part of the result row: the generated ORDER BY
|
|
181
|
+
* uses the row's result keys, which only exist for selected columns. */
|
|
182
|
+
function validateOrderBy(dao: DaoSchema, methodName: string, m: FindSchema): void {
|
|
183
|
+
if (!m.orderBy) return;
|
|
184
|
+
const orders = Array.isArray(m.orderBy) ? m.orderBy : [m.orderBy];
|
|
185
|
+
for (const o of orders) {
|
|
186
|
+
if (!m.results.columns.includes(o.column)) {
|
|
187
|
+
throw new Error(`dao ${dao.name}.${methodName}: orderBy column '${o.column.name}' is not part of results '${m.results.name}'`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Aggregate results: the result entity must carry at least one column; every
|
|
193
|
+
* plain column (grouping dimension) and every aggregate field's underlying
|
|
194
|
+
* column must be reachable (same rule as read results). */
|
|
195
|
+
function validateAggregateResults(dao: DaoSchema, methodName: string, m: AggregateSchema): void {
|
|
196
|
+
const columns = m.results.columns;
|
|
197
|
+
if (columns.length === 0) {
|
|
198
|
+
throw new Error(`dao ${dao.name}.${methodName}: aggregate has no results`);
|
|
199
|
+
}
|
|
200
|
+
for (const c of columns) {
|
|
201
|
+
if (isAggregate(c)) {
|
|
202
|
+
if (c.expr.field) assertColumnReachable(dao, methodName, c.expr.field);
|
|
203
|
+
} else {
|
|
204
|
+
assertColumnReachable(dao, methodName, c);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Upsert conflict keys: must equal the pk or a complete unique index column
|
|
210
|
+
* set, and every key must be carried by args. Auto-increment tables are
|
|
211
|
+
* forbidden (MySQL auto_increment burns ids on duplicate-key updates). */
|
|
212
|
+
function validateUpsert(dao: DaoSchema, methodName: string, m: UpsertSchema): void {
|
|
213
|
+
const table = dao.table;
|
|
214
|
+
if (table.autoIncrement) {
|
|
215
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert on table '${table.name}' with auto-increment pk is forbidden (MySQL auto_increment burns ids on duplicate-key updates)`);
|
|
216
|
+
}
|
|
217
|
+
const keys = Array.isArray(m.keys) ? m.keys : [m.keys];
|
|
218
|
+
if (keys.length === 0) {
|
|
219
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert keys must be non-empty`);
|
|
220
|
+
}
|
|
221
|
+
for (const k of keys) {
|
|
222
|
+
if (k.schema !== table) {
|
|
223
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert key '${k.name}' is not a column of table ${table.name}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const keyNames = keys.map((k) => k.name).sort();
|
|
227
|
+
const candidates: Array<string[]> = [];
|
|
228
|
+
const uniqueCols = new Set<string>();
|
|
229
|
+
const pk = table.primaryKey;
|
|
230
|
+
if (pk) {
|
|
231
|
+
const cs = (Array.isArray(pk) ? pk : [pk]).map((c) => c.name);
|
|
232
|
+
candidates.push(cs.sort());
|
|
233
|
+
for (const n of cs) uniqueCols.add(n);
|
|
234
|
+
}
|
|
235
|
+
for (const idx of table.indexes ?? []) {
|
|
236
|
+
if (idx.unique) {
|
|
237
|
+
const cs = (Array.isArray(idx.columns) ? idx.columns : [idx.columns]).map((c) => c.name);
|
|
238
|
+
candidates.push(cs.sort());
|
|
239
|
+
for (const n of cs) uniqueCols.add(n);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const matches = candidates.some((c) => c.length === keyNames.length && c.every((n, i) => n === keyNames[i]));
|
|
243
|
+
if (!matches) {
|
|
244
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert keys [${keys.map((k) => k.name).join(', ')}] must equal the pk or a complete unique index column set of table ${table.name}`);
|
|
245
|
+
}
|
|
246
|
+
for (const k of keys) {
|
|
247
|
+
if (!m.args.columns.includes(k)) {
|
|
248
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert args '${m.args.name}' must carry conflict key '${k.name}'`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
// ON DUPLICATE KEY UPDATE has no WHERE — tenant isolation is only possible
|
|
252
|
+
// when the tenant column participates in the conflict key.
|
|
253
|
+
const tenant = tenantFkOf(dao);
|
|
254
|
+
if (tenant && !keys.includes(tenant)) {
|
|
255
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert on tenant-scoped table '${table.name}' must include tenant column '${tenant.name}' in keys (ON DUPLICATE KEY UPDATE has no WHERE — tenant isolation requires the tenant column to be part of the conflict key)`);
|
|
256
|
+
}
|
|
257
|
+
// The merge clause (col = new.col) only writes non-key writable columns —
|
|
258
|
+
// conflict keys are the conflict identity, readOnly columns stay
|
|
259
|
+
// DB-managed, and every pk/unique column is excluded: a merge writing a
|
|
260
|
+
// unique column could collide with another row's value and chain-fire the
|
|
261
|
+
// duplicate-key handler (unique columns are conflict identity, not data).
|
|
262
|
+
const mergeCols = m.args.columns.filter((c) => !keys.includes(c) && !c.readOnly && !uniqueCols.has(c.name));
|
|
263
|
+
if (mergeCols.length === 0) {
|
|
264
|
+
throw new Error(`dao ${dao.name}.${methodName}: upsert has no merge columns — args '${m.args.name}' carries only conflict keys, pk/unique and readOnly columns (ON DUPLICATE KEY UPDATE needs at least one writable non-unique column)`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Validates write-method args: every column must come from the dao table
|
|
269
|
+
* (single-table atomicity). */
|
|
270
|
+
function validateWriteArgs(dao: DaoSchema, methodName: string, args: EntitySchema): void {
|
|
271
|
+
const cols = Object.values(dao.table.columns);
|
|
272
|
+
for (const c of args.columns) {
|
|
273
|
+
if (!cols.includes(c)) {
|
|
274
|
+
throw new Error(`dao ${dao.name}.${methodName}: args column '${c.name}' of '${args.name}' is not a column of table ${dao.table.name}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Enforces the tenant/version column presence on row-carried methods
|
|
280
|
+
* (decisions: insert = declared in args; update = extracted from row).
|
|
281
|
+
* Tenant is required on both writes (the column is always scoped); version
|
|
282
|
+
* is required only on update — inserts let the DB default initialize it. */
|
|
283
|
+
function validateRowCarriedColumns(dao: DaoSchema, methodName: string, method: InsertSchema | UpdateSchema | UpsertSchema): void {
|
|
284
|
+
const tenant = tenantFkOf(dao);
|
|
285
|
+
if (tenant) {
|
|
286
|
+
if (!method.args.columns.includes(tenant)) {
|
|
287
|
+
throw new Error(`dao ${dao.name}.${methodName}: args '${method.args.name}' must include tenant column '${tenant.name}'`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (method.type === 'update') {
|
|
291
|
+
const version = versionOf(dao);
|
|
292
|
+
if (version && !method.args.columns.includes(version)) {
|
|
293
|
+
throw new Error(`dao ${dao.name}.${methodName}: args '${method.args.name}' must include version column '${version.name}' — the optimistic lock reads it from the row`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function defineDao(options: {
|
|
299
|
+
name: string;
|
|
300
|
+
api: ProjectApiSchema;
|
|
301
|
+
app?: FrontAppSchema;
|
|
302
|
+
table: TableSchema;
|
|
303
|
+
methods: Record<string, DaoMethodDef>;
|
|
304
|
+
description?: string;
|
|
305
|
+
}): DaoSchema {
|
|
306
|
+
if (options.app && !options.api.apps.includes(options.app)) {
|
|
307
|
+
throw new Error(`dao ${options.name}: api '${options.api.name}' does not serve app '${options.app.name}'`);
|
|
308
|
+
}
|
|
309
|
+
const schema: DaoSchema = {
|
|
310
|
+
type: 'dao',
|
|
311
|
+
name: options.name,
|
|
312
|
+
description: options.description,
|
|
313
|
+
api: options.api,
|
|
314
|
+
app: options.app,
|
|
315
|
+
table: options.table,
|
|
316
|
+
methods: {},
|
|
317
|
+
};
|
|
318
|
+
for (const key of Object.keys(options.methods)) {
|
|
319
|
+
const method = options.methods[key] as DaoMethodDef;
|
|
320
|
+
for (const ref of [method.args, 'where' in method ? method.where : undefined]) {
|
|
321
|
+
if (isFilterSchema(ref) && ref.api !== options.api) {
|
|
322
|
+
throw new Error(
|
|
323
|
+
`dao ${options.name}: method '${key}' references filter '${ref.name}' bound to ${ref.api.name}/${ref.app?.name ?? 'common'} but the dao is bound to ${options.api.name}/${options.app?.name ?? 'common'}`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
// app-bound dao may use common or same-app filters; common dao may only use common filters.
|
|
327
|
+
if (isFilterSchema(ref) && ref.api === options.api && !options.app && ref.app) {
|
|
328
|
+
throw new Error(
|
|
329
|
+
`dao ${options.name}: method '${key}' references app-bound filter '${ref.name}' (${ref.app.name}) but the dao is api-level common`,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
if (isFilterSchema(ref) && ref.api === options.api && options.app && ref.app && ref.app !== options.app) {
|
|
333
|
+
throw new Error(
|
|
334
|
+
`dao ${options.name}: method '${key}' references filter '${ref.name}' bound to ${ref.api.name}/${ref.app.name} but the dao is bound to ${options.api.name}/${options.app.name}`,
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Method-kind validation (declaration is complete — every kind is checked).
|
|
339
|
+
if (method.type === 'insert' || method.type === 'update' || method.type === 'upsert') {
|
|
340
|
+
const m = method as InsertSchema | UpdateSchema | UpsertSchema;
|
|
341
|
+
validateWriteArgs(schema, key, m.args as EntitySchema);
|
|
342
|
+
validateRowCarriedColumns(schema, key, m);
|
|
343
|
+
if (method.type === 'update') {
|
|
344
|
+
validateUpdateSet(schema, key, method as UpdateSchema);
|
|
345
|
+
validateUpdateWhere(schema, key, method as UpdateSchema);
|
|
346
|
+
}
|
|
347
|
+
if (method.type === 'upsert') {
|
|
348
|
+
validateUpsert(schema, key, method as UpsertSchema);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (method.type === 'get' || method.type === 'delete') {
|
|
352
|
+
validateKeyArgs(schema, key, (method as GetSchema | DeleteSchema).args);
|
|
353
|
+
}
|
|
354
|
+
if (method.type === 'find' || method.type === 'get') {
|
|
355
|
+
validateRowColumns(schema, key, (method as FindSchema | GetSchema).results);
|
|
356
|
+
}
|
|
357
|
+
if (method.type === 'find') {
|
|
358
|
+
validateOrderBy(schema, key, method as FindSchema);
|
|
359
|
+
}
|
|
360
|
+
if (method.type === 'aggregate') {
|
|
361
|
+
validateAggregateResults(schema, key, method as AggregateSchema);
|
|
362
|
+
}
|
|
363
|
+
// Spread of a union loses discriminant correlation; the cast is safe
|
|
364
|
+
// (the builder only adds name and the back-reference field).
|
|
365
|
+
schema.methods[key] = { ...method, name: key, schema } as DaoMethodSchema;
|
|
366
|
+
}
|
|
367
|
+
return schema;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function isFilterSchema(value: unknown): value is FilterSchema {
|
|
371
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
372
|
+
const v = value as Record<string, unknown>;
|
|
373
|
+
return v.type === 'filter' && typeof v.name === 'string';
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// DAO method kinds. Signature only: name + params + result. No logic —
|
|
377
|
+
// complex SQL goes into description (text), never into schema.
|
|
378
|
+
// Query conditions are FilterSchema references (the single shared filter
|
|
379
|
+
// model) — never inline criteria.
|
|
380
|
+
|
|
381
|
+
/** Sort specification for find results. */
|
|
382
|
+
export interface OrderBySchema {
|
|
383
|
+
/** Column to sort by. */
|
|
384
|
+
column: Field;
|
|
385
|
+
/** Sort direction. */
|
|
386
|
+
sort: 'asc' | 'desc';
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Query rows by criteria; returns a list of row objects. */
|
|
390
|
+
export interface FindSchema extends SchemaBase {
|
|
391
|
+
type: 'find';
|
|
392
|
+
schema: DaoSchema;
|
|
393
|
+
/** Query filter; omit = all rows. */
|
|
394
|
+
args?: FilterSchema;
|
|
395
|
+
/** Pagination marker: 'page' (page/pageSize) or 'limit' (position/limit).
|
|
396
|
+
* Only presence matters — parameter shapes are a generator convention. */
|
|
397
|
+
mode?: 'page' | 'limit';
|
|
398
|
+
/** Result sort; omit = no explicit order. */
|
|
399
|
+
orderBy?: OrderBySchema | OrderBySchema[];
|
|
400
|
+
/** Result row: selected columns (multi-table via external reference columns). */
|
|
401
|
+
results: EntitySchema;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Fetch a single row by exact key — any column or AND combination of
|
|
405
|
+
* columns (single PK, composite PK, getByXX are all legal declarations).
|
|
406
|
+
* May miss the row: the generated signature returns Row | null. */
|
|
407
|
+
export interface GetSchema extends SchemaBase {
|
|
408
|
+
type: 'get';
|
|
409
|
+
schema: DaoSchema;
|
|
410
|
+
/** Key column(s): one field or AND-exact-matched fields. */
|
|
411
|
+
args: Field | Field[];
|
|
412
|
+
/** Additional criteria AND-combined onto the key (e.g. state condition
|
|
413
|
+
* for optimistic reads). */
|
|
414
|
+
where?: FilterSchema;
|
|
415
|
+
/** Result row: selected columns (multi-table via external reference columns). */
|
|
416
|
+
results: EntitySchema;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Insert one row; returns the generated key (string). */
|
|
420
|
+
export interface InsertSchema extends SchemaBase {
|
|
421
|
+
type: 'insert';
|
|
422
|
+
schema: DaoSchema;
|
|
423
|
+
/** Row object. */
|
|
424
|
+
args: EntitySchema;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Update one row; returns number (affected rows). The where key is derived
|
|
428
|
+
* from the PK columns inside args; tenant/version columns are extracted
|
|
429
|
+
* from the row into WHERE and never SET (optimistic lock auto-manages
|
|
430
|
+
* `version = version + 1`). */
|
|
431
|
+
export interface UpdateSchema extends SchemaBase {
|
|
432
|
+
type: 'update';
|
|
433
|
+
schema: DaoSchema;
|
|
434
|
+
/** Row object containing the PK columns plus the columns to set. */
|
|
435
|
+
args: EntitySchema;
|
|
436
|
+
/** Expression-set columns (`col = expr`) beyond direct assignment. */
|
|
437
|
+
set?: SetExpr[];
|
|
438
|
+
/** Additional criteria beyond the derived PK (AND-combined). */
|
|
439
|
+
where?: FilterSchema;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Delete one row by exact key; returns number (affected rows). */
|
|
443
|
+
export interface DeleteSchema extends SchemaBase {
|
|
444
|
+
type: 'delete';
|
|
445
|
+
schema: DaoSchema;
|
|
446
|
+
/** Key column(s): one field or AND-exact-matched fields. */
|
|
447
|
+
args: Field | Field[];
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Insert-or-update (MySQL ON DUPLICATE KEY UPDATE): atomic idempotent write.
|
|
451
|
+
* Returns affected rows (1 = inserted, 2 = updated, 0 = unchanged).
|
|
452
|
+
* No id generation — every conflict key must be carried in args. Tables with
|
|
453
|
+
* an auto-increment pk are forbidden (MySQL auto_increment burns ids on
|
|
454
|
+
* duplicate-key updates). */
|
|
455
|
+
export interface UpsertSchema extends SchemaBase {
|
|
456
|
+
type: 'upsert';
|
|
457
|
+
schema: DaoSchema;
|
|
458
|
+
/** Row object: must carry every conflict key plus the columns to merge. */
|
|
459
|
+
args: EntitySchema;
|
|
460
|
+
/** Conflict keys: must equal the pk or a complete unique index column set. */
|
|
461
|
+
keys: Field | Field[];
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export type DaoMethodSchema =
|
|
465
|
+
| FindSchema
|
|
466
|
+
| GetSchema
|
|
467
|
+
| InsertSchema
|
|
468
|
+
| UpdateSchema
|
|
469
|
+
| DeleteSchema
|
|
470
|
+
| UpsertSchema
|
|
471
|
+
| AggregateSchema;
|
|
472
|
+
|
|
473
|
+
/** Aggregate query (find upgraded at the select level): the result entity
|
|
474
|
+
* mixes plain columns (the GROUP BY dimensions) and aggregate fields
|
|
475
|
+
* (aggField — count/sum/avg).
|
|
476
|
+
*
|
|
477
|
+
* Usage — aggregate fields are defined in the entity file, the dao method
|
|
478
|
+
* only references the entity:
|
|
479
|
+
*
|
|
480
|
+
* ```ts
|
|
481
|
+
* // entity_schema/{api}/{app}/entity/order.entity.ts
|
|
482
|
+
* export const orderStatusStats = defineEntity({
|
|
483
|
+
* name: 'OrderStatusStats',
|
|
484
|
+
* api, app,
|
|
485
|
+
* columns: [
|
|
486
|
+
* order.columns.status, // GROUP BY dimension
|
|
487
|
+
* aggField('total', Compute.count()), // count → jsType 'number'
|
|
488
|
+
* aggField('sumAmt', Compute.sum(order.columns.amount)), // sum(decimal) → 'string'
|
|
489
|
+
* ],
|
|
490
|
+
* });
|
|
491
|
+
*
|
|
492
|
+
* // dao_schema/{api}/{app}/dao/order.dao.ts
|
|
493
|
+
* stats: { type: 'aggregate', args: orderFilter, results: orderStatusStats },
|
|
494
|
+
* ``` */
|
|
495
|
+
export interface AggregateSchema extends SchemaBase {
|
|
496
|
+
type: 'aggregate';
|
|
497
|
+
schema: DaoSchema;
|
|
498
|
+
/** Criteria filter, same shape as find. */
|
|
499
|
+
args?: FilterSchema;
|
|
500
|
+
/** Result entity: plain columns group the rows (one row per group);
|
|
501
|
+
* aggregate fields become the computed output columns. The entity — with
|
|
502
|
+
* its aggField definitions — lives in the entity file ({table}.entity.ts);
|
|
503
|
+
* the dao method only references it, never defines aggregate fields inline. */
|
|
504
|
+
results: EntitySchema;
|
|
486
505
|
}
|