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"}