Files
Cloud-Tour-to-Libo/app/project_lifecycle.py

542 lines
20 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.

"""Validation and FalkorDB helpers for graph-project lifecycle operations.
This module deliberately has no dependency on ``app.data_platform``. Graph
projects and relational data-center databases are separate resources even when
they happen to use the same project id.
"""
from __future__ import annotations
import json
import re
from collections.abc import Mapping
from typing import Any
from falkordb import FalkorDB
from app.config import settings
SAFE_RESOURCE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$")
SAFE_SCHEMA_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,127}$")
SAFE_SCHEMA_VERSION = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
TYPE_ALIASES = {
"str": "string",
"text": "string",
"varchar": "string",
"int": "integer",
"long": "integer",
"float": "number",
"double": "number",
"decimal": "number",
"bool": "boolean",
"json": "object",
"dict": "object",
"list": "array",
"timestamp": "datetime",
}
SUPPORTED_FIELD_TYPES = {
"string",
"integer",
"number",
"boolean",
"object",
"array",
"date",
"datetime",
"any",
}
class ProjectValidationError(ValueError):
"""Raised when a provision payload is invalid before any write occurs."""
def __init__(self, errors: list[str]):
super().__init__(errors[0] if errors else "项目数据校验失败")
self.errors = errors
class GraphImportError(RuntimeError):
"""FalkorDB import failed, optionally leaving a graph needing cleanup."""
def __init__(self, message: str, *, cleanup_error: Exception | None = None):
super().__init__(message)
self.cleanup_error = cleanup_error
self.cleanup_required = cleanup_error is not None
def _as_mapping(value: Any) -> dict[str, Any]:
return dict(value) if isinstance(value, Mapping) else {}
def _normalize_named_mapping(
value: Any,
*,
kind: str,
name_keys: tuple[str, ...],
errors: list[str],
) -> dict[str, dict[str, Any]]:
if isinstance(value, Mapping):
result: dict[str, dict[str, Any]] = {}
for raw_name, raw_meta in value.items():
name = str(raw_name or "").strip()
if kind == "关系类型" and isinstance(raw_meta, (list, tuple)):
if len(raw_meta) < 2:
errors.append(f"关系类型 {name or '<未命名>'} 至少需要 from 和 to")
continue
raw_meta = {
"from": raw_meta[0],
"to": raw_meta[1],
"description": raw_meta[2] if len(raw_meta) > 2 else None,
"properties": {},
}
if not isinstance(raw_meta, Mapping):
errors.append(f"{kind} {name or '<未命名>'} 的定义必须是 JSON 对象")
continue
result[name] = dict(raw_meta)
return result
if isinstance(value, list):
result = {}
for index, raw_meta in enumerate(value):
if not isinstance(raw_meta, Mapping):
errors.append(f"{kind}列表第 {index + 1} 项必须是 JSON 对象")
continue
meta = dict(raw_meta)
name = next(
(str(meta.get(key) or "").strip() for key in name_keys if meta.get(key)),
"",
)
if not name:
errors.append(f"{kind}列表第 {index + 1} 项缺少名称")
continue
result[name] = meta
return result
errors.append(f"{kind}必须是 JSON 对象或数组")
return {}
def _normalize_field_definitions(
value: Any,
*,
path: str,
errors: list[str],
) -> dict[str, dict[str, Any]]:
if value is None:
return {}
if isinstance(value, Mapping):
raw_items = list(value.items())
elif isinstance(value, list):
raw_items = []
for index, item in enumerate(value):
if isinstance(item, str):
raw_items.append((item, {"type": "string"}))
elif isinstance(item, Mapping):
name = str(item.get("name") or item.get("field_name") or "").strip()
if not name:
errors.append(f"{path} 第 {index + 1} 个字段缺少名称")
continue
raw_items.append((name, item))
else:
errors.append(f"{path} 第 {index + 1} 个字段定义无效")
else:
errors.append(f"{path} 必须是 JSON 对象或数组")
return {}
normalized: dict[str, dict[str, Any]] = {}
for raw_name, raw_definition in raw_items:
name = str(raw_name or "").strip()
if not name:
errors.append(f"{path} 包含空字段名")
continue
if isinstance(raw_definition, str):
definition = {"type": raw_definition}
elif isinstance(raw_definition, Mapping):
definition = dict(raw_definition)
else:
errors.append(f"{path}.{name} 的字段定义必须是对象或类型字符串")
continue
raw_type = str(definition.get("type") or definition.get("value_type") or "string").lower().strip()
value_type = TYPE_ALIASES.get(raw_type, raw_type)
if value_type not in SUPPORTED_FIELD_TYPES:
errors.append(f"{path}.{name} 使用了不支持的类型 {raw_type!r}")
continue
definition["type"] = value_type
definition["required"] = bool(definition.get("required", False))
normalized[name] = definition
return normalized
def _matches_field_type(value: Any, expected: str) -> bool:
if expected == "any":
return True
if expected in {"string", "date", "datetime"}:
return isinstance(value, str)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "boolean":
return isinstance(value, bool)
if expected == "object":
return isinstance(value, Mapping)
if expected == "array":
return isinstance(value, list)
return False
def _endpoint_types(value: Any) -> list[str]:
return [part.strip() for part in str(value or "").split("|") if part.strip()]
def _validate_properties(
properties: dict[str, Any],
definitions: dict[str, dict[str, Any]],
*,
path: str,
errors: list[str],
) -> None:
for field_name, definition in definitions.items():
value_present = field_name in properties and properties[field_name] is not None
if definition.get("required") and not value_present:
errors.append(f"{path} 缺少必填属性 {field_name}")
continue
if not value_present:
continue
expected = str(definition.get("type") or "string")
if not _matches_field_type(properties[field_name], expected):
errors.append(f"{path}.{field_name} 应为 {expected} 类型")
def normalize_provision_payload(body: Any) -> dict[str, Any]:
"""Normalize and fully validate a project provision request.
The function is intentionally pure so validation can be completed before
PostgreSQL or FalkorDB is touched.
"""
if not isinstance(body, Mapping):
raise ProjectValidationError(["请求体必须是 JSON 对象"])
errors: list[str] = []
project_id = str(body.get("project_id") or "").strip()
explicit_tenant_id = str(body.get("tenant_id") or "").strip()
explicit_graph_name = str(body.get("graph_name") or "").strip()
tenant_id = explicit_tenant_id or project_id
display_name = str(body.get("display_name") or "").strip()
graph_name = explicit_graph_name or project_id
if not project_id:
errors.append("项目英文标识不能为空")
elif not SAFE_RESOURCE_ID.fullmatch(project_id):
errors.append("项目英文标识只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头")
if explicit_tenant_id and not SAFE_RESOURCE_ID.fullmatch(explicit_tenant_id):
errors.append("项目隔离标识只能包含安全字符")
if not display_name:
errors.append("display_name 不能为空")
if explicit_graph_name and not SAFE_RESOURCE_ID.fullmatch(explicit_graph_name):
errors.append("图谱资源标识只能包含字母、数字、点、下划线和连字符,且必须以字母或数字开头")
raw_schema = body.get("schema")
if not isinstance(raw_schema, Mapping):
errors.append("schema 必须是 JSON 对象")
raw_schema = {}
schema = dict(raw_schema)
namespace = str(schema.get("namespace") or project_id).strip()
version = str(schema.get("version") or "").strip()
if not namespace:
errors.append("schema.namespace 不能为空")
elif not SAFE_RESOURCE_ID.fullmatch(namespace):
errors.append("schema.namespace 只能包含安全字符")
if not version:
errors.append("schema.version 不能为空")
elif not SAFE_SCHEMA_VERSION.fullmatch(version):
errors.append("schema.version 只能包含字母、数字、点、下划线和连字符,最长 64 个字符")
entity_types = _normalize_named_mapping(
schema.get("entity_types"),
kind="实体类型",
name_keys=("name", "entity_type"),
errors=errors,
)
if not entity_types:
errors.append("schema.entity_types 至少需要一个实体类型")
normalized_entities: dict[str, dict[str, Any]] = {}
for entity_name, raw_meta in entity_types.items():
if not SAFE_SCHEMA_IDENTIFIER.fullmatch(entity_name):
errors.append(f"实体类型 {entity_name!r} 不是安全的 Schema/Cypher 标识符")
continue
meta = dict(raw_meta)
meta["fields"] = _normalize_field_definitions(
meta.get("fields"), path=f"schema.entity_types.{entity_name}.fields", errors=errors
)
normalized_entities[entity_name] = meta
relation_types = _normalize_named_mapping(
schema.get("relation_types", {}),
kind="关系类型",
name_keys=("name", "relation_type"),
errors=errors,
)
normalized_relations: dict[str, dict[str, Any]] = {}
for relation_name, raw_meta in relation_types.items():
if not SAFE_SCHEMA_IDENTIFIER.fullmatch(relation_name):
errors.append(f"关系类型 {relation_name!r} 不是安全的 Schema/Cypher 标识符")
continue
meta = dict(raw_meta)
source_type = str(meta.get("from") or meta.get("source") or "").strip()
target_type = str(meta.get("to") or meta.get("target") or "").strip()
source_types = _endpoint_types(source_type)
target_types = _endpoint_types(target_type)
if not source_types or any(item not in normalized_entities for item in source_types):
errors.append(f"关系类型 {relation_name} 的 from/source 必须引用已定义实体类型")
if not target_types or any(item not in normalized_entities for item in target_types):
errors.append(f"关系类型 {relation_name} 的 to/target 必须引用已定义实体类型")
meta["from"] = source_type
meta["to"] = target_type
meta["properties"] = _normalize_field_definitions(
meta.get("properties"),
path=f"schema.relation_types.{relation_name}.properties",
errors=errors,
)
normalized_relations[relation_name] = meta
raw_graph_data = body.get("graph_data")
if raw_graph_data is None:
raw_graph_data = {"nodes": [], "relations": []}
if not isinstance(raw_graph_data, Mapping):
errors.append("graph_data 必须是 JSON 对象")
raw_graph_data = {}
raw_nodes = raw_graph_data.get("nodes", [])
raw_relations = raw_graph_data.get("relations", [])
if not isinstance(raw_nodes, list):
errors.append("graph_data.nodes 必须是数组")
raw_nodes = []
if not isinstance(raw_relations, list):
errors.append("graph_data.relations 必须是数组")
raw_relations = []
if not raw_nodes:
errors.append("graph_data.nodes 至少需要一个节点,FalkorDB 不支持持久化空图")
nodes: list[dict[str, Any]] = []
node_types: dict[str, str] = {}
for index, raw_node in enumerate(raw_nodes):
path = f"graph_data.nodes[{index}]"
if not isinstance(raw_node, Mapping):
errors.append(f"{path} 必须是 JSON 对象")
continue
node = dict(raw_node)
node_id = str(node.get("id") or "").strip()
entity_type = str(node.get("type") or node.get("entity_type") or "").strip()
properties = node.get("properties", {})
if not node_id:
errors.append(f"{path}.id 不能为空")
elif node_id in node_types:
errors.append(f"节点 ID {node_id!r} 重复")
if entity_type not in normalized_entities:
errors.append(f"{path}.type {entity_type!r} 未在 Schema 中定义")
if not isinstance(properties, Mapping):
errors.append(f"{path}.properties 必须是 JSON 对象")
properties = {}
normalized_properties = dict(properties)
if entity_type in normalized_entities:
_validate_properties(
normalized_properties,
normalized_entities[entity_type].get("fields", {}),
path=path,
errors=errors,
)
if node_id and node_id not in node_types:
node_types[node_id] = entity_type
nodes.append({"id": node_id, "type": entity_type, "properties": normalized_properties})
relations: list[dict[str, Any]] = []
for index, raw_relation in enumerate(raw_relations):
path = f"graph_data.relations[{index}]"
if not isinstance(raw_relation, Mapping):
errors.append(f"{path} 必须是 JSON 对象")
continue
relation = dict(raw_relation)
relation_type = str(relation.get("type") or relation.get("relation_type") or "").strip()
source = str(relation.get("source") or "").strip()
target = str(relation.get("target") or "").strip()
properties = relation.get("properties", {})
definition = normalized_relations.get(relation_type)
if not definition:
errors.append(f"{path}.type {relation_type!r} 未在 Schema 中定义")
if source not in node_types:
errors.append(f"{path}.source {source!r} 不存在")
if target not in node_types:
errors.append(f"{path}.target {target!r} 不存在")
if (
definition
and source in node_types
and node_types[source] not in _endpoint_types(definition.get("from"))
):
errors.append(f"{path}.source 的节点类型必须是 {definition.get('from')}")
if (
definition
and target in node_types
and node_types[target] not in _endpoint_types(definition.get("to"))
):
errors.append(f"{path}.target 的节点类型必须是 {definition.get('to')}")
if not isinstance(properties, Mapping):
errors.append(f"{path}.properties 必须是 JSON 对象")
properties = {}
normalized_properties = dict(properties)
if definition:
_validate_properties(
normalized_properties,
definition.get("properties", {}),
path=path,
errors=errors,
)
relations.append(
{
"type": relation_type,
"source": source,
"target": target,
"properties": normalized_properties,
}
)
if errors:
raise ProjectValidationError(errors)
schema["namespace"] = namespace
schema["version"] = version
schema["entity_types"] = normalized_entities
schema["relation_types"] = normalized_relations
return {
"project_id": project_id,
"tenant_id": tenant_id,
"display_name": display_name,
"description": str(body.get("description") or "").strip() or None,
"graph_name": graph_name,
"schema": schema,
"graph_data": {"nodes": nodes, "relations": relations},
"counts": {
"entity_types": len(normalized_entities),
"relation_types": len(normalized_relations),
"nodes": len(nodes),
"relations": len(relations),
},
}
def _falkor_client() -> FalkorDB:
kwargs: dict[str, Any] = {
"host": settings.falkordb_host,
"port": settings.falkordb_port,
"socket_timeout": 15,
"socket_connect_timeout": 5,
}
if settings.falkordb_password:
kwargs["password"] = settings.falkordb_password
return FalkorDB(**kwargs)
def list_falkor_graphs() -> set[str]:
db = _falkor_client()
try:
return {
name.decode("utf-8") if isinstance(name, bytes) else str(name)
for name in db.list_graphs()
}
finally:
db.close()
def _graph_safe_value(value: Any) -> Any:
if value is None:
return None
if isinstance(value, (str, bool, int, float)):
return value
if isinstance(value, list) and all(isinstance(item, (str, bool, int, float)) for item in value):
return value
return json.dumps(value, ensure_ascii=False, sort_keys=True)
def _graph_safe_properties(properties: Mapping[str, Any]) -> dict[str, Any]:
return {
str(key): safe_value
for key, value in properties.items()
if (safe_value := _graph_safe_value(value)) is not None
}
def import_falkor_graph(graph_name: str, graph_data: Mapping[str, Any]) -> dict[str, int]:
"""Create a brand-new FalkorDB graph from already validated data."""
db = _falkor_client()
created = False
try:
existing = {
name.decode("utf-8") if isinstance(name, bytes) else str(name)
for name in db.list_graphs()
}
if graph_name in existing:
raise FileExistsError(f"FalkorDB 图 {graph_name!r} 已存在")
graph = db.select_graph(graph_name)
for node in graph_data.get("nodes", []):
label = str(node["type"])
properties = _graph_safe_properties(node.get("properties") or {})
properties.setdefault("id", str(node["id"]))
properties["__kg_node_id"] = str(node["id"])
# From this point onward the graph may exist even if the client
# raises after the server accepted the command.
created = True
graph.query(f"CREATE (n:{label}) SET n += $props", {"props": properties})
for relation in graph_data.get("relations", []):
relation_type = str(relation["type"])
properties = _graph_safe_properties(relation.get("properties") or {})
graph.query(
f"""
MATCH (source {{__kg_node_id:$source}}), (target {{__kg_node_id:$target}})
CREATE (source)-[relation:{relation_type}]->(target)
SET relation += $props
""",
{
"source": str(relation["source"]),
"target": str(relation["target"]),
"props": properties,
},
)
created = True
if not created:
return {"nodes": 0, "relations": 0}
node_count = int(graph.query("MATCH (n) RETURN count(n)").result_set[0][0])
relation_count = int(graph.query("MATCH ()-[r]->() RETURN count(r)").result_set[0][0])
return {"nodes": node_count, "relations": relation_count}
except FileExistsError:
raise
except Exception as exc:
cleanup_error: Exception | None = None
if created:
try:
db.select_graph(graph_name).delete()
except Exception as delete_exc:
cleanup_error = delete_exc
raise GraphImportError(str(exc), cleanup_error=cleanup_error) from exc
finally:
db.close()
def delete_falkor_graph(graph_name: str) -> bool:
"""Delete one exact graph name, returning False when it no longer exists."""
db = _falkor_client()
try:
existing = {
name.decode("utf-8") if isinstance(name, bytes) else str(name)
for name in db.list_graphs()
}
if graph_name not in existing:
return False
db.select_graph(graph_name).delete()
return True
finally:
db.close()