1477 lines
57 KiB
Python
1477 lines
57 KiB
Python
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"}
|