UI②:数据对象全用中文名 —— label 随物化落 PG 注释,全链读回

此前 label 在建表那一步就丢了,治理台只剩 project__intake_record 这种键给人看。
用表/列注释而非另建元数据表:名字与表同生共死,不会对不上。
设计台/运营台/对象详情/触发器下拉一律中文名在前、键在后。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
2026-07-28 02:33:09 +08:00
co-authored by Claude Fable 5
parent 188637cf26
commit ec0b78c550
12 changed files with 113 additions and 26 deletions
+7
View File
@@ -35,3 +35,10 @@
标识符过 `IDENT` 正则 + `quoteIdent`
影响半径: index/contract/port 未变 → 对外零影响
近变(2026-07-28):**人读名落库** —— 物化时把声明里的 `label` 写成 PG 的表/列注释,
`describe` 与 `listObjects` 一并读回(`MaterializedObject.label` /
`MaterializedColumn.label` / `listObjects()` 出参加 `label?`)。
此前 label 在建表那一步就丢了,治理台只剩 `project__intake_record` 这种键给人看。
用注释而非另建元数据表:名字与表同生共死,不会对不上。
`comment on` 是工具语句不吃占位符,字面量在 adapter 内转义单引号。
+2 -1
View File
@@ -91,7 +91,8 @@ export type ReleaseWriteResult = {
export type LedgerService = {
applyDeclarations(declarations: readonly ObjectDeclaration[]): Promise<SchemaApplyResult[]>;
describe(address: ObjectAddress): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectAddress[]>;
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectAddress & { label?: string | undefined }>>;
systemWrite(write: SystemWrite): Promise<void>;
draftWrite(write: DraftWrite): Promise<void>;
@@ -63,13 +63,21 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
const store: LedgerStore = {
async describe(ref) {
const table = tableNameOf(ref);
const cols = await runner.query<{ column_name: string; data_type: string }>(
`select column_name, data_type from information_schema.columns
where table_schema = $1 and table_name = $2 order by ordinal_position`,
// 列注释即人读名:与列定义同一次查询取回,不多跑一趟
const cols = await runner.query<{ column_name: string; data_type: string; label: string | null }>(
`select c.column_name, c.data_type,
col_description(($1 || '.' || quote_ident($2))::regclass, c.ordinal_position) as label
from information_schema.columns c
where c.table_schema = $1 and c.table_name = $2 order by c.ordinal_position`,
[LEDGER_SCHEMA, table],
);
if (cols.rows.length === 0) return undefined;
const tableLabel = await runner.query<{ label: string | null }>(
`select obj_description(($1 || '.' || quote_ident($2))::regclass) as label`,
[LEDGER_SCHEMA, table],
);
const pk = await runner.query<{ attname: string }>(
`select a.attname from pg_index i
join pg_attribute a on a.attrelid = i.indrelid and a.attnum = any(i.indkey)
@@ -77,30 +85,53 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
[`${LEDGER_SCHEMA}.${quoteIdent(table)}`],
);
const label = tableLabel.rows[0]?.label ?? undefined;
const materialized: MaterializedObject = {
domain: ref.domain,
object: ref.object,
columns: cols.rows.map((c) => ({ name: c.column_name, type: FROM_PG[c.data_type] ?? 'text' })),
...(label === null || label === undefined ? {} : { label }),
columns: cols.rows.map((c) => ({
name: c.column_name,
type: FROM_PG[c.data_type] ?? 'text',
...(c.label === null ? {} : { label: c.label }),
})),
primaryKey: pk.rows.map((p) => p.attname),
};
return materialized;
},
async listObjects() {
const res = await runner.query<{ table_name: string }>(
`select table_name from information_schema.tables
where table_schema = $1 and table_name not like '\\_\\_%' order by table_name`,
// 表注释即人读名 —— 与表名同一次查询取回,治理台不必逐个 describe 才知道叫什么
const res = await runner.query<{ table_name: string; label: string | null }>(
`select t.table_name,
obj_description(($1 || '.' || quote_ident(t.table_name))::regclass) as label
from information_schema.tables t
where t.table_schema = $1 and t.table_name not like '\\_\\_%' order by t.table_name`,
[LEDGER_SCHEMA],
);
return res.rows.flatMap((r) => {
const idx = r.table_name.indexOf('__');
if (idx <= 0) return [];
return [{ domain: r.table_name.slice(0, idx), object: r.table_name.slice(idx + 2) }];
return [
{
domain: r.table_name.slice(0, idx),
object: r.table_name.slice(idx + 2),
...(r.label === null ? {} : { label: r.label }),
},
];
});
},
async applyChanges(ref, changes) {
const table = `${LEDGER_SCHEMA}.${quoteIdent(tableNameOf(ref))}`;
/*
* 注释即人读名;没有 label 就不写,读回来是 null,呈现自然退回键名。
* `comment on` 是**工具语句**,不吃 $1 占位符 —— 只能拼字面量,故自己转义单引号。
*/
const comment = async (target: string, label: string | undefined) => {
if (label === undefined || label.trim() === '') return;
await runner.query(`comment on ${target} is '${label.replaceAll("'", "''")}'`);
};
for (const change of changes) {
switch (change.kind) {
case 'create-table': {
@@ -110,12 +141,22 @@ function buildStore(runner: Queryable, pool: Pool): LedgerStore {
? `, primary key (${change.object.primaryKey.map(quoteIdent).join(', ')})`
: '';
await runner.query(`create table if not exists ${table} (${cols.join(', ')}${pk})`);
/*
* 人读名落**表/列注释** —— 声明里带的 label 不能在建表这一步丢掉:
* 丢了,治理台就只剩 `project__intake_record` 这种键给人看。
* 用注释而不是另建元数据表:名字与表同生共死,不会对不上。
*/
await comment(`table ${table}`, change.object.label);
for (const f of change.object.fields) {
await comment(`column ${table}.${quoteIdent(f.name)}`, f.label);
}
break;
}
case 'add-column':
await runner.query(
`alter table ${table} add column if not exists ${quoteIdent(change.field.name)} ${PG_TYPE[change.field.type]}`,
);
await comment(`column ${table}.${quoteIdent(change.field.name)}`, change.field.label);
break;
case 'widen-column':
await runner.query(
@@ -185,11 +185,15 @@ export function mergeDeclarations(declarations: readonly ObjectDeclaration[]): M
export type MaterializedColumn = {
readonly name: string;
readonly type: LedgerFieldType;
/** 人读名 —— 落在 PG 的列注释上,建表/加列时随声明写下 */
readonly label?: string | undefined;
};
export type MaterializedObject = {
readonly domain: string;
readonly object: string;
/** 人读名 —— 落在 PG 的表注释上;没有就退回对象键 */
readonly label?: string | undefined;
readonly columns: readonly MaterializedColumn[];
readonly primaryKey: readonly string[];
};
+2 -1
View File
@@ -20,7 +20,8 @@ export type ObjectRef = { readonly domain: string; readonly object: string };
export type LedgerStore = {
/** 已物化对象的列与主键;未物化返回 undefined */
describe(ref: ObjectRef): Promise<MaterializedObject | undefined>;
listObjects(): Promise<ObjectRef[]>;
/** 出参带人读名(表注释);没登记就没有 label,呈现退回对象键 */
listObjects(): Promise<Array<ObjectRef & { label?: string | undefined }>>;
/** 执行「只增不破坏」的变更计划 */
applyChanges(ref: ObjectRef, changes: readonly SchemaChange[]): Promise<void>;