Files
Cloud-Tour-to-Libo/app/data_platform/schema.py
T

1477 lines
57 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<table>"?[A-Za-z][A-Za-z0-9_]*"?)
\s*\((?P<columns>.*)\)\s*$
""",
re.IGNORECASE | re.DOTALL | re.VERBOSE,
)
SQL_TYPE_PATTERN = re.compile(
r"""
^(?P<type>
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<constraints>.*)$
""",
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"}