from __future__ import annotations import hashlib import re from typing import Any from psycopg import Error as PsycopgError, sql from psycopg.types.json import Jsonb from app.config import settings from app.db import get_conn from app.data_platform.registry import ( TABLE_DEFINITIONS, TABLE_REGISTRY, FieldDefinition, TableDefinition, ) CUSTOM_FIELD_TYPES: dict[str, tuple[str, str]] = { "text": ("TEXT", "text"), "long_text": ("TEXT", "long_text"), "integer": ("INTEGER", "number"), "number": ("NUMERIC(18,4)", "number"), "boolean": ("BOOLEAN", "boolean"), "date": ("DATE", "date"), "datetime": ("TIMESTAMPTZ", "datetime"), "json": ("JSONB", "json"), "url": ("TEXT", "url"), } SYSTEM_COLUMN_CODES = { "id", "tenant_id", "project_id", "created_at", "updated_at", "deleted_at", "deleted_by", } IDENTIFIER_PATTERN = re.compile(r"^[a-z][a-z0-9_]{1,62}$") CREATE_TABLE_PATTERN = re.compile( r""" ^\s*CREATE\s+TABLE\s+ (?:IF\s+NOT\s+EXISTS\s+)? (?P"?[A-Za-z][A-Za-z0-9_]*"?) \s*\((?P.*)\)\s*$ """, re.IGNORECASE | re.DOTALL | re.VERBOSE, ) SQL_TYPE_PATTERN = re.compile( r""" ^(?P character\s+varying(?:\s*\(\s*\d+\s*\))? |varchar(?:\s*\(\s*\d+\s*\))? |character(?:\s*\(\s*\d+\s*\))? |char(?:\s*\(\s*\d+\s*\))? |text |smallint|int2|integer|int|int4|bigint|int8|smallserial|serial|bigserial |numeric(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))? |decimal(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))? |real|float4|double\s+precision|float8 |boolean|bool |date |timestamp(?:\s*\(\s*\d+\s*\))?(?:\s+(?:with|without)\s+time\s+zone)? |timestamptz(?:\s*\(\s*\d+\s*\))? |jsonb|json |uuid )(?=\s|$)(?P.*)$ """, re.IGNORECASE | re.DOTALL | re.VERBOSE, ) SAFE_DEFAULT_PATTERN = re.compile( r""" ^(?: NULL|TRUE|FALSE|CURRENT_TIMESTAMP|CURRENT_DATE|NOW\(\)|GEN_RANDOM_UUID\(\) |[-+]?(?:\d+(?:\.\d*)?|\.\d+) |'(?:[^']|'')*'(?:\s*::\s*(?:text|varchar|json|jsonb|uuid|date|timestamptz))? )$ """, re.IGNORECASE | re.VERBOSE, ) def _slug(value: str) -> str: normalized = re.sub(r"[^a-z0-9]+", "_", value.lower()).strip("_") return normalized[:42] or "project" def project_schema_name(project_id: str) -> str: digest = hashlib.sha1(project_id.encode("utf-8")).hexdigest()[:6] return f"biz_{_slug(project_id)}_{digest}" def project_database_name(project_id: str) -> str: return f"{_slug(project_id)}_db" def _column_sql(table: TableDefinition) -> sql.Composed: columns: list[sql.Composable] = [ sql.SQL("id UUID PRIMARY KEY"), sql.SQL("tenant_id TEXT NOT NULL"), sql.SQL("project_id TEXT NOT NULL"), ] for item in table.fields: parts: list[sql.Composable] = [ sql.Identifier(item.code), sql.SQL(item.sql_type), ] if item.required: parts.append(sql.SQL("NOT NULL")) if item.unique: parts.append(sql.SQL("UNIQUE")) if item.default_sql: parts.extend((sql.SQL("DEFAULT"), sql.SQL(item.default_sql))) columns.append(sql.SQL(" ").join(parts)) columns.extend( ( sql.SQL("created_at TIMESTAMPTZ NOT NULL DEFAULT now()"), sql.SQL("updated_at TIMESTAMPTZ NOT NULL DEFAULT now()"), sql.SQL("deleted_at TIMESTAMPTZ"), sql.SQL("deleted_by TEXT"), ) ) return sql.SQL(", ").join(columns) async def ensure_platform_registry() -> None: admin_schema = sql.Identifier(settings.db_schema) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( """ CREATE TABLE IF NOT EXISTS {}.project_databases ( project_id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, display_name TEXT NOT NULL, database_name TEXT NOT NULL UNIQUE, schema_name TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'ready', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ).format(admin_schema) ) await cur.execute( sql.SQL( """ CREATE TABLE IF NOT EXISTS {}.project_table_definitions ( project_id TEXT NOT NULL, table_code TEXT NOT NULL, label TEXT NOT NULL, group_name TEXT NOT NULL DEFAULT '自定义', description TEXT NOT NULL DEFAULT '', fields_jsonb JSONB NOT NULL DEFAULT '[]'::jsonb, allow_create BOOLEAN NOT NULL DEFAULT true, allow_update BOOLEAN NOT NULL DEFAULT true, allow_delete BOOLEAN NOT NULL DEFAULT true, status TEXT NOT NULL DEFAULT 'active', created_by TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (project_id, table_code) ) """ ).format(admin_schema) ) await cur.execute( sql.SQL( """ CREATE TABLE IF NOT EXISTS {}.project_table_overrides ( project_id TEXT NOT NULL, source_code TEXT NOT NULL, table_code TEXT NOT NULL, label TEXT NOT NULL, fields_jsonb JSONB, status TEXT NOT NULL DEFAULT 'active', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (project_id, source_code) ) """ ).format(admin_schema) ) await cur.execute( sql.SQL( "ALTER TABLE {}.project_table_overrides " "ADD COLUMN IF NOT EXISTS fields_jsonb JSONB" ).format(admin_schema) ) await conn.commit() def _custom_table_from_row(row: dict[str, Any]) -> TableDefinition: fields = _field_definitions_from_rows(row.get("fields_jsonb") or []) return TableDefinition( code=str(row["table_code"]), label=str(row["label"]), group=str(row.get("group_name") or "自定义"), description=str(row.get("description") or ""), fields=fields, allow_create=bool(row.get("allow_create", True)), allow_update=bool(row.get("allow_update", True)), allow_delete=bool(row.get("allow_delete", True)), ) def _field_definitions_from_rows(rows: list[dict[str, Any]]) -> tuple[FieldDefinition, ...]: return tuple( FieldDefinition( code=str(item["code"]), label=str(item["label"]), sql_type=str(item["sql_type"]), data_type=str(item.get("data_type") or "text"), required=bool(item.get("required")), searchable=bool(item.get("searchable")), sortable=bool(item.get("sortable", True)), editable=bool(item.get("editable", True)), visible_in_list=bool(item.get("visible_in_list", True)), default_sql=str(item["default_sql"]) if item.get("default_sql") else None, unique=bool(item.get("unique")), options=tuple(str(value) for value in item.get("options") or ()), ) for item in rows ) async def list_custom_table_definitions(project_id: str) -> tuple[TableDefinition, ...]: await ensure_platform_registry() async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( """ SELECT * FROM {}.project_table_definitions WHERE project_id=%s AND status='active' ORDER BY created_at, table_code """ ).format(sql.Identifier(settings.db_schema)), (project_id,), ) return tuple(_custom_table_from_row(dict(row)) for row in await cur.fetchall()) def _table_with_identity( table: TableDefinition, *, code: str, label: str, fields: tuple[FieldDefinition, ...] | None = None, ) -> TableDefinition: return TableDefinition( code=code, label=label, group=table.group, description=table.description, fields=fields if fields is not None else table.fields, allow_create=table.allow_create, allow_update=table.allow_update, allow_delete=table.allow_delete, ) async def _builtin_table_overrides(project_id: str) -> dict[str, dict[str, Any]]: await ensure_platform_registry() async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( """ SELECT * FROM {}.project_table_overrides WHERE project_id=%s """ ).format(sql.Identifier(settings.db_schema)), (project_id,), ) return { str(row["source_code"]): dict(row) for row in await cur.fetchall() } async def list_project_table_entries( project_id: str, ) -> tuple[tuple[TableDefinition, str, str], ...]: """Return effective definitions together with origin and stable source code.""" overrides = await _builtin_table_overrides(project_id) entries: list[tuple[TableDefinition, str, str]] = [] for table in TABLE_DEFINITIONS: override = overrides.get(table.code) if override and str(override["status"]) == "deleted": continue effective = ( _table_with_identity( table, code=str(override["table_code"]), label=str(override["label"]), fields=( _field_definitions_from_rows(override["fields_jsonb"]) if override.get("fields_jsonb") is not None else table.fields ), ) if override else table ) entries.append((effective, "builtin", table.code)) entries.extend( (table, "custom", table.code) for table in await list_custom_table_definitions(project_id) ) return tuple(entries) async def list_project_table_definitions( project_id: str, ) -> tuple[TableDefinition, ...]: return tuple( definition for definition, _origin, _source_code in await list_project_table_entries(project_id) ) async def resolve_project_table_entry( project_id: str, table_code: str, ) -> tuple[TableDefinition, str, str] | None: return next( ( entry for entry in await list_project_table_entries(project_id) if entry[0].code == table_code ), None, ) def _custom_definition_from_payload(body: dict[str, Any]) -> tuple[TableDefinition, list[dict[str, Any]]]: table_code = str(body.get("code") or "").strip() if not IDENTIFIER_PATTERN.fullmatch(table_code): raise ValueError("表名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位") if table_code in TABLE_REGISTRY or table_code in {"data_change_logs", "graph_sync_queue"}: raise ValueError("该表名已被系统占用") label = str(body.get("label") or "").strip() if not label: raise ValueError("请输入数据表中文名称") group = str(body.get("group") or "自定义").strip() or "自定义" raw_fields = body.get("fields") if not isinstance(raw_fields, list) or not raw_fields: raise ValueError("请至少定义一个业务字段") seen: set[str] = set() fields: list[FieldDefinition] = [] field_rows: list[dict[str, Any]] = [] for raw in raw_fields: if not isinstance(raw, dict): raise ValueError("字段定义格式错误") code = str(raw.get("code") or "").strip() if not IDENTIFIER_PATTERN.fullmatch(code): raise ValueError(f"字段名 {code or '(空)'} 格式错误") if code in SYSTEM_COLUMN_CODES: raise ValueError(f"字段名 {code} 是系统保留字段") if code in seen: raise ValueError(f"字段名 {code} 重复") seen.add(code) field_label = str(raw.get("label") or "").strip() if not field_label: raise ValueError(f"字段 {code} 缺少中文名称") type_key = str(raw.get("type_key") or "text").strip() if type_key not in CUSTOM_FIELD_TYPES: raise ValueError(f"字段 {field_label} 的类型不受支持") sql_type, data_type = CUSTOM_FIELD_TYPES[type_key] options = tuple( str(value).strip() for value in (raw.get("options") or []) if str(value).strip() ) definition = FieldDefinition( code=code, label=field_label, sql_type=sql_type, data_type=data_type, required=bool(raw.get("required")), searchable=bool(raw.get("searchable")), sortable=bool(raw.get("sortable", True)), editable=True, visible_in_list=bool(raw.get("visible_in_list", True)), unique=bool(raw.get("unique")), options=options, ) fields.append(definition) field_rows.append( { "code": definition.code, "label": definition.label, "sql_type": definition.sql_type, "data_type": definition.data_type, "required": definition.required, "searchable": definition.searchable, "sortable": definition.sortable, "editable": definition.editable, "visible_in_list": definition.visible_in_list, "unique": definition.unique, "options": list(definition.options), } ) return ( TableDefinition( code=table_code, label=label, group=group, description=str(body.get("description") or "").strip(), fields=tuple(fields), allow_create=bool(body.get("allow_create", True)), allow_update=bool(body.get("allow_update", True)), allow_delete=bool(body.get("allow_delete", True)), ), field_rows, ) def _normalize_sql_identifier(value: str, *, kind: str) -> str: identifier = value.strip() if identifier.startswith('"') and identifier.endswith('"'): identifier = identifier[1:-1] if '"' in identifier: raise ValueError(f"{kind}不支持转义引号") else: identifier = identifier.lower() if not IDENTIFIER_PATTERN.fullmatch(identifier): raise ValueError( f"{kind} {identifier or '(空)'} 格式错误:必须以小写字母开头," "只能包含小写字母、数字和下划线,长度为 2–63 位" ) return identifier def _split_sql_definition_items(value: str) -> list[str]: items: list[str] = [] start = 0 depth = 0 quote: str | None = None index = 0 while index < len(value): char = value[index] if quote: if char == quote: if index + 1 < len(value) and value[index + 1] == quote: index += 2 continue quote = None elif char in {"'", '"'}: quote = char elif char == "(": depth += 1 elif char == ")": depth -= 1 if depth < 0: raise ValueError("CREATE TABLE 字段括号不匹配") elif char == "," and depth == 0: item = value[start:index].strip() if not item: raise ValueError("CREATE TABLE 中存在空字段定义") items.append(item) start = index + 1 index += 1 if quote or depth != 0: raise ValueError("CREATE TABLE 中的引号或括号不匹配") item = value[start:].strip() if item: items.append(item) return items def _split_sql_column(item: str) -> tuple[str, str]: value = item.strip() if value.startswith('"'): end = value.find('"', 1) if end < 0: raise ValueError("字段名引号不完整") identifier = value[: end + 1] remainder = value[end + 1 :].strip() else: match = re.match(r"^([A-Za-z][A-Za-z0-9_]*)\s+(.+)$", value, re.DOTALL) if not match: raise ValueError(f"无法识别字段定义:{value[:80]}") identifier = match.group(1) remainder = match.group(2).strip() if not remainder: raise ValueError(f"字段 {identifier} 缺少数据类型") return _normalize_sql_identifier(identifier, kind="字段名"), remainder def _canonical_sql_type(raw_type: str, field_code: str) -> tuple[str, str]: value = re.sub(r"\s+", " ", raw_type.strip()).lower() sized = re.fullmatch( r"(character varying|varchar|character|char)\s*\(\s*(\d+)\s*\)", value, ) if sized: length = int(sized.group(2)) if not 1 <= length <= 10000: raise ValueError(f"字段 {field_code} 的字符长度必须在 1–10000 之间") base = "VARCHAR" if sized.group(1) in {"character varying", "varchar"} else "CHAR" data_type = "url" if field_code == "url" or field_code.endswith("_url") else "text" return f"{base}({length})", data_type if value in {"character varying", "varchar", "character", "char"}: data_type = "url" if field_code == "url" or field_code.endswith("_url") else "text" return ("VARCHAR" if value in {"character varying", "varchar"} else "CHAR"), data_type if value == "text": data_type = "url" if field_code == "url" or field_code.endswith("_url") else "long_text" return "TEXT", data_type integer_types = { "smallint": "SMALLINT", "int2": "SMALLINT", "integer": "INTEGER", "int": "INTEGER", "int4": "INTEGER", "bigint": "BIGINT", "int8": "BIGINT", "smallserial": "SMALLSERIAL", "serial": "SERIAL", "bigserial": "BIGSERIAL", } if value in integer_types: return integer_types[value], "number" numeric = re.fullmatch( r"(numeric|decimal)(?:\s*\(\s*(\d+)\s*(?:,\s*(\d+)\s*)?\))?", value, ) if numeric: precision = int(numeric.group(2)) if numeric.group(2) else None scale = int(numeric.group(3)) if numeric.group(3) else None if precision is not None and not 1 <= precision <= 1000: raise ValueError(f"字段 {field_code} 的数字精度必须在 1–1000 之间") if scale is not None and (scale < 0 or scale > (precision or 0)): raise ValueError(f"字段 {field_code} 的小数位数不能超过数字精度") if precision is None: return "NUMERIC", "number" if scale is None: return f"NUMERIC({precision})", "number" return f"NUMERIC({precision},{scale})", "number" if value in {"real", "float4"}: return "REAL", "number" if value in {"double precision", "float8"}: return "DOUBLE PRECISION", "number" if value in {"boolean", "bool"}: return "BOOLEAN", "boolean" if value == "date": return "DATE", "date" timestamp = re.fullmatch( r"(timestamp|timestamptz)(?:\s*\(\s*(\d+)\s*\))?" r"(?:\s+(with|without)\s+time\s+zone)?", value, ) if timestamp: precision = int(timestamp.group(2)) if timestamp.group(2) else None if precision is not None and not 0 <= precision <= 6: raise ValueError(f"字段 {field_code} 的时间精度必须在 0–6 之间") with_timezone = timestamp.group(1) == "timestamptz" or timestamp.group(3) == "with" base = "TIMESTAMPTZ" if with_timezone else "TIMESTAMP" return (f"{base}({precision})" if precision is not None else base), "datetime" if value in {"json", "jsonb"}: return "JSONB", "json" if value == "uuid": return "UUID", "text" raise ValueError(f"字段 {field_code} 的 SQL 类型 {raw_type} 暂不支持") def _parse_sql_column_constraints( value: str, field_code: str, ) -> tuple[bool, str | None, bool]: remaining = value.strip() required = False unique = False default_sql: str | None = None while remaining: not_null = re.match(r"^NOT\s+NULL(?:\s+|$)", remaining, re.IGNORECASE) if not_null: required = True remaining = remaining[not_null.end() :].strip() continue nullable = re.match(r"^NULL(?:\s+|$)", remaining, re.IGNORECASE) if nullable: remaining = remaining[nullable.end() :].strip() continue unique_constraint = re.match(r"^UNIQUE(?:\s+|$)", remaining, re.IGNORECASE) if unique_constraint: unique = True remaining = remaining[unique_constraint.end() :].strip() continue default = re.match(r"^DEFAULT(?:\s+|$)", remaining, re.IGNORECASE) if default: expression_and_rest = remaining[default.end() :].strip() expression_match = re.match( r"^(NULL|TRUE|FALSE|CURRENT_TIMESTAMP|CURRENT_DATE|NOW\(\)|" r"GEN_RANDOM_UUID\(\)|[-+]?(?:\d+(?:\.\d*)?|\.\d+)|" r"'(?:[^']|'')*'(?:\s*::\s*(?:text|varchar|json|jsonb|uuid|date|timestamptz))?)" r"(?=\s|$)", expression_and_rest, re.IGNORECASE, ) if not expression_match: raise ValueError( f"字段 {field_code} 的 DEFAULT 只支持文本、数字、布尔值、NULL、" "CURRENT_DATE、CURRENT_TIMESTAMP、now() 或 gen_random_uuid()" ) expression = expression_match.group(1) if not SAFE_DEFAULT_PATTERN.fullmatch(expression): raise ValueError(f"字段 {field_code} 的 DEFAULT 不安全") default_sql = expression remaining = expression_and_rest[expression_match.end() :].strip() continue raise ValueError( f"字段 {field_code} 含暂不支持的约束:{remaining[:80]}。" "目前支持 NOT NULL、NULL、UNIQUE 和安全 DEFAULT;" "MySQL AUTO_INCREMENT 与 ON UPDATE 仅用于平台托管的 id/updated_at 字段" ) return required, default_sql, unique def _field_row(definition: FieldDefinition) -> dict[str, Any]: return { "code": definition.code, "label": definition.label, "sql_type": definition.sql_type, "data_type": definition.data_type, "required": definition.required, "searchable": definition.searchable, "sortable": definition.sortable, "editable": definition.editable, "visible_in_list": definition.visible_in_list, "default_sql": definition.default_sql, "unique": definition.unique, "options": list(definition.options), } def _structure_field_from_payload( body: dict[str, Any], *, current: FieldDefinition | None = None, ) -> FieldDefinition: code = str(body.get("code") or (current.code if current else "")).strip() if not IDENTIFIER_PATTERN.fullmatch(code): raise ValueError("字段名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位") if code in SYSTEM_COLUMN_CODES: raise ValueError(f"字段名 {code} 是系统保留字段") label = str(body.get("label") or (current.label if current else "")).strip() if not label: raise ValueError("请输入字段名称") type_key = str(body.get("type_key") or "").strip() if type_key: if type_key not in CUSTOM_FIELD_TYPES: raise ValueError("字段类型不受支持") sql_type, data_type = CUSTOM_FIELD_TYPES[type_key] elif current: # A label/default-only edit must not silently change the physical precision # of VARCHAR/NUMERIC columns that do not exactly match the form presets. sql_type, data_type = current.sql_type, current.data_type else: sql_type, data_type = CUSTOM_FIELD_TYPES["text"] default_value = body.get("default_sql", current.default_sql if current else None) default_sql = str(default_value).strip() if default_value not in (None, "") else None if default_sql and not SAFE_DEFAULT_PATTERN.fullmatch(default_sql): raise ValueError( "默认值只支持文本、数字、布尔值、NULL、CURRENT_DATE、" "CURRENT_TIMESTAMP、now() 或 gen_random_uuid()" ) return FieldDefinition( code=code, label=label, sql_type=sql_type, data_type=data_type, required=bool(body.get("required", current.required if current else False)), searchable=bool(body.get("searchable", current.searchable if current else False)), sortable=bool(body.get("sortable", current.sortable if current else True)), editable=True, visible_in_list=bool( body.get("visible_in_list", current.visible_in_list if current else True) ), default_sql=default_sql, unique=bool(body.get("unique", current.unique if current else False)), options=current.options if current else (), ) async def _persist_effective_fields( cur: Any, *, project_id: str, current: TableDefinition, origin: str, source_code: str, fields: tuple[FieldDefinition, ...], ) -> None: admin_schema = sql.Identifier(settings.db_schema) field_rows = Jsonb([_field_row(field) for field in fields]) if origin == "custom": await cur.execute( sql.SQL( """ UPDATE {}.project_table_definitions SET fields_jsonb=%s, updated_at=now() WHERE project_id=%s AND table_code=%s AND status='active' """ ).format(admin_schema), (field_rows, project_id, current.code), ) if cur.rowcount != 1: raise ValueError("数据表字段元数据不存在") return await cur.execute( sql.SQL( """ INSERT INTO {}.project_table_overrides ( project_id, source_code, table_code, label, fields_jsonb, status, updated_at ) VALUES (%s, %s, %s, %s, %s, 'active', now()) ON CONFLICT (project_id, source_code) DO UPDATE SET table_code=EXCLUDED.table_code, label=EXCLUDED.label, fields_jsonb=EXCLUDED.fields_jsonb, status='active', updated_at=now() """ ).format(admin_schema), (project_id, source_code, current.code, current.label, field_rows), ) def _database_error_message(exc: PsycopgError) -> str: primary = getattr(getattr(exc, "diag", None), "message_primary", None) return f"PostgreSQL 拒绝结构修改:{primary or str(exc)}" async def create_table_column( project_id: str, table_code: str, body: dict[str, Any], ) -> TableDefinition: await ensure_platform_registry() database = await get_project_database(project_id) entry = await resolve_project_table_entry(project_id, table_code) if not database or not entry: raise ValueError("数据表不存在") current, origin, source_code = entry definition = _structure_field_from_payload(body) if any(field.code == definition.code for field in current.fields): raise ValueError("当前数据表已存在同名字段") if definition.required and not definition.default_sql: raise ValueError("新增非空字段必须设置默认值,避免现有记录无法补值") project_schema = sql.Identifier(str(database["schema_name"])) parts: list[sql.Composable] = [ sql.Identifier(definition.code), sql.SQL(definition.sql_type), ] if definition.default_sql: parts.extend((sql.SQL("DEFAULT"), sql.SQL(definition.default_sql))) if definition.required: parts.append(sql.SQL("NOT NULL")) next_fields = (*current.fields, definition) try: async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL("ALTER TABLE {}.{} ADD COLUMN {}").format( project_schema, sql.Identifier(current.code), sql.SQL(" ").join(parts), ) ) await cur.execute( sql.SQL("COMMENT ON COLUMN {}.{}.{} IS {}").format( project_schema, sql.Identifier(current.code), sql.Identifier(definition.code), sql.Literal(definition.label), ) ) await _persist_effective_fields( cur, project_id=project_id, current=current, origin=origin, source_code=source_code, fields=next_fields, ) await conn.commit() except PsycopgError as exc: raise ValueError(_database_error_message(exc)) from exc return _table_with_identity( current, code=current.code, label=current.label, fields=next_fields, ) async def update_table_column( project_id: str, table_code: str, column_code: str, body: dict[str, Any], ) -> TableDefinition: await ensure_platform_registry() if column_code in SYSTEM_COLUMN_CODES: raise ValueError("系统字段不可修改") database = await get_project_database(project_id) entry = await resolve_project_table_entry(project_id, table_code) if not database or not entry: raise ValueError("数据表不存在") current, origin, source_code = entry current_field = next((field for field in current.fields if field.code == column_code), None) if not current_field: raise ValueError("业务字段不存在或不可修改") definition = _structure_field_from_payload(body, current=current_field) if definition.code != column_code and any( field.code == definition.code for field in current.fields ): raise ValueError("当前数据表已存在同名字段") next_fields = tuple( definition if field.code == column_code else field for field in current.fields ) project_schema = sql.Identifier(str(database["schema_name"])) try: async with get_conn() as conn: async with conn.cursor() as cur: if current_field.sql_type != definition.sql_type: await cur.execute( sql.SQL("ALTER TABLE {}.{} ALTER COLUMN {} DROP DEFAULT").format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), ) ) await cur.execute( sql.SQL( "ALTER TABLE {}.{} ALTER COLUMN {} TYPE {} USING {}::{}" ).format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), sql.SQL(definition.sql_type), sql.Identifier(column_code), sql.SQL(definition.sql_type), ) ) if definition.default_sql: await cur.execute( sql.SQL( "ALTER TABLE {}.{} ALTER COLUMN {} SET DEFAULT {}" ).format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), sql.SQL(definition.default_sql), ) ) else: await cur.execute( sql.SQL("ALTER TABLE {}.{} ALTER COLUMN {} DROP DEFAULT").format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), ) ) await cur.execute( sql.SQL( "ALTER TABLE {}.{} ALTER COLUMN {} {} NOT NULL" ).format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), sql.SQL("SET" if definition.required else "DROP"), ) ) if definition.code != column_code: await cur.execute( sql.SQL("ALTER TABLE {}.{} RENAME COLUMN {} TO {}").format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), sql.Identifier(definition.code), ) ) await cur.execute( sql.SQL("COMMENT ON COLUMN {}.{}.{} IS {}").format( project_schema, sql.Identifier(current.code), sql.Identifier(definition.code), sql.Literal(definition.label), ) ) await _persist_effective_fields( cur, project_id=project_id, current=current, origin=origin, source_code=source_code, fields=next_fields, ) await conn.commit() except PsycopgError as exc: raise ValueError(_database_error_message(exc)) from exc return _table_with_identity( current, code=current.code, label=current.label, fields=next_fields, ) async def delete_table_column( project_id: str, table_code: str, column_code: str, confirm_name: str, ) -> TableDefinition: await ensure_platform_registry() if column_code in SYSTEM_COLUMN_CODES: raise ValueError("系统字段不可删除") if confirm_name.strip() != column_code: raise ValueError("确认字段名不正确,未执行删除") database = await get_project_database(project_id) entry = await resolve_project_table_entry(project_id, table_code) if not database or not entry: raise ValueError("数据表不存在") current, origin, source_code = entry if not any(field.code == column_code for field in current.fields): raise ValueError("业务字段不存在或不可删除") next_fields = tuple(field for field in current.fields if field.code != column_code) if not next_fields: raise ValueError("数据表至少需要保留一个业务字段") project_schema = sql.Identifier(str(database["schema_name"])) try: async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL("ALTER TABLE {}.{} DROP COLUMN {} RESTRICT").format( project_schema, sql.Identifier(current.code), sql.Identifier(column_code), ) ) await _persist_effective_fields( cur, project_id=project_id, current=current, origin=origin, source_code=source_code, fields=next_fields, ) await conn.commit() except PsycopgError as exc: raise ValueError(_database_error_message(exc)) from exc return _table_with_identity( current, code=current.code, label=current.label, fields=next_fields, ) def custom_definition_from_create_sql( body: dict[str, Any], ) -> tuple[TableDefinition, list[dict[str, Any]]]: statement = str(body.get("sql") or "").strip() if not statement: raise ValueError("请输入 CREATE TABLE SQL") if "--" in statement or "/*" in statement or "*/" in statement: raise ValueError("SQL 建表暂不支持注释,请删除 -- 或 /* */ 注释") if statement.endswith(";"): statement = statement[:-1].rstrip() if ";" in statement: raise ValueError("一次只能执行一条 CREATE TABLE,禁止多语句") match = CREATE_TABLE_PATTERN.fullmatch(statement) if not match: raise ValueError("仅支持 CREATE TABLE table_name (...) 格式") table_code = _normalize_sql_identifier(match.group("table"), kind="表名") if table_code in TABLE_REGISTRY or table_code in {"data_change_logs", "graph_sync_queue"}: raise ValueError("该表名已被系统占用") seen: set[str] = set() fields: list[FieldDefinition] = [] for item in _split_sql_definition_items(match.group("columns")): upper = item.lstrip().upper() if re.fullmatch(r'PRIMARY\s+KEY\s*\(\s*"?id"?\s*\)', item, re.IGNORECASE): continue if upper.startswith( ("CONSTRAINT ", "PRIMARY KEY", "UNIQUE ", "FOREIGN KEY", "CHECK ", "EXCLUDE ") ): raise ValueError("暂不支持表级约束;平台会自动创建 UUID 主键 id") code, remainder = _split_sql_column(item) if code in SYSTEM_COLUMN_CODES: continue if code in seen: raise ValueError(f"字段名 {code} 重复") seen.add(code) type_match = SQL_TYPE_PATTERN.fullmatch(remainder) if not type_match: raise ValueError(f"字段 {code} 的数据类型或定义无法识别") sql_type, data_type = _canonical_sql_type(type_match.group("type"), code) required, default_sql, unique = _parse_sql_column_constraints( type_match.group("constraints"), code, ) field_definition = FieldDefinition( code=code, label=code.replace("_", " "), sql_type=sql_type, data_type=data_type, required=required, searchable=data_type in {"text", "long_text", "url"}, sortable=data_type != "json", editable=True, visible_in_list=True, default_sql=default_sql, unique=unique, ) fields.append(field_definition) if not fields: raise ValueError("请至少定义一个非系统业务字段") definition = TableDefinition( code=table_code, label=str(body.get("label") or table_code).strip() or table_code, group=str(body.get("group") or "自定义").strip() or "自定义", description=str(body.get("description") or "").strip(), fields=tuple(fields), allow_create=bool(body.get("allow_create", True)), allow_update=bool(body.get("allow_update", True)), allow_delete=bool(body.get("allow_delete", True)), ) return definition, [_field_row(field) for field in definition.fields] async def _create_custom_table_definition( project_id: str, definition: TableDefinition, field_rows: list[dict[str, Any]], actor: str, ) -> TableDefinition: await ensure_platform_registry() database = await get_project_database(project_id) if not database: raise ValueError("关系数据库不存在或已收藏") existing_tables = await list_project_table_definitions(project_id) if any(item.code == definition.code for item in existing_tables): raise ValueError("当前项目中已存在同名数据表") admin_schema = sql.Identifier(settings.db_schema) project_schema = sql.Identifier(str(database["schema_name"])) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL("CREATE TABLE {}.{} ({})").format( project_schema, sql.Identifier(definition.code), _column_sql(definition), ) ) await cur.execute( sql.SQL("CREATE INDEX {} ON {}.{} (updated_at DESC)").format( sql.Identifier(f"{definition.code}_updated_idx"), project_schema, sql.Identifier(definition.code), ) ) await cur.execute( sql.SQL( "CREATE INDEX {} ON {}.{} (project_id) WHERE deleted_at IS NULL" ).format( sql.Identifier(f"{definition.code}_project_idx"), project_schema, sql.Identifier(definition.code), ) ) await cur.execute( sql.SQL( """ INSERT INTO {}.project_table_definitions ( project_id, table_code, label, group_name, description, fields_jsonb, allow_create, allow_update, allow_delete, created_by ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """ ).format(admin_schema), ( project_id, definition.code, definition.label, definition.group, definition.description, Jsonb(field_rows), definition.allow_create, definition.allow_update, definition.allow_delete, actor, ), ) await conn.commit() return definition async def create_custom_table( project_id: str, body: dict[str, Any], actor: str, ) -> TableDefinition: definition, field_rows = _custom_definition_from_payload(body) return await _create_custom_table_definition(project_id, definition, field_rows, actor) async def create_custom_table_from_sql( project_id: str, body: dict[str, Any], actor: str, ) -> TableDefinition: definition, field_rows = custom_definition_from_create_sql(body) return await _create_custom_table_definition(project_id, definition, field_rows, actor) async def ensure_project_database( project_id: str, tenant_id: str, display_name: str, ) -> dict[str, Any]: await ensure_platform_registry() schema_name = project_schema_name(project_id) database_name = project_database_name(project_id) admin_schema = sql.Identifier(settings.db_schema) project_schema = sql.Identifier(schema_name) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( "SELECT 1 FROM {}.project_databases WHERE project_id=%s" ).format(admin_schema), (project_id,), ) database_exists = await cur.fetchone() is not None await cur.execute( sql.SQL( """ INSERT INTO {}.project_databases ( project_id, tenant_id, display_name, database_name, schema_name, status, updated_at ) VALUES (%s, %s, %s, %s, %s, 'ready', now()) ON CONFLICT (project_id) DO UPDATE SET tenant_id=EXCLUDED.tenant_id, display_name=EXCLUDED.display_name, database_name=EXCLUDED.database_name, schema_name=EXCLUDED.schema_name, status='ready', updated_at=now() RETURNING * """ ).format(admin_schema), (project_id, tenant_id, display_name, database_name, schema_name), ) database_row = await cur.fetchone() await cur.execute(sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(project_schema)) if not database_exists: for table in TABLE_DEFINITIONS: await cur.execute( sql.SQL("CREATE TABLE IF NOT EXISTS {}.{} ({})").format( project_schema, sql.Identifier(table.code), _column_sql(table), ) ) await cur.execute( sql.SQL( "CREATE INDEX IF NOT EXISTS {} ON {}.{} (updated_at DESC)" ).format( sql.Identifier(f"{table.code}_updated_idx"), project_schema, sql.Identifier(table.code), ) ) await cur.execute( sql.SQL( "CREATE INDEX IF NOT EXISTS {} ON {}.{} (project_id) WHERE deleted_at IS NULL" ).format( sql.Identifier(f"{table.code}_project_idx"), project_schema, sql.Identifier(table.code), ) ) await cur.execute( sql.SQL( """ CREATE TABLE IF NOT EXISTS {}.data_change_logs ( id BIGSERIAL PRIMARY KEY, tenant_id TEXT NOT NULL, project_id TEXT NOT NULL, table_code TEXT NOT NULL, record_id UUID NOT NULL, operation TEXT NOT NULL, before_data JSONB, after_data JSONB, actor TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """ ).format(project_schema) ) await conn.commit() return dict(database_row) async def ensure_all_project_databases() -> None: """Deprecated: relational databases are no longer derived from graph projects.""" await ensure_platform_registry() async def get_project_database(project_id: str) -> dict[str, Any] | None: await ensure_platform_registry() async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( "SELECT * FROM {}.project_databases " "WHERE project_id=%s AND status='ready'" ).format(sql.Identifier(settings.db_schema)), (project_id,), ) row = await cur.fetchone() return dict(row) if row else None async def rename_project_database(project_id: str, display_name: str) -> dict[str, Any]: """Rename the logical database while keeping its stable code and schema.""" await ensure_platform_registry() next_name = display_name.strip() if not next_name: raise ValueError("请输入数据库名称") if len(next_name) > 100: raise ValueError("数据库名称不能超过 100 个字符") admin_schema = sql.Identifier(settings.db_schema) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL( """ UPDATE {}.project_databases SET display_name=%s, updated_at=now() WHERE project_id=%s AND status='ready' RETURNING * """ ).format(admin_schema), (next_name, project_id), ) row = await cur.fetchone() if not row: raise ValueError("关系数据库不存在或已收藏") await conn.commit() return dict(row) async def delete_project_database(project_id: str, confirm_name: str) -> dict[str, str]: """Delete one PostgreSQL data-center database without touching graph data.""" await ensure_platform_registry() database = await get_project_database(project_id) if not database: raise ValueError("关系数据库不存在或已收藏") expected_names = { str(database["display_name"]), str(database["project_id"]), str(database["database_name"]), } if confirm_name.strip() not in expected_names: raise ValueError("确认名称不正确,未执行删除") admin_schema = sql.Identifier(settings.db_schema) project_schema = sql.Identifier(str(database["schema_name"])) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute(sql.SQL("DROP SCHEMA {} CASCADE").format(project_schema)) await cur.execute( sql.SQL( "DELETE FROM {}.project_table_definitions WHERE project_id=%s" ).format(admin_schema), (project_id,), ) await cur.execute( sql.SQL( "DELETE FROM {}.project_table_overrides WHERE project_id=%s" ).format(admin_schema), (project_id,), ) await cur.execute( sql.SQL( "DELETE FROM {}.project_databases WHERE project_id=%s" ).format(admin_schema), (project_id,), ) await conn.commit() return {"project_id": project_id, "status": "deleted"} async def rename_custom_table( project_id: str, table_code: str, new_code: str, new_label: str, ) -> TableDefinition: """Rename any managed PostgreSQL table without touching graph data.""" await ensure_platform_registry() next_code = new_code.strip() next_label = new_label.strip() if not IDENTIFIER_PATTERN.fullmatch(next_code): raise ValueError("表名必须以小写字母开头,只能包含小写字母、数字和下划线,长度为 2–63 位") if next_code in {"data_change_logs", "graph_sync_queue"}: raise ValueError("该表名已被系统占用") if not next_label: raise ValueError("请输入数据表名称") database = await get_project_database(project_id) if not database: raise ValueError("关系数据库不存在或已收藏") current_entry = await resolve_project_table_entry(project_id, table_code) if not current_entry: raise ValueError("数据表不存在") current, origin, source_code = current_entry if next_code in TABLE_REGISTRY and next_code != source_code: raise ValueError("该表名已被系统占用") effective_tables = await list_project_table_definitions(project_id) if next_code != table_code and any(item.code == next_code for item in effective_tables): raise ValueError("当前数据库中已存在同名数据表") admin_schema = sql.Identifier(settings.db_schema) project_schema = sql.Identifier(str(database["schema_name"])) async with get_conn() as conn: async with conn.cursor() as cur: if next_code != table_code: await cur.execute( sql.SQL("ALTER TABLE {}.{} RENAME TO {}").format( project_schema, sql.Identifier(table_code), sql.Identifier(next_code), ) ) await cur.execute( sql.SQL("ALTER INDEX IF EXISTS {}.{} RENAME TO {}").format( project_schema, sql.Identifier(f"{table_code}_updated_idx"), sql.Identifier(f"{next_code}_updated_idx"), ) ) await cur.execute( sql.SQL("ALTER INDEX IF EXISTS {}.{} RENAME TO {}").format( project_schema, sql.Identifier(f"{table_code}_project_idx"), sql.Identifier(f"{next_code}_project_idx"), ) ) await cur.execute( sql.SQL( "UPDATE {}.data_change_logs SET table_code=%s WHERE table_code=%s" ).format(project_schema), (next_code, table_code), ) if origin == "custom": await cur.execute( sql.SQL( """ UPDATE {}.project_table_definitions SET table_code=%s, label=%s, updated_at=now() WHERE project_id=%s AND table_code=%s AND status='active' """ ).format(admin_schema), (next_code, next_label, project_id, table_code), ) if cur.rowcount != 1: raise ValueError("数据表不存在") else: await cur.execute( sql.SQL( """ INSERT INTO {}.project_table_overrides ( project_id, source_code, table_code, label, status, updated_at ) VALUES (%s, %s, %s, %s, 'active', now()) ON CONFLICT (project_id, source_code) DO UPDATE SET table_code=EXCLUDED.table_code, label=EXCLUDED.label, status='active', updated_at=now() """ ).format(admin_schema), (project_id, source_code, next_code, next_label), ) await conn.commit() return _table_with_identity( current, code=next_code, label=next_label, ) async def delete_custom_table( project_id: str, table_code: str, confirm_name: str, ) -> dict[str, str]: """Drop any managed PostgreSQL table without touching graph data.""" await ensure_platform_registry() database = await get_project_database(project_id) if not database: raise ValueError("关系数据库不存在或已收藏") current_entry = await resolve_project_table_entry(project_id, table_code) if not current_entry: raise ValueError("数据表不存在") current, origin, source_code = current_entry if confirm_name.strip() not in {table_code, current.label}: raise ValueError("确认名称不正确,未执行删除") admin_schema = sql.Identifier(settings.db_schema) project_schema = sql.Identifier(str(database["schema_name"])) async with get_conn() as conn: async with conn.cursor() as cur: await cur.execute( sql.SQL("DROP TABLE {}.{}").format( project_schema, sql.Identifier(table_code), ) ) if origin == "custom": await cur.execute( sql.SQL( """ DELETE FROM {}.project_table_definitions WHERE project_id=%s AND table_code=%s """ ).format(admin_schema), (project_id, table_code), ) else: await cur.execute( sql.SQL( """ INSERT INTO {}.project_table_overrides ( project_id, source_code, table_code, label, status, updated_at ) VALUES (%s, %s, %s, %s, 'deleted', now()) ON CONFLICT (project_id, source_code) DO UPDATE SET table_code=EXCLUDED.table_code, label=EXCLUDED.label, status='deleted', updated_at=now() """ ).format(admin_schema), (project_id, source_code, table_code, current.label), ) await cur.execute( sql.SQL( "DELETE FROM {}.data_change_logs WHERE table_code=%s" ).format(project_schema), (table_code,), ) await conn.commit() return {"project_id": project_id, "table_code": table_code, "status": "deleted"}