// DB table definitions. // Shape: { type: 'table', } import type { EntityPhrase } from './dictionary.js'; import type { CollectionSchemaBase, EnumDef, Field } from './dsl.js'; export type Index = { name?: string; columns: Field | Field[]; unique?: boolean; }; export type ForeignKey = { columns: Field | Field[]; references: Field | Field[]; }; export interface TableSchemaOptions< N extends string = string, C extends Record = Record, E extends Record = Record > { description?: string; paginated: boolean; actor?: boolean; generator?: string; autoIncrement?: Field; /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */ label?: Field; /** Optimistic lock version column: updates auto-manage `version = version + 1` * and `WHERE version = ?` (value taken from the row). Must be an integer column. */ version?: Field; primaryKey?: Field | Field[]; indexes?: Index[]; foreignKeys?: Record; /** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */ phrase?: EntityPhrase; /** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */ enums: E; columns: C; } export class TableSchema< N extends string = string, C extends Record = Record, E extends Record = Record > implements CollectionSchemaBase { type = 'table'; name: N; description?: string; /** 分页 */ paginated: boolean; /** 系统操作者(如小程序为 C 端用户,管理端为运营) */ actor?: boolean; /** id 生成器 */ generator?: string; /** 自增主键字段 */ autoIncrement?: Field; primaryKey?: Field | Field[]; indexes?: Index[]; /** 外键,引用其他表的字段 */ foreignKeys?: Record; /** 本表用于关联显示的名称字段(如 name / username)。被外键引用时,自动用该字段做 label 展示。 */ label?: Field; /** Optimistic lock version column: updates auto-manage `version = version + 1` * and `WHERE version = ?` (value taken from the row). Must be an integer column. */ version?: Field; /** 引用的实体短语(词典条目):本表归属的实体;关联表等多实体场景不需要 */ phrase?: EntityPhrase; /** 本表引用的所有枚举定义(map,key 为枚举标识),显式声明供 gen-enums 收集 */ enums: E; columns: C; constructor(name: N, options: TableSchemaOptions) { if (!options.enums) { throw new Error(`table '${name}': enums is required — declare enums: {} when the table has no enums`); } if (options.paginated === undefined || options.paginated === null) { throw new Error(`table '${name}': paginated is required — discuss with user whether this table needs pagination, then set paginated: true or paginated: false`); } this.name = name; this.description = options.description; this.paginated = options.paginated; this.actor = options.actor; this.generator = options.generator; this.autoIncrement = options.autoIncrement; this.primaryKey = options.primaryKey; this.indexes = options.indexes; this.foreignKeys = options.foreignKeys; this.label = options.label; this.version = options.version; this.phrase = options.phrase; this.enums = options.enums; this.columns = options.columns; } /** True when the field is part of this table's primary key */ isPk(fieldRef: Field): boolean { if (this.primaryKey === undefined) return false; return Array.isArray(this.primaryKey) ? this.primaryKey.includes(fieldRef) : this.primaryKey === fieldRef; } } export function defineTable< N extends string, C extends Record, E extends Record = Record >( name: N, schema: TableSchemaOptions, ): TableSchema { const table = new TableSchema(name, schema); if (table.generator && table.autoIncrement) { throw new Error(`table '${name}': generator and autoIncrement are mutually exclusive`); } if (table.label && !Object.values(table.columns).includes(table.label)) { throw new Error(`table '${name}': label field '${table.label.name}' must be one of the table's columns`); } if (table.version && !Object.values(table.columns).includes(table.version)) { throw new Error(`table '${name}': version field '${table.version.name}' must be one of the table's columns`); } if (table.version && table.version.jsType !== 'number') { throw new Error(`table '${name}': version field '${table.version.name}' must be an integer column`); } for (const key of Object.keys(table.columns)) { const field = table.columns[key] as Field; if (field.schema && field.schema !== table) { throw new Error( `field ${key}: belongs to table ${field.schema.name}, cannot reuse in table ${table.name}`, ); } if (field.type === 'array' || field.type === 'object') { throw new Error( `table '${name}': column '${key}' cannot be a nested ${field.type} field — wire-format nesting is not a table column`, ); } } for (const key of Object.keys(table.columns)) { table.columns[key].name = key; table.columns[key].schema = table; } if (table.primaryKey) { const pkFields = Array.isArray(table.primaryKey) ? table.primaryKey : [table.primaryKey]; const colRefs = Object.values(table.columns); for (const pk of pkFields) { if (!colRefs.includes(pk)) { throw new Error( `table '${name}': primaryKey field '${pk.name}' must be the exact same object as the corresponding column (extract it to a const and reuse)`, ); } } } for (const [fkName, fk] of Object.entries(table.foreignKeys ?? {})) { const refs = Array.isArray(fk.references) ? fk.references : [fk.references]; const fields = Array.isArray(fk.columns) ? fk.columns : [fk.columns]; const colRefs = Object.values(table.columns); for (const fkField of fields) { if (!colRefs.includes(fkField)) { throw new Error( `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)`, ); } } for (let i = 0; i < refs.length; i++) { const ref = refs[i]; if (!ref.schema) throw new Error(`foreign key ${fkName}: references field has no schema`); if (ref.schema === table) throw new Error(`foreign key ${fkName}: cannot reference own table ${table.name}`); const phrase = (ref.schema as TableSchema).phrase; if (!phrase) throw new Error(`foreign key ${fkName}: referenced table ${ref.schema.name} has no phrase, cannot check field naming`); const expected = `${phrase.name}_${ref.name}`; const fkField = fields[i]; if (fkField.name !== expected) { throw new Error( `foreign key ${fkName}: field must be named ${expected} (phrase ${phrase.name} + ${ref.name}), got ${fkField.name}`, ); } } } for (const [idxKey, index] of Object.entries(table.indexes ?? {})) { const cols = Array.isArray(index.columns) ? index.columns : [index.columns]; const colRefs = Object.values(table.columns); for (const col of cols) { if (typeof col !== 'object' || col === null || typeof (col as Field).type !== 'string') { const idxName = index.name ?? idxKey; throw new Error( `index ${idxName}: columns must be Field instances (stringField()/intField()/...), got ${JSON.stringify(col)}`, ); } if (!colRefs.includes(col)) { const idxName = index.name ?? idxKey; throw new Error( `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)`, ); } } } return table; }