@pylonts/dsl 1.1.4 → 1.1.5
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/dsl.d.ts +9 -1
- package/dist/dsl.js +10 -0
- package/dist/mysql-driver.js +4 -1
- package/dist/typebox-driver.js +3 -2
- package/docs/curd.md +110 -110
- package/docs/dto.md +66 -66
- package/docs/table.md +135 -135
- package/package.json +1 -1
- package/src/action.ts +10 -10
- package/src/asset.ts +62 -62
- package/src/bases.ts +29 -29
- package/src/component.ts +21 -21
- package/src/convert.ts +15 -15
- package/src/curd.ts +93 -93
- package/src/dao.ts +13 -13
- package/src/db.ts +181 -181
- package/src/dsl.ts +23 -0
- package/src/dto.ts +247 -247
- package/src/event.ts +12 -12
- package/src/index.ts +32 -32
- package/src/mermaid-driver.ts +84 -84
- package/src/mock.ts +12 -12
- package/src/mysql-driver.ts +4 -2
- package/src/navigation.ts +28 -28
- package/src/page-def.ts +79 -79
- package/src/page-flow.ts +153 -153
- package/src/page.ts +76 -76
- package/src/popup.ts +25 -25
- package/src/project.ts +97 -97
- package/src/provider.ts +72 -72
- package/src/ref.ts +18 -18
- package/src/route.ts +11 -11
- package/src/service.ts +20 -20
- package/src/typebox-driver.ts +193 -192
- package/src/utils.ts +26 -26
package/src/curd.ts
CHANGED
|
@@ -1,94 +1,94 @@
|
|
|
1
|
-
import { SchemaBase, Field, Operator } from './dsl.js';
|
|
2
|
-
import { TableSchema } from './db.js';
|
|
3
|
-
import { FrontAppSchema } from './project.js';
|
|
4
|
-
import { ActionSchema } from './action.js';
|
|
5
|
-
|
|
6
|
-
// Admin-only CRUD page standard: binds one entity table to a frontend admin
|
|
7
|
-
// app, describing everything needed to generate the list page plus optional
|
|
8
|
-
// add/update/detail pages. Field-level columns are plain Field instances
|
|
9
|
-
// (table fields, cross-table refs allowed) — DTOs are derived by the generator,
|
|
10
|
-
// the schema itself never references DtoMessage.
|
|
11
|
-
|
|
12
|
-
/** Mode of an action page: modal dialog or standalone route. */
|
|
13
|
-
export type ActionPageMode = 'modal' | 'route';
|
|
14
|
-
|
|
15
|
-
/** One CRUD action page (add / update / detail). */
|
|
16
|
-
export interface ActionPage {
|
|
17
|
-
mode: ActionPageMode;
|
|
18
|
-
/** For add/update: when true, render as modal on list page; when false/undefined, render as standalone route page. */
|
|
19
|
-
modal?: boolean;
|
|
20
|
-
/** Fields rendered on this page. Required, non-empty — every field the
|
|
21
|
-
* frontend shows must be listed explicitly. */
|
|
22
|
-
columns: Field[];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/** List page configuration. */
|
|
26
|
-
export interface CurdListConfig {
|
|
27
|
-
/** List columns; required, non-empty. Every field the list shows must be
|
|
28
|
-
* listed explicitly. May include cross-table fields via foreign refs. */
|
|
29
|
-
columns: Field[];
|
|
30
|
-
/** Fuzzy keyword search on this table's columns. */
|
|
31
|
-
keyword?: { columns: Field[] };
|
|
32
|
-
/** Default sort. Required — column and direction are both mandatory. */
|
|
33
|
-
orderBy: { column: Field; direction: 'asc' | 'desc' };
|
|
34
|
-
/** Search condition fields; op defaults to 'eq'. */
|
|
35
|
-
searchFields?: { field: Field; op?: Operator }[];
|
|
36
|
-
/** Column header text overrides: Field.name → header text. */
|
|
37
|
-
columnTitles?: Record<string, string>;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** Admin-only CRUD page standard: binds one entity table to a frontend
|
|
41
|
-
* admin app. Drives generation of the list page plus optional
|
|
42
|
-
* add/update/detail pages. */
|
|
43
|
-
export interface CurdSchema extends SchemaBase {
|
|
44
|
-
/** The admin frontend app this CRUD belongs to (shared instance, type must be 'admin'). */
|
|
45
|
-
app: FrontAppSchema;
|
|
46
|
-
/** The bound entity table (shared instance). */
|
|
47
|
-
table: TableSchema;
|
|
48
|
-
/** List page Chinese title. */
|
|
49
|
-
title: string;
|
|
50
|
-
/** Sidebar menu section (group) this CRUD page belongs to. */
|
|
51
|
-
section: string;
|
|
52
|
-
/** Extra user actions on this page (beyond the standard CRUD). */
|
|
53
|
-
actions?: ActionSchema[];
|
|
54
|
-
/** Add/update/detail action pages. */
|
|
55
|
-
actionPages?: {
|
|
56
|
-
add?: ActionPage;
|
|
57
|
-
update?: ActionPage;
|
|
58
|
-
detail?: ActionPage;
|
|
59
|
-
};
|
|
60
|
-
list: CurdListConfig;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
function assertColumns(curd: CurdSchema, pageName: string, columns: Field[]): void {
|
|
64
|
-
if (columns.length === 0) {
|
|
65
|
-
throw new Error(`curd ${curd.name}: ${pageName}.columns must be non-empty`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function assertFieldsOwnTable(curd: CurdSchema, label: string, fields: Field[]): void {
|
|
70
|
-
for (const f of fields) {
|
|
71
|
-
if (f.schema !== curd.table) {
|
|
72
|
-
throw new Error(`curd ${curd.name}: ${label} field ${f.name} does not belong to table ${curd.table.name}`);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Defines an admin CRUD page standard. Runtime-validates admin app binding,
|
|
78
|
-
* non-empty columns and table field ownership (same style as defineTable). */
|
|
79
|
-
export function defineCurd(name: string, schema: Omit<CurdSchema, 'name'>): CurdSchema {
|
|
80
|
-
const curd: CurdSchema = { name, ...schema };
|
|
81
|
-
if (curd.app.type !== 'admin') {
|
|
82
|
-
throw new Error(`curd ${name}: app ${curd.app.name} must be type 'admin' (got '${curd.app.type}')`);
|
|
83
|
-
}
|
|
84
|
-
if (!curd.section) {
|
|
85
|
-
throw new Error(`curd ${name}: section is required (sidebar menu group, e.g. '商户管理')`);
|
|
86
|
-
}
|
|
87
|
-
assertColumns(curd, 'list', curd.list.columns);
|
|
88
|
-
for (const [pageName, page] of Object.entries(curd.actionPages ?? {})) {
|
|
89
|
-
if (page) assertColumns(curd, `actionPages.${pageName}`, page.columns);
|
|
90
|
-
}
|
|
91
|
-
assertFieldsOwnTable(curd, 'keyword', curd.list.keyword?.columns ?? []);
|
|
92
|
-
assertFieldsOwnTable(curd, 'orderBy', [curd.list.orderBy.column]);
|
|
93
|
-
return curd;
|
|
1
|
+
import { SchemaBase, Field, Operator } from './dsl.js';
|
|
2
|
+
import { TableSchema } from './db.js';
|
|
3
|
+
import { FrontAppSchema } from './project.js';
|
|
4
|
+
import { ActionSchema } from './action.js';
|
|
5
|
+
|
|
6
|
+
// Admin-only CRUD page standard: binds one entity table to a frontend admin
|
|
7
|
+
// app, describing everything needed to generate the list page plus optional
|
|
8
|
+
// add/update/detail pages. Field-level columns are plain Field instances
|
|
9
|
+
// (table fields, cross-table refs allowed) — DTOs are derived by the generator,
|
|
10
|
+
// the schema itself never references DtoMessage.
|
|
11
|
+
|
|
12
|
+
/** Mode of an action page: modal dialog or standalone route. */
|
|
13
|
+
export type ActionPageMode = 'modal' | 'route';
|
|
14
|
+
|
|
15
|
+
/** One CRUD action page (add / update / detail). */
|
|
16
|
+
export interface ActionPage {
|
|
17
|
+
mode: ActionPageMode;
|
|
18
|
+
/** For add/update: when true, render as modal on list page; when false/undefined, render as standalone route page. */
|
|
19
|
+
modal?: boolean;
|
|
20
|
+
/** Fields rendered on this page. Required, non-empty — every field the
|
|
21
|
+
* frontend shows must be listed explicitly. */
|
|
22
|
+
columns: Field[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** List page configuration. */
|
|
26
|
+
export interface CurdListConfig {
|
|
27
|
+
/** List columns; required, non-empty. Every field the list shows must be
|
|
28
|
+
* listed explicitly. May include cross-table fields via foreign refs. */
|
|
29
|
+
columns: Field[];
|
|
30
|
+
/** Fuzzy keyword search on this table's columns. */
|
|
31
|
+
keyword?: { columns: Field[] };
|
|
32
|
+
/** Default sort. Required — column and direction are both mandatory. */
|
|
33
|
+
orderBy: { column: Field; direction: 'asc' | 'desc' };
|
|
34
|
+
/** Search condition fields; op defaults to 'eq'. */
|
|
35
|
+
searchFields?: { field: Field; op?: Operator }[];
|
|
36
|
+
/** Column header text overrides: Field.name → header text. */
|
|
37
|
+
columnTitles?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Admin-only CRUD page standard: binds one entity table to a frontend
|
|
41
|
+
* admin app. Drives generation of the list page plus optional
|
|
42
|
+
* add/update/detail pages. */
|
|
43
|
+
export interface CurdSchema extends SchemaBase {
|
|
44
|
+
/** The admin frontend app this CRUD belongs to (shared instance, type must be 'admin'). */
|
|
45
|
+
app: FrontAppSchema;
|
|
46
|
+
/** The bound entity table (shared instance). */
|
|
47
|
+
table: TableSchema;
|
|
48
|
+
/** List page Chinese title. */
|
|
49
|
+
title: string;
|
|
50
|
+
/** Sidebar menu section (group) this CRUD page belongs to. */
|
|
51
|
+
section: string;
|
|
52
|
+
/** Extra user actions on this page (beyond the standard CRUD). */
|
|
53
|
+
actions?: ActionSchema[];
|
|
54
|
+
/** Add/update/detail action pages. */
|
|
55
|
+
actionPages?: {
|
|
56
|
+
add?: ActionPage;
|
|
57
|
+
update?: ActionPage;
|
|
58
|
+
detail?: ActionPage;
|
|
59
|
+
};
|
|
60
|
+
list: CurdListConfig;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function assertColumns(curd: CurdSchema, pageName: string, columns: Field[]): void {
|
|
64
|
+
if (columns.length === 0) {
|
|
65
|
+
throw new Error(`curd ${curd.name}: ${pageName}.columns must be non-empty`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function assertFieldsOwnTable(curd: CurdSchema, label: string, fields: Field[]): void {
|
|
70
|
+
for (const f of fields) {
|
|
71
|
+
if (f.schema !== curd.table) {
|
|
72
|
+
throw new Error(`curd ${curd.name}: ${label} field ${f.name} does not belong to table ${curd.table.name}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Defines an admin CRUD page standard. Runtime-validates admin app binding,
|
|
78
|
+
* non-empty columns and table field ownership (same style as defineTable). */
|
|
79
|
+
export function defineCurd(name: string, schema: Omit<CurdSchema, 'name'>): CurdSchema {
|
|
80
|
+
const curd: CurdSchema = { name, ...schema };
|
|
81
|
+
if (curd.app.type !== 'admin') {
|
|
82
|
+
throw new Error(`curd ${name}: app ${curd.app.name} must be type 'admin' (got '${curd.app.type}')`);
|
|
83
|
+
}
|
|
84
|
+
if (!curd.section) {
|
|
85
|
+
throw new Error(`curd ${name}: section is required (sidebar menu group, e.g. '商户管理')`);
|
|
86
|
+
}
|
|
87
|
+
assertColumns(curd, 'list', curd.list.columns);
|
|
88
|
+
for (const [pageName, page] of Object.entries(curd.actionPages ?? {})) {
|
|
89
|
+
if (page) assertColumns(curd, `actionPages.${pageName}`, page.columns);
|
|
90
|
+
}
|
|
91
|
+
assertFieldsOwnTable(curd, 'keyword', curd.list.keyword?.columns ?? []);
|
|
92
|
+
assertFieldsOwnTable(curd, 'orderBy', [curd.list.orderBy.column]);
|
|
93
|
+
return curd;
|
|
94
94
|
}
|
package/src/dao.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { SchemaBase } from './dsl.js';
|
|
2
|
-
import { FrontAppSchema } from './project.js';
|
|
3
|
-
|
|
4
|
-
/** A data-access layer bound to exactly one frontend app. */
|
|
5
|
-
export interface DaoSchema extends SchemaBase {
|
|
6
|
-
type: 'dao';
|
|
7
|
-
/** The frontend app this DAO belongs to (shared instance from project.config). */
|
|
8
|
-
app: FrontAppSchema;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function defineDao(name: string, app: FrontAppSchema, description?: string): DaoSchema {
|
|
12
|
-
return { name, type: 'dao', app, description };
|
|
13
|
-
}
|
|
1
|
+
import { SchemaBase } from './dsl.js';
|
|
2
|
+
import { FrontAppSchema } from './project.js';
|
|
3
|
+
|
|
4
|
+
/** A data-access layer bound to exactly one frontend app. */
|
|
5
|
+
export interface DaoSchema extends SchemaBase {
|
|
6
|
+
type: 'dao';
|
|
7
|
+
/** The frontend app this DAO belongs to (shared instance from project.config). */
|
|
8
|
+
app: FrontAppSchema;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function defineDao(name: string, app: FrontAppSchema, description?: string): DaoSchema {
|
|
12
|
+
return { name, type: 'dao', app, description };
|
|
13
|
+
}
|
package/src/db.ts
CHANGED
|
@@ -1,182 +1,182 @@
|
|
|
1
|
-
// DB table definitions.
|
|
2
|
-
// Shape: { type: 'table', <extension fields> }
|
|
3
|
-
|
|
4
|
-
import type { EntityPhrase } from './dictionary.js';
|
|
5
|
-
import type { CollectionSchemaBase, EnumDef, Field } from './dsl.js';
|
|
6
|
-
|
|
7
|
-
export type Index = {
|
|
8
|
-
name?: string;
|
|
9
|
-
columns: Field | Field[];
|
|
10
|
-
unique?: boolean;
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
export type ForeignKey = {
|
|
14
|
-
columns: Field | Field[];
|
|
15
|
-
references: Field | Field[];
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export interface TableSchemaOptions<
|
|
19
|
-
N extends string = string,
|
|
20
|
-
C extends Record<string, Field> = Record<string, Field>,
|
|
21
|
-
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
22
|
-
> {
|
|
23
|
-
description?: string;
|
|
24
|
-
paginated: boolean;
|
|
25
|
-
actor?: boolean;
|
|
26
|
-
generator?: string;
|
|
27
|
-
autoIncrement?: Field;
|
|
28
|
-
/** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
|
|
29
|
-
label?: Field;
|
|
30
|
-
primaryKey?: Field | Field[];
|
|
31
|
-
indexes?: Index[];
|
|
32
|
-
foreignKeys?: Record<string, ForeignKey>;
|
|
33
|
-
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
34
|
-
phrase?: EntityPhrase;
|
|
35
|
-
/** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
|
|
36
|
-
enums: E;
|
|
37
|
-
columns: C;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export class TableSchema<
|
|
41
|
-
N extends string = string,
|
|
42
|
-
C extends Record<string, Field> = Record<string, Field>,
|
|
43
|
-
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
44
|
-
> implements CollectionSchemaBase {
|
|
45
|
-
type = 'table';
|
|
46
|
-
name: N;
|
|
47
|
-
description?: string;
|
|
48
|
-
/** 分页 */
|
|
49
|
-
paginated: boolean;
|
|
50
|
-
/** 系统操作者(如小程序为 C 端用户,管理端为运营) */
|
|
51
|
-
actor?: boolean;
|
|
52
|
-
/** id 生成器 */
|
|
53
|
-
generator?: string;
|
|
54
|
-
/** 自增主键字段 */
|
|
55
|
-
autoIncrement?: Field;
|
|
56
|
-
primaryKey?: Field | Field[];
|
|
57
|
-
indexes?: Index[];
|
|
58
|
-
/** 外键,引用其他表的字段 */
|
|
59
|
-
foreignKeys?: Record<string, ForeignKey>;
|
|
60
|
-
/** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
|
|
61
|
-
label?: Field;
|
|
62
|
-
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
63
|
-
phrase?: EntityPhrase;
|
|
64
|
-
/** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
|
|
65
|
-
enums: E;
|
|
66
|
-
columns: C;
|
|
67
|
-
|
|
68
|
-
constructor(name: N, options: TableSchemaOptions<N, C, E>) {
|
|
69
|
-
if (!options.enums) {
|
|
70
|
-
throw new Error(`table '${name}': enums is required — declare enums: {} when the table has no enums`);
|
|
71
|
-
}
|
|
72
|
-
if (options.paginated === undefined || options.paginated === null) {
|
|
73
|
-
throw new Error(`table '${name}': paginated is required — discuss with user whether this table needs pagination, then set paginated: true or paginated: false`);
|
|
74
|
-
}
|
|
75
|
-
this.name = name;
|
|
76
|
-
this.description = options.description;
|
|
77
|
-
this.paginated = options.paginated;
|
|
78
|
-
this.actor = options.actor;
|
|
79
|
-
this.generator = options.generator;
|
|
80
|
-
this.autoIncrement = options.autoIncrement;
|
|
81
|
-
this.primaryKey = options.primaryKey;
|
|
82
|
-
this.indexes = options.indexes;
|
|
83
|
-
this.foreignKeys = options.foreignKeys;
|
|
84
|
-
this.label = options.label;
|
|
85
|
-
this.phrase = options.phrase;
|
|
86
|
-
this.enums = options.enums;
|
|
87
|
-
this.columns = options.columns;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** True when the field is part of this table's primary key */
|
|
91
|
-
isPk(fieldRef: Field): boolean {
|
|
92
|
-
if (this.primaryKey === undefined) return false;
|
|
93
|
-
return Array.isArray(this.primaryKey)
|
|
94
|
-
? this.primaryKey.includes(fieldRef)
|
|
95
|
-
: this.primaryKey === fieldRef;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
export function defineTable<
|
|
100
|
-
N extends string,
|
|
101
|
-
C extends Record<string, Field>,
|
|
102
|
-
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
103
|
-
>(
|
|
104
|
-
name: N,
|
|
105
|
-
schema: TableSchemaOptions<N, C, E>,
|
|
106
|
-
): TableSchema<N, C, E> {
|
|
107
|
-
const table = new TableSchema<N, C, E>(name, schema);
|
|
108
|
-
if (table.generator && table.autoIncrement) {
|
|
109
|
-
throw new Error(`table '${name}': generator and autoIncrement are mutually exclusive`);
|
|
110
|
-
}
|
|
111
|
-
if (table.label && !Object.values(table.columns).includes(table.label)) {
|
|
112
|
-
throw new Error(`table '${name}': label field '${table.label.name}' must be one of the table's columns`);
|
|
113
|
-
}
|
|
114
|
-
for (const key of Object.keys(table.columns)) {
|
|
115
|
-
const field = table.columns[key] as Field;
|
|
116
|
-
if (field.schema && field.schema !== table) {
|
|
117
|
-
throw new Error(
|
|
118
|
-
`field ${key}: belongs to table ${field.schema.name}, cannot reuse in table ${table.name}`,
|
|
119
|
-
);
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
for (const key of Object.keys(table.columns)) {
|
|
123
|
-
table.columns[key].name = key;
|
|
124
|
-
table.columns[key].schema = table;
|
|
125
|
-
}
|
|
126
|
-
if (table.primaryKey) {
|
|
127
|
-
const pkFields = Array.isArray(table.primaryKey) ? table.primaryKey : [table.primaryKey];
|
|
128
|
-
const colRefs = Object.values(table.columns);
|
|
129
|
-
for (const pk of pkFields) {
|
|
130
|
-
if (!colRefs.includes(pk)) {
|
|
131
|
-
throw new Error(
|
|
132
|
-
`table '${name}': primaryKey field '${pk.name}' must be the exact same object as the corresponding column (extract it to a const and reuse)`,
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
for (const [fkName, fk] of Object.entries(table.foreignKeys ?? {})) {
|
|
138
|
-
const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
|
|
139
|
-
const fields = Array.isArray(fk.columns) ? fk.columns : [fk.columns];
|
|
140
|
-
const colRefs = Object.values(table.columns);
|
|
141
|
-
for (const fkField of fields) {
|
|
142
|
-
if (!colRefs.includes(fkField)) {
|
|
143
|
-
throw new Error(
|
|
144
|
-
`foreign key ${fkName}: column '${fkField.name}' must be the exact same object as the corresponding column in the table (extract it to a const and reuse)`,
|
|
145
|
-
);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
for (let i = 0; i < refs.length; i++) {
|
|
149
|
-
const ref = refs[i];
|
|
150
|
-
if (!ref.schema) throw new Error(`foreign key ${fkName}: references field has no schema`);
|
|
151
|
-
if (ref.schema === table) throw new Error(`foreign key ${fkName}: cannot reference own table ${table.name}`);
|
|
152
|
-
const phrase = (ref.schema as TableSchema).phrase;
|
|
153
|
-
if (!phrase) throw new Error(`foreign key ${fkName}: referenced table ${ref.schema.name} has no phrase, cannot check field naming`);
|
|
154
|
-
const expected = `${phrase.name}_${ref.name}`;
|
|
155
|
-
const fkField = fields[i];
|
|
156
|
-
if (fkField.name !== expected) {
|
|
157
|
-
throw new Error(
|
|
158
|
-
`foreign key ${fkName}: field must be named ${expected} (phrase ${phrase.name} + ${ref.name}), got ${fkField.name}`,
|
|
159
|
-
);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
}
|
|
163
|
-
for (const [idxKey, index] of Object.entries(table.indexes ?? {})) {
|
|
164
|
-
const cols = Array.isArray(index.columns) ? index.columns : [index.columns];
|
|
165
|
-
const colRefs = Object.values(table.columns);
|
|
166
|
-
for (const col of cols) {
|
|
167
|
-
if (typeof col !== 'object' || col === null || typeof (col as Field).type !== 'string') {
|
|
168
|
-
const idxName = index.name ?? idxKey;
|
|
169
|
-
throw new Error(
|
|
170
|
-
`index ${idxName}: columns must be Field instances (stringField()/intField()/...), got ${JSON.stringify(col)}`,
|
|
171
|
-
);
|
|
172
|
-
}
|
|
173
|
-
if (!colRefs.includes(col)) {
|
|
174
|
-
const idxName = index.name ?? idxKey;
|
|
175
|
-
throw new Error(
|
|
176
|
-
`index ${idxName}: column '${col.name}' must be the exact same object as the corresponding column in the table (extract it to a const and reuse)`,
|
|
177
|
-
);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
return table;
|
|
1
|
+
// DB table definitions.
|
|
2
|
+
// Shape: { type: 'table', <extension fields> }
|
|
3
|
+
|
|
4
|
+
import type { EntityPhrase } from './dictionary.js';
|
|
5
|
+
import type { CollectionSchemaBase, EnumDef, Field } from './dsl.js';
|
|
6
|
+
|
|
7
|
+
export type Index = {
|
|
8
|
+
name?: string;
|
|
9
|
+
columns: Field | Field[];
|
|
10
|
+
unique?: boolean;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type ForeignKey = {
|
|
14
|
+
columns: Field | Field[];
|
|
15
|
+
references: Field | Field[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export interface TableSchemaOptions<
|
|
19
|
+
N extends string = string,
|
|
20
|
+
C extends Record<string, Field> = Record<string, Field>,
|
|
21
|
+
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
22
|
+
> {
|
|
23
|
+
description?: string;
|
|
24
|
+
paginated: boolean;
|
|
25
|
+
actor?: boolean;
|
|
26
|
+
generator?: string;
|
|
27
|
+
autoIncrement?: Field;
|
|
28
|
+
/** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
|
|
29
|
+
label?: Field;
|
|
30
|
+
primaryKey?: Field | Field[];
|
|
31
|
+
indexes?: Index[];
|
|
32
|
+
foreignKeys?: Record<string, ForeignKey>;
|
|
33
|
+
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
34
|
+
phrase?: EntityPhrase;
|
|
35
|
+
/** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
|
|
36
|
+
enums: E;
|
|
37
|
+
columns: C;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class TableSchema<
|
|
41
|
+
N extends string = string,
|
|
42
|
+
C extends Record<string, Field> = Record<string, Field>,
|
|
43
|
+
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
44
|
+
> implements CollectionSchemaBase {
|
|
45
|
+
type = 'table';
|
|
46
|
+
name: N;
|
|
47
|
+
description?: string;
|
|
48
|
+
/** 分页 */
|
|
49
|
+
paginated: boolean;
|
|
50
|
+
/** 系统操作者(如小程序为 C 端用户,管理端为运营) */
|
|
51
|
+
actor?: boolean;
|
|
52
|
+
/** id 生成器 */
|
|
53
|
+
generator?: string;
|
|
54
|
+
/** 自增主键字段 */
|
|
55
|
+
autoIncrement?: Field;
|
|
56
|
+
primaryKey?: Field | Field[];
|
|
57
|
+
indexes?: Index[];
|
|
58
|
+
/** 外键,引用其他表的字段 */
|
|
59
|
+
foreignKeys?: Record<string, ForeignKey>;
|
|
60
|
+
/** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */
|
|
61
|
+
label?: Field;
|
|
62
|
+
/** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */
|
|
63
|
+
phrase?: EntityPhrase;
|
|
64
|
+
/** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */
|
|
65
|
+
enums: E;
|
|
66
|
+
columns: C;
|
|
67
|
+
|
|
68
|
+
constructor(name: N, options: TableSchemaOptions<N, C, E>) {
|
|
69
|
+
if (!options.enums) {
|
|
70
|
+
throw new Error(`table '${name}': enums is required — declare enums: {} when the table has no enums`);
|
|
71
|
+
}
|
|
72
|
+
if (options.paginated === undefined || options.paginated === null) {
|
|
73
|
+
throw new Error(`table '${name}': paginated is required — discuss with user whether this table needs pagination, then set paginated: true or paginated: false`);
|
|
74
|
+
}
|
|
75
|
+
this.name = name;
|
|
76
|
+
this.description = options.description;
|
|
77
|
+
this.paginated = options.paginated;
|
|
78
|
+
this.actor = options.actor;
|
|
79
|
+
this.generator = options.generator;
|
|
80
|
+
this.autoIncrement = options.autoIncrement;
|
|
81
|
+
this.primaryKey = options.primaryKey;
|
|
82
|
+
this.indexes = options.indexes;
|
|
83
|
+
this.foreignKeys = options.foreignKeys;
|
|
84
|
+
this.label = options.label;
|
|
85
|
+
this.phrase = options.phrase;
|
|
86
|
+
this.enums = options.enums;
|
|
87
|
+
this.columns = options.columns;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** True when the field is part of this table's primary key */
|
|
91
|
+
isPk(fieldRef: Field): boolean {
|
|
92
|
+
if (this.primaryKey === undefined) return false;
|
|
93
|
+
return Array.isArray(this.primaryKey)
|
|
94
|
+
? this.primaryKey.includes(fieldRef)
|
|
95
|
+
: this.primaryKey === fieldRef;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function defineTable<
|
|
100
|
+
N extends string,
|
|
101
|
+
C extends Record<string, Field>,
|
|
102
|
+
E extends Record<string, EnumDef> = Record<string, EnumDef>
|
|
103
|
+
>(
|
|
104
|
+
name: N,
|
|
105
|
+
schema: TableSchemaOptions<N, C, E>,
|
|
106
|
+
): TableSchema<N, C, E> {
|
|
107
|
+
const table = new TableSchema<N, C, E>(name, schema);
|
|
108
|
+
if (table.generator && table.autoIncrement) {
|
|
109
|
+
throw new Error(`table '${name}': generator and autoIncrement are mutually exclusive`);
|
|
110
|
+
}
|
|
111
|
+
if (table.label && !Object.values(table.columns).includes(table.label)) {
|
|
112
|
+
throw new Error(`table '${name}': label field '${table.label.name}' must be one of the table's columns`);
|
|
113
|
+
}
|
|
114
|
+
for (const key of Object.keys(table.columns)) {
|
|
115
|
+
const field = table.columns[key] as Field;
|
|
116
|
+
if (field.schema && field.schema !== table) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`field ${key}: belongs to table ${field.schema.name}, cannot reuse in table ${table.name}`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const key of Object.keys(table.columns)) {
|
|
123
|
+
table.columns[key].name = key;
|
|
124
|
+
table.columns[key].schema = table;
|
|
125
|
+
}
|
|
126
|
+
if (table.primaryKey) {
|
|
127
|
+
const pkFields = Array.isArray(table.primaryKey) ? table.primaryKey : [table.primaryKey];
|
|
128
|
+
const colRefs = Object.values(table.columns);
|
|
129
|
+
for (const pk of pkFields) {
|
|
130
|
+
if (!colRefs.includes(pk)) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`table '${name}': primaryKey field '${pk.name}' must be the exact same object as the corresponding column (extract it to a const and reuse)`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
for (const [fkName, fk] of Object.entries(table.foreignKeys ?? {})) {
|
|
138
|
+
const refs = Array.isArray(fk.references) ? fk.references : [fk.references];
|
|
139
|
+
const fields = Array.isArray(fk.columns) ? fk.columns : [fk.columns];
|
|
140
|
+
const colRefs = Object.values(table.columns);
|
|
141
|
+
for (const fkField of fields) {
|
|
142
|
+
if (!colRefs.includes(fkField)) {
|
|
143
|
+
throw new Error(
|
|
144
|
+
`foreign key ${fkName}: column '${fkField.name}' must be the exact same object as the corresponding column in the table (extract it to a const and reuse)`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (let i = 0; i < refs.length; i++) {
|
|
149
|
+
const ref = refs[i];
|
|
150
|
+
if (!ref.schema) throw new Error(`foreign key ${fkName}: references field has no schema`);
|
|
151
|
+
if (ref.schema === table) throw new Error(`foreign key ${fkName}: cannot reference own table ${table.name}`);
|
|
152
|
+
const phrase = (ref.schema as TableSchema).phrase;
|
|
153
|
+
if (!phrase) throw new Error(`foreign key ${fkName}: referenced table ${ref.schema.name} has no phrase, cannot check field naming`);
|
|
154
|
+
const expected = `${phrase.name}_${ref.name}`;
|
|
155
|
+
const fkField = fields[i];
|
|
156
|
+
if (fkField.name !== expected) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`foreign key ${fkName}: field must be named ${expected} (phrase ${phrase.name} + ${ref.name}), got ${fkField.name}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
for (const [idxKey, index] of Object.entries(table.indexes ?? {})) {
|
|
164
|
+
const cols = Array.isArray(index.columns) ? index.columns : [index.columns];
|
|
165
|
+
const colRefs = Object.values(table.columns);
|
|
166
|
+
for (const col of cols) {
|
|
167
|
+
if (typeof col !== 'object' || col === null || typeof (col as Field).type !== 'string') {
|
|
168
|
+
const idxName = index.name ?? idxKey;
|
|
169
|
+
throw new Error(
|
|
170
|
+
`index ${idxName}: columns must be Field instances (stringField()/intField()/...), got ${JSON.stringify(col)}`,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
if (!colRefs.includes(col)) {
|
|
174
|
+
const idxName = index.name ?? idxKey;
|
|
175
|
+
throw new Error(
|
|
176
|
+
`index ${idxName}: column '${col.name}' must be the exact same object as the corresponding column in the table (extract it to a const and reuse)`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return table;
|
|
182
182
|
}
|
package/src/dsl.ts
CHANGED
|
@@ -71,6 +71,24 @@ interface DecimalField extends BaseField {
|
|
|
71
71
|
// Value is transported as string to avoid binary float error.
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
type RateUnit = 'pct' | 'pm' | 'bp';
|
|
75
|
+
|
|
76
|
+
export function rateScale(unit: RateUnit): number {
|
|
77
|
+
switch (unit) {
|
|
78
|
+
case 'pct': return 2;
|
|
79
|
+
case 'pm': return 3;
|
|
80
|
+
case 'bp': return 4;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RateField extends BaseField {
|
|
85
|
+
type: 'rate';
|
|
86
|
+
jsType: 'string';
|
|
87
|
+
unit: RateUnit;
|
|
88
|
+
// Precision/scale are derived from unit at DDL/TypeBox render time,
|
|
89
|
+
// not stored on the field.
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
interface BooleanField extends BaseField {
|
|
75
93
|
type: 'boolean';
|
|
76
94
|
jsType: 'boolean';
|
|
@@ -131,6 +149,7 @@ export type Field =
|
|
|
131
149
|
| IntField
|
|
132
150
|
| BigintField
|
|
133
151
|
| DecimalField
|
|
152
|
+
| RateField
|
|
134
153
|
| BooleanField
|
|
135
154
|
| DateField
|
|
136
155
|
| TimeField
|
|
@@ -163,6 +182,10 @@ export function decimalField(extra: FieldExtras<DecimalField>): DecimalField {
|
|
|
163
182
|
return { name: '', type: 'decimal', jsType: 'string', ...extra };
|
|
164
183
|
}
|
|
165
184
|
|
|
185
|
+
export function rateField(unit: RateUnit, extra: Omit<FieldExtras<RateField>, 'unit'> = {}): RateField {
|
|
186
|
+
return { name: '', type: 'rate', jsType: 'string', unit, ...extra };
|
|
187
|
+
}
|
|
188
|
+
|
|
166
189
|
export function booleanField(extra: FieldExtras<BooleanField> = {}): BooleanField {
|
|
167
190
|
return { name: '', type: 'boolean', jsType: 'boolean', ...extra };
|
|
168
191
|
}
|