feat(ontology): 本体 spec 与跨引用校验

- pydantic 模型覆盖 entities/links/actions/functions/rules/constraints
- 跨引用校验:link.from/to 实体存在、rule.via 链路连得通且终点与 to 一致
- YAML 1.2 口径 loader:on/off/yes/no 不再被当成布尔(否则 on: Line 解析成 {True: Line})
- of 支持标量类型或实体名;属性支持 accepted: boolean 简写
- 19 条测试,其中 test_readme_example_loads 直接解析 README 里的 YAML
- dev 组补 types-pyyaml,mypy strict 通过
This commit is contained in:
Donny committed 2026-11-05 02:36:25 +08:00
1 parent e0bea0c54f
commit cc0d80ba3a
4 files changed
+598

No files matched your search

+1
View File
@@ -25,6 +25,7 @@ dev = [
"testcontainers[postgres]>=4.8",
"ruff>=0.8",
"mypy>=1.13",
"types-pyyaml>=6.0.12",
]
[build-system]
+368
View File
@@ -0,0 +1,368 @@
"""本体 spec:YAML 契约的 pydantic 模型。
这里的模型同时承担三件事:YAML 的语法校验、跨引用校验(link 的 from/to 是否存在、
rule.via 是否连得通)、以及给 compiler 的类型安全输入。字段命名与 README 里的示例
一一对应,不要在别处再定义第二套。
"""
from __future__ import annotations
import re
from enum import StrEnum
from pathlib import Path
from typing import Any, Self
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from adapter_python_pg.errors import OntologyError
__all__ = [
"ActionSpec",
"Cardinality",
"ConstraintSpec",
"EntityKind",
"EntitySpec",
"FunctionSpec",
"IdSpec",
"LinkSpec",
"Ontology",
"PropertySpec",
"PropertyType",
"RuleKind",
"RuleSpec",
"load_ontology",
]
# 本体里的名字要能安全地进 SQL 标识符与 Cypher label
_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
# YAML 1.1(PyYAML 默认)把 on/off/yes/no 当成布尔,本体里 `on: Line` 会被解析成
# {True: 'Line'}。这里改成 1.2 口径:只有 true/false 是布尔,其余保持字符串。
class _Yaml12SafeLoader(yaml.SafeLoader):
"""只认 true/false 的安全 loader。"""
_Yaml12SafeLoader.yaml_implicit_resolvers = {
key: [(tag, regexp) for tag, regexp in mappings if tag != "tag:yaml.org,2002:bool"]
for key, mappings in yaml.SafeLoader.yaml_implicit_resolvers.items()
}
_Yaml12SafeLoader.add_implicit_resolver(
"tag:yaml.org,2002:bool",
re.compile(r"^(?:true|True|TRUE|false|False|FALSE)$"),
list("tTfF"),
)
class PropertyType(StrEnum):
STRING = "string"
NUMBER = "number"
BOOLEAN = "boolean"
DATETIME = "datetime"
ENUM = "enum"
JSON = "json"
LIST = "list"
# geo 是"全 JSONB"规则的唯一例外:compiler 为它建 geometry 生成列 + GiST 索引
GEO = "geo"
REF = "ref"
class EntityKind(StrEnum):
OBJECT = "object"
TIMESERIES = "timeseries"
class Cardinality(StrEnum):
ONE_TO_ONE = "1:1"
ONE_TO_N = "1:N"
N_TO_ONE = "N:1"
N_TO_N = "N:N"
class RuleKind(StrEnum):
DERIVE_LINK = "derive_link"
DERIVE_PROPERTY = "derive_property"
class Effect(StrEnum):
WRITE = "write"
QUERY = "query"
EXTERNAL = "external"
class _Base(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, validate_assignment=True)
class IdSpec(_Base):
"""实体主键:调用方必须提供,adapter 不生成(幂等 upsert 与图节点 `_id` 都依赖它)。"""
type: PropertyType = PropertyType.STRING
pattern: str | None = None
@model_validator(mode="after")
def _only_stringish(self) -> Self:
if self.type not in {PropertyType.STRING, PropertyType.NUMBER}:
raise ValueError("id.type 只能是 string 或 number")
return self
class PropertySpec(_Base):
type: PropertyType
required: bool = False
unique: bool = False
index: bool = False
default: Any = None
@model_validator(mode="before")
@classmethod
def _allow_shorthand(cls, data: object) -> object:
"""允许 `accepted: boolean` 这种简写,等价于 `{type: boolean}`。"""
if isinstance(data, str):
return {"type": data}
return data
# enum 专用
values: tuple[str, ...] | None = None
# list 元素的类型:PropertyType 之一,或实体名(typed 结果集,action output 常用)
of: str | None = None
# 语义信息,不影响存储
unit: str | None = None
pattern: str | None = None
# 预留:属性提升为真实列。当前不实现,只接受声明(见 AGENTS.md 拍板 3)
column: bool = False
@model_validator(mode="after")
def _check(self) -> Self:
if self.type is PropertyType.ENUM:
if not self.values:
raise ValueError("enum 属性必须给 values")
elif self.values:
raise ValueError("只有 enum 属性可以给 values")
if self.type is PropertyType.LIST:
if self.of is None:
raise ValueError("list 属性必须给 of(元素类型)")
elif self.of is not None:
raise ValueError("只有 list 属性可以给 of")
return self
class EntitySpec(_Base):
label: str | None = None
id: IdSpec = Field(default_factory=IdSpec)
properties: dict[str, PropertySpec] = Field(default_factory=dict)
kind: EntityKind = EntityKind.OBJECT
# kind=timeseries 时必填:时间列名 + 分片粒度
time: str | None = None
partition_by: str | None = None
@model_validator(mode="after")
def _check(self) -> Self:
for name in self.properties:
if not _NAME.match(name):
raise ValueError(f"属性名非法(需匹配 {_NAME.pattern}):{name!r}")
if self.kind is EntityKind.TIMESERIES:
if not self.time:
raise ValueError("timeseries 实体必须声明 time")
if self.time not in self.properties:
raise ValueError(f"time={self.time!r} 未在 properties 中定义")
if self.properties[self.time].type is not PropertyType.DATETIME:
raise ValueError("time 属性类型必须是 datetime")
elif self.time is not None:
raise ValueError("只有 kind=timeseries 的实体可以声明 time")
return self
class LinkSpec(_Base):
type: str
from_: str = Field(alias="from")
to: str
cardinality: Cardinality = Cardinality.N_TO_ONE
properties: dict[str, PropertySpec] = Field(default_factory=dict)
class ActionSpec(_Base):
on: str
effect: Effect = Effect.WRITE
input: dict[str, PropertySpec] = Field(default_factory=dict)
output: dict[str, PropertySpec] = Field(default_factory=dict)
class FunctionSpec(_Base):
on: str
expr: str
output: PropertySpec
class RuleSpec(_Base):
kind: RuleKind
from_: str | None = Field(default=None, alias="from") # derive_link 用
to: str | None = None # derive_link 用
via: tuple[str, ...] | None = None # link 的 type 序列
on: str | None = None # derive_property 用
expr: str | None = None
output: PropertySpec | None = None
@model_validator(mode="after")
def _check(self) -> Self:
if self.kind is RuleKind.DERIVE_LINK:
if not (self.from_ and self.to and self.via):
raise ValueError("derive_link 需要 from、to、via")
if self.expr or self.on or self.output:
raise ValueError("derive_link 不接受 on/expr/output")
else:
if not (self.on and self.expr and self.output):
raise ValueError("derive_property 需要 on、expr、output")
if self.from_ or self.to or self.via:
raise ValueError("derive_property 不接受 from/to/via")
return self
class ConstraintSpec(_Base):
on: str
expr: str
class Ontology(_Base):
"""一份本体。跨引用校验在这里做,读进来的对象可以直接交给 compiler。"""
ontology: str
version: int = 1
entities: dict[str, EntitySpec]
links: dict[str, LinkSpec] = Field(default_factory=dict)
actions: dict[str, ActionSpec] = Field(default_factory=dict)
functions: dict[str, FunctionSpec] = Field(default_factory=dict)
rules: dict[str, RuleSpec] = Field(default_factory=dict)
constraints: dict[str, ConstraintSpec] = Field(default_factory=dict)
@model_validator(mode="after")
def _check(self) -> Self:
if not self.entities:
raise ValueError("本体必须至少定义一个实体")
for name in (*self.entities, *self.links, *self.actions, *self.functions):
if not _NAME.match(name):
raise ValueError(f"名字非法(需匹配 {_NAME.pattern}):{name!r}")
for name, link in self.links.items():
self._require_entity(link.from_, f"link {name}.from")
self._require_entity(link.to, f"link {name}.to")
for name, action in self.actions.items():
self._require_entity(action.on, f"action {name}.on")
for name, func in self.functions.items():
self._require_entity(func.on, f"function {name}.on")
for name, rule in self.rules.items():
self._check_rule(name, rule)
for name, cons in self.constraints.items():
self._require_entity(cons.on, f"constraint {name}.on")
self._check_of_types()
return self
# -- 查询辅助 ---------------------------------------------------------
def entity(self, name: str) -> EntitySpec:
try:
return self.entities[name]
except KeyError:
raise OntologyError(f"本体里没有实体 {name!r}") from None
def timeseries_entities(self) -> dict[str, EntitySpec]:
return {k: v for k, v in self.entities.items() if v.kind is EntityKind.TIMESERIES}
def link_types(self) -> dict[str, list[tuple[str, str]]]:
"""link.type → [(from, to)],供 rule.via 的链式校验使用。"""
edges: dict[str, list[tuple[str, str]]] = {}
for link in self.links.values():
edges.setdefault(link.type, []).append((link.from_, link.to))
return edges
def _check_of_types(self) -> None:
"""`of` 要么是标量类型名,要么是已定义的实体名。"""
scalar = {str(t) for t in PropertyType}
def check(where: str, props: dict[str, PropertySpec]) -> None:
for prop_name, prop in props.items():
if prop.of is None or prop.of in scalar:
continue
self._require_entity(prop.of, f"{where}.{prop_name}.of")
for entity_name, entity in self.entities.items():
check(entity_name, entity.properties)
for link_name, link in self.links.items():
check(link_name, link.properties)
for action_name, action in self.actions.items():
check(f"{action_name}.input", action.input)
check(f"{action_name}.output", action.output)
# -- 内部校验 ---------------------------------------------------------
def _require_entity(self, name: str | None, where: str) -> None:
if name is None or name not in self.entities:
raise ValueError(f"{where} 引用了不存在的实体:{name!r}")
def _check_rule(self, name: str, rule: RuleSpec) -> None:
if rule.kind is RuleKind.DERIVE_PROPERTY:
self._require_entity(rule.on, f"rule {name}.on")
return
self._require_entity(rule.from_, f"rule {name}.from")
self._require_entity(rule.to, f"rule {name}.to")
assert rule.from_ and rule.to and rule.via # RuleSpec 已保证
edges = self.link_types()
current = rule.from_
walked: list[str] = []
for step, link_type in enumerate(rule.via, start=1):
candidates = edges.get(link_type)
if not candidates:
raise ValueError(f"rule {name}.via 引用了不存在的 link type:{link_type!r}")
hop = next((dst for src, dst in candidates if src == current), None)
if hop is None:
starts = ", ".join(sorted({src for src, _ in candidates}))
raise ValueError(
f"rule {name}.via 链在第 {step} 步断了:{link_type!r} 没有从 "
f"{current!r} 出发的边(该 type 的起点有:{starts})"
)
current = hop
walked.append(link_type)
if current != rule.to:
raise ValueError(
f"rule {name} 的 via 链终点是 {current!r},与 to={rule.to!r} 不一致"
f"(链路:{rule.from_} -{' → '.join(walked)}→ {current})"
)
def load_ontology(source: str | Path) -> Ontology:
"""从 YAML 文件路径或 YAML 文本加载本体。
任何结构/引用问题都收敛成 `OntologyError`,不把 pydantic 的 ValidationError
泄漏给调用方(宿主只应 catch 一种异常)。
"""
if isinstance(source, Path) or ("\n" not in source and source.endswith((".yml", ".yaml"))):
path = Path(source)
if not path.is_file():
raise OntologyError(f"本体文件不存在:{path}")
text = path.read_text(encoding="utf-8")
else:
text = source
try:
raw = yaml.load(text, Loader=_Yaml12SafeLoader)
except yaml.YAMLError as exc:
raise OntologyError(f"YAML 语法错误:{exc}") from exc
if not isinstance(raw, dict):
raise OntologyError("本体根节点必须是映射(mapping)")
try:
return Ontology.model_validate(raw)
except ValidationError as exc:
raise OntologyError(_format_validation_error(exc)) from exc
def _format_validation_error(exc: ValidationError) -> str:
lines = []
for err in exc.errors():
loc = ".".join(str(part) for part in err["loc"]) or "<root>"
lines.append(f"{loc}: {err['msg']}")
return "本体校验失败 —— " + "; ".join(lines)
+218
View File
@@ -0,0 +1,218 @@
"""本体 spec 测试。
其中 `test_readme_example_loads` 直接解析 README 里那段 YAML —— 文档示例一旦写坏,
这里先红,读者不会抄到坏例子。
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
import yaml
from adapter_python_pg.errors import OntologyError
from adapter_python_pg.ontology.spec import (
Cardinality,
EntityKind,
PropertyType,
RuleKind,
load_ontology,
)
README = Path(__file__).resolve().parents[1] / "README.md"
def parse(mapping: dict[str, object]) -> object:
"""走公开入口(YAML 文本 → Ontology),与真实使用路径一致。"""
return load_ontology(yaml.safe_dump(mapping, allow_unicode=True))
def readme_yaml() -> str:
text = README.read_text(encoding="utf-8")
block = re.search(r"```yaml\n(.*?)```", text, re.S)
assert block, "README 里没有 yaml 代码块"
return block.group(1)
def minimal(**overrides: object) -> dict[str, object]:
"""最简本体,供各用例覆盖局部字段。"""
base: dict[str, object] = {
"ontology": "t",
"entities": {
"A": {},
"B": {},
},
"links": {"A_B": {"type": "rel", "from": "A", "to": "B"}},
}
base.update(overrides)
return base
# -- README 契约 -----------------------------------------------------------
def test_readme_example_loads() -> None:
onto = load_ontology(readme_yaml())
assert onto.ontology == "factory-twin"
assert set(onto.entities) == {"Factory", "Line", "Device", "SensorReading"}
assert set(onto.links) == {"Line_Factory", "Device_Line"}
assert onto.entities["SensorReading"].kind is EntityKind.TIMESERIES
assert onto.entities["SensorReading"].time == "ts"
assert onto.entities["Factory"].properties["location"].type is PropertyType.GEO
assert onto.entities["Line"].properties["status"].values == ("running", "idle", "fault")
assert onto.links["Device_Line"].cardinality is Cardinality.N_TO_ONE
assert onto.links["Device_Line"].from_ == "Device"
assert onto.rules["Device_inFactory"].kind is RuleKind.DERIVE_LINK
assert onto.rules["Device_inFactory"].via == ("mounted_on", "belongs_to")
def test_readme_rule_chain_resolves_device_to_factory() -> None:
onto = load_ontology(readme_yaml())
assert onto.link_types()["mounted_on"] == [("Device", "Line")]
assert onto.link_types()["belongs_to"] == [("Line", "Factory")]
def test_timeseries_entities_helper() -> None:
onto = load_ontology(readme_yaml())
assert list(onto.timeseries_entities()) == ["SensorReading"]
# -- 跨引用校验 ------------------------------------------------------------
def test_link_target_must_exist() -> None:
bad = minimal(links={"A_B": {"type": "rel", "from": "A", "to": "Nope"}})
with pytest.raises(OntologyError, match=r"link A_B\.to 引用了不存在的实体"):
parse(bad)
def test_link_source_must_exist() -> None:
bad = minimal(links={"A_B": {"type": "rel", "from": "Nope", "to": "B"}})
with pytest.raises(OntologyError, match=r"link A_B\.from 引用了不存在的实体"):
parse(bad)
def test_rule_via_must_be_connected() -> None:
bad = minimal(
rules={
"backwards": {
"kind": "derive_link",
"from": "B",
"to": "A",
"via": ["rel"], # rel 只从 A 出发,B 走不动
}
}
)
with pytest.raises(OntologyError, match="via 链在第 1 步断了"):
parse(bad)
def test_rule_chain_end_must_match_to() -> None:
bad = minimal(
entities={"A": {}, "B": {}, "C": {}},
links={
"A_B": {"type": "rel", "from": "A", "to": "B"},
"B_C": {"type": "rel2", "from": "B", "to": "C"},
},
rules={
"short": {"kind": "derive_link", "from": "A", "to": "C", "via": ["rel"]},
},
)
with pytest.raises(OntologyError, match="via 链终点是 'B'"):
parse(bad)
def test_rule_via_unknown_type() -> None:
bad = minimal(
rules={"r": {"kind": "derive_link", "from": "A", "to": "B", "via": ["nope"]}},
)
with pytest.raises(OntologyError, match="不存在的 link type"):
parse(bad)
# -- 字段级校验 ------------------------------------------------------------
def test_enum_requires_values() -> None:
bad = minimal(entities={"A": {"properties": {"s": {"type": "enum"}}}})
with pytest.raises(OntologyError, match="enum 属性必须给 values"):
parse(bad)
def test_values_only_on_enum() -> None:
bad = minimal(entities={"A": {"properties": {"s": {"type": "string", "values": ["x"]}}}})
with pytest.raises(OntologyError, match="只有 enum 属性可以给 values"):
parse(bad)
def test_list_requires_of() -> None:
bad = minimal(entities={"A": {"properties": {"tags": {"type": "list"}}}})
with pytest.raises(OntologyError, match="list 属性必须给 of"):
parse(bad)
def test_timeseries_requires_declared_time() -> None:
bad = minimal(
entities={
"A": {"kind": "timeseries", "time": "ts"}, # properties 里没有 ts
}
)
with pytest.raises(OntologyError, match="未在 properties 中定义"):
parse(bad)
def test_timeseries_time_must_be_datetime() -> None:
bad = minimal(
entities={
"A": {
"kind": "timeseries",
"time": "ts",
"properties": {"ts": {"type": "string"}},
}
}
)
with pytest.raises(OntologyError, match="time 属性类型必须是 datetime"):
parse(bad)
def test_unknown_field_is_rejected() -> None:
"""拼错字段名必须报错,而不是被静默忽略。"""
bad = minimal(entities={"A": {"properties": {"x": {"type": "string", "indx": True}}}})
with pytest.raises(OntologyError, match="indx"):
parse(bad)
def test_bad_identifier() -> None:
bad = minimal(entities={"A; drop table x": {}})
with pytest.raises(OntologyError, match="名字非法"):
parse(bad)
# -- 加载入口 --------------------------------------------------------------
def test_load_from_text_and_path(tmp_path: Path) -> None:
text = readme_yaml()
assert load_ontology(text).ontology == "factory-twin"
path = tmp_path / "onto.yaml"
path.write_text(text, encoding="utf-8")
assert load_ontology(path).ontology == "factory-twin"
def test_missing_file(tmp_path: Path) -> None:
with pytest.raises(OntologyError, match="本体文件不存在"):
load_ontology(tmp_path / "nope.yaml")
def test_yaml_syntax_error() -> None:
with pytest.raises(OntologyError, match="YAML 语法错误"):
load_ontology("ontology: x\nentities: [unclosed")
def test_root_must_be_mapping() -> None:
with pytest.raises(OntologyError, match="根节点必须是映射"):
load_ontology("- a\n- b\n")
Generated
+11
View File
@@ -31,6 +31,7 @@ dev = [
{ name = "pytest-asyncio" },
{ name = "ruff" },
{ name = "testcontainers" },
{ name = "types-pyyaml" },
]
[package.metadata]
@@ -51,6 +52,7 @@ dev = [
{ name = "pytest-asyncio", specifier = ">=0.24" },
{ name = "ruff", specifier = ">=0.8" },
{ name = "testcontainers", extras = ["postgres"], specifier = ">=4.8" },
{ name = "types-pyyaml", specifier = ">=6.0.12" },
]
[[package]]
@@ -1225,6 +1227,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/7e/424aac8b355597835deb333e757a0e94b5ccf38ad00f07fe6ed1f4e17c88/testcontainers-4.15.0-py3-none-any.whl", hash = "sha256:8796c14e76604031ad39cf0ed3b8e9806283a1fbf5270965c2b1c594caa31b74", size = 160771, upload-time = "2026-07-24T23:08:00.13Z" },
]
[[package]]
name = "types-pyyaml"
version = "6.0.12.20261103"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d9/82/2ed4d215a2840b467a9a864f03b0d21904250e28f5e1f006ad36fba0311a/types_pyyaml-6.0.12.20261103.tar.gz", hash = "sha256:7a1b5f837881dd35d5448b5f18275164f89f206185e31e8eae6e0b42607bc001", size = 18911, upload-time = "2026-11-03T08:45:23.714Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/10/4353eb86278f6a59aeb9fe2be4d979c20b110b4009fa455a3ea6467cf5cb/types_pyyaml-6.0.12.20261103-py3-none-any.whl", hash = "sha256:444654c61f635dab82c092a511aa96a0ce31164810784b9a37314e3e56532ac8", size = 21299, upload-time = "2026-11-03T08:45:22.749Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"