arango-query-core 0.2.4__tar.gz → 0.2.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/CHANGELOG.md +26 -0
  2. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/PKG-INFO +1 -1
  3. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/__init__.py +2 -0
  4. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/mapping.py +100 -0
  5. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/owl_turtle.py +30 -6
  6. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/docs/PRD.md +19 -1
  7. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/pyproject.toml +1 -1
  8. arango_query_core-0.2.5/tests/test_mapping_supertypes.py +190 -0
  9. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
  10. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/.github/workflows/ci.yml +0 -0
  11. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/.github/workflows/publish.yml +0 -0
  12. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/.gitignore +0 -0
  13. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/LICENSE +0 -0
  14. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/README.md +0 -0
  15. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/aql.py +0 -0
  16. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/errors.py +0 -0
  17. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/exec.py +0 -0
  18. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/extensions.py +0 -0
  19. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/__init__.py +0 -0
  20. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/engine.py +0 -0
  21. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/fewshot.py +0 -0
  22. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/grounding.py +0 -0
  23. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/pathindex.py +0 -0
  24. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/postconditions.py +0 -0
  25. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/providers.py +0 -0
  26. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/seams.py +0 -0
  27. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/nl/synthbank.py +0 -0
  28. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/owl_rdflib.py +0 -0
  29. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/arango_query_core/py.typed +0 -0
  30. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/fixtures/mappings/pg.owl.ttl +0 -0
  31. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_mapping_helpers.py +0 -0
  32. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_mapping_resolver_normalization.py +0 -0
  33. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_mapping_vci_detection.py +0 -0
  34. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_engine.py +0 -0
  35. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_engine_extraction_and_resilience.py +0 -0
  36. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_fewshot.py +0 -0
  37. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_grounding.py +0 -0
  38. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_pathindex.py +0 -0
  39. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_postconditions.py +0 -0
  40. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_providers.py +0 -0
  41. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_nl_synthbank.py +0 -0
  42. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_owl_rdflib.py +0 -0
  43. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_owl_turtle_roundtrip.py +0 -0
  44. {arango_query_core-0.2.4 → arango_query_core-0.2.5}/tests/test_upstream_markers.py +0 -0
@@ -6,6 +6,32 @@ exact versions (contextual-data-fabric CC-9) and re-run their golden /
6
6
  eval suites on every bump — pre-1.0, **minor** bumps may break the API
7
7
  and this file must say so explicitly.
8
8
 
9
+ ## [0.2.5] - 2026-10-26
10
+
11
+ ### Added
12
+
13
+ - **Supertypes (label hierarchies).** A supertype is a label that stands for
14
+ other labels, such as LDBC's `Message`, which is every `Post` and every
15
+ `Comment`. It is mapped with one of two new entity styles,
16
+ `SUPERTYPE_STYLES`:
17
+ - `MULTI_LABEL`: the members share a collection and list their labels in
18
+ an array field (`labelsField`, `labelValue`);
19
+ - `UNION`: the members have their own collections (`members`).
20
+ - **New resolver methods.** `MappingResolver.is_supertype()` and
21
+ `supertype_members()`. The latter flattens nested supertypes and refuses
22
+ unknown members and cycles with `INVALID_MAPPING`. A supertype without
23
+ properties of its own has its members' properties.
24
+ - **`edge_constrains_target`** treats a supertype target as guaranteed when
25
+ every class an edge endpoint can be is a member: `HAS_CREATOR`'s domain
26
+ `[Post, Comment]` guarantees `Message`.
27
+ - **OWL/Turtle** writes and reads the hierarchy as `rdfs:subClassOf`.
28
+ `turtle_to_mapping` no longer invents a `collectionName` for a `UNION`
29
+ supertype. The rdflib parser does not read `rdfs:subClassOf` yet.
30
+
31
+ The change is additive: mappings without the new styles resolve exactly as
32
+ before. A consumer that switches on `style` and does not handle the new
33
+ values rejects them rather than misreading them.
34
+
9
35
  ## [0.2.4] - 2026-10-21
10
36
 
11
37
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: arango-query-core
3
- Version: 0.2.4
3
+ Version: 0.2.5
4
4
  Summary: Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)
5
5
  Project-URL: Repository, https://github.com/ArthurKeen/arango-query-core
6
6
  Project-URL: Changelog, https://github.com/ArthurKeen/arango-query-core/blob/main/CHANGELOG.md
@@ -4,6 +4,7 @@ from .exec import AqlExecutor, explain_aql, safe_execute
4
4
  from .extensions import ExtensionPolicy, ExtensionRegistry
5
5
  from .mapping import (
6
6
  COLLECTION_NAME_RE,
7
+ SUPERTYPE_STYLES,
7
8
  IndexInfo,
8
9
  MappingBundle,
9
10
  MappingResolver,
@@ -23,6 +24,7 @@ except ImportError:
23
24
 
24
25
  __all__ = [
25
26
  "COLLECTION_NAME_RE",
27
+ "SUPERTYPE_STYLES",
26
28
  "AqlExecutor",
27
29
  "AqlFragment",
28
30
  "AqlQuery",
@@ -10,6 +10,28 @@ from .errors import CoreError
10
10
 
11
11
  JsonObj = dict[str, Any]
12
12
 
13
+ #: Entity mapping styles for a *supertype*: a label whose instances are the
14
+ #: instances of other labels (LDBC's ``Message`` is every ``Post`` and every
15
+ #: ``Comment``).
16
+ #:
17
+ #: * ``MULTI_LABEL`` — the members share one collection and every document
18
+ #: lists all its labels in an array field::
19
+ #:
20
+ #: {"style": "MULTI_LABEL", "collectionName": "nodes",
21
+ #: "labelsField": "labels", "labelValue": "Message"}
22
+ #:
23
+ #: matches documents of ``nodes`` whose ``labels`` contain ``"Message"``.
24
+ #: * ``UNION`` — the members live in their own collections::
25
+ #:
26
+ #: {"style": "UNION", "members": ["Post", "Comment"]}
27
+ #:
28
+ #: Members come from the physical ``members`` list or, failing that, the
29
+ #: conceptual entity's ``subtypes``. They must be mapped labels; a member may
30
+ #: itself be a supertype (members are flattened). Consumers that switch on
31
+ #: ``style`` and do not know these values fail closed, which is why supertypes
32
+ #: get styles of their own rather than extra fields on ``LABEL``/``COLLECTION``.
33
+ SUPERTYPE_STYLES = frozenset({"MULTI_LABEL", "UNION"})
34
+
13
35
 
14
36
  # ArangoDB collection-name grammar (per the official docs):
15
37
  # * starts with a letter or underscore
@@ -391,6 +413,13 @@ class MappingResolver:
391
413
  return {}
392
414
 
393
415
  props_raw = mapping.get("properties", {})
416
+ if not props_raw and mapping.get("style") in SUPERTYPE_STYLES:
417
+ # A supertype without its own property list has its members' properties.
418
+ merged: dict[str, PropertyInfo] = {}
419
+ for member in self.supertype_members(label_or_type):
420
+ for name, info in self.resolve_properties(member).items():
421
+ merged.setdefault(name, info)
422
+ return merged
394
423
  if not isinstance(props_raw, dict):
395
424
  return {}
396
425
 
@@ -518,7 +547,16 @@ class MappingResolver:
518
547
  be in the set **and** every class in the union must map to the same
519
548
  physical collection (otherwise multiple collections are reachable and
520
549
  IS_SAME_COLLECTION is still needed).
550
+
551
+ A supertype target is guaranteed when every class the endpoint can be
552
+ is one of its members: ``HAS_CREATOR``'s domain ``[Post, Comment]``
553
+ guarantees ``Message``.
521
554
  """
555
+ if self.is_supertype(target_label):
556
+ classes = [endpoint] if isinstance(endpoint, str) else list(endpoint or [])
557
+ members = set(self.supertype_members(target_label))
558
+ return bool(classes) and all(c in members for c in classes)
559
+
522
560
  if isinstance(endpoint, str):
523
561
  return endpoint == target_label
524
562
 
@@ -645,6 +683,68 @@ class MappingResolver:
645
683
  """Check if any index on the relationship's edge collection has VCI enabled."""
646
684
  return any(idx.vci for idx in self.resolve_indexes(rel_type))
647
685
 
686
+ # ------------------------------------------------------------------
687
+ # Supertypes (label hierarchies)
688
+ # ------------------------------------------------------------------
689
+
690
+ def is_supertype(self, label: str) -> bool:
691
+ """True when *label* is mapped with a supertype style (see :data:`SUPERTYPE_STYLES`)."""
692
+ try:
693
+ return self.resolve_entity(label).get("style") in SUPERTYPE_STYLES
694
+ except CoreError:
695
+ return False
696
+
697
+ def _declared_members(self, label: str, emap: JsonObj) -> list[str]:
698
+ members = emap.get("members")
699
+ if members is None:
700
+ for ent in self.bundle.conceptual_schema.get("entities", []) or []:
701
+ if isinstance(ent, dict) and ent.get("name") == label:
702
+ members = ent.get("subtypes")
703
+ break
704
+ if not isinstance(members, list) or not members or not all(isinstance(m, str) and m for m in members):
705
+ raise CoreError(
706
+ f"Supertype {label!r} declares no members: give the physical mapping a 'members' list "
707
+ f"or the conceptual entity a 'subtypes' list",
708
+ code="INVALID_MAPPING",
709
+ )
710
+ return members
711
+
712
+ def supertype_members(self, label: str) -> list[str]:
713
+ """The concrete (non-supertype) labels a supertype stands for, in declaration order.
714
+
715
+ Nested supertypes are flattened. Raises ``INVALID_MAPPING`` for a
716
+ member that is not mapped or a cycle; raises ``MAPPING_NOT_FOUND`` if
717
+ *label* is not mapped, and ``INVALID_MAPPING`` if it is not a supertype.
718
+ """
719
+ out: list[str] = []
720
+
721
+ def visit(name: str, path: tuple[str, ...]) -> None:
722
+ if name in path:
723
+ raise CoreError(
724
+ f"Supertype cycle: {' -> '.join((*path, name))}",
725
+ code="INVALID_MAPPING",
726
+ )
727
+ try:
728
+ emap = self.resolve_entity(name)
729
+ except CoreError as e:
730
+ if path:
731
+ raise CoreError(
732
+ f"Supertype {path[-1]!r} lists member {name!r}, which is not mapped",
733
+ code="INVALID_MAPPING",
734
+ ) from e
735
+ raise
736
+ if emap.get("style") not in SUPERTYPE_STYLES:
737
+ if not path:
738
+ raise CoreError(f"{name!r} is not a supertype", code="INVALID_MAPPING")
739
+ if name not in out:
740
+ out.append(name)
741
+ return
742
+ for member in self._declared_members(name, emap):
743
+ visit(member, (*path, name))
744
+
745
+ visit(label, ())
746
+ return out
747
+
648
748
  def all_entity_labels(self) -> list[str]:
649
749
  """Return all entity type labels defined in the mapping."""
650
750
  entities = self.bundle.physical_mapping.get("entities", {})
@@ -62,6 +62,13 @@ def mapping_to_turtle(bundle: MappingBundle) -> str:
62
62
  lines.append(f':{name} phys:typeField "{tf}" .')
63
63
  if tv:
64
64
  lines.append(f':{name} phys:typeValue "{tv}" .')
65
+ if style == "MULTI_LABEL":
66
+ for annot in ("labelsField", "labelValue"):
67
+ if emap.get(annot):
68
+ lines.append(f':{name} phys:{annot} "{emap[annot]}" .')
69
+ # A supertype's members are its OWL subclasses.
70
+ for member in emap.get("members") or entity_def.get("subtypes") or []:
71
+ lines.append(f":{member} rdfs:subClassOf :{name} .")
65
72
 
66
73
  for prop in entity_def.get("properties", []):
67
74
  pname = prop.get("name", "")
@@ -127,6 +134,7 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
127
134
  obj_props: dict[str, dict[str, str]] = {}
128
135
  data_props: list[dict[str, Any]] = []
129
136
  annotations: dict[str, dict[str, str]] = {}
137
+ subclasses: dict[str, list[str]] = {} # supertype -> member classes (rdfs:subClassOf)
130
138
 
131
139
  current_subject: str | None = None
132
140
  current_kind: str | None = None # "class", "objprop", "dataprop"
@@ -150,6 +158,11 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
150
158
  obj_props.setdefault(current_subject, {})
151
159
  continue
152
160
 
161
+ m_sub = re.match(r":(\w+)\s+rdfs:subClassOf\s+:(\w+)\s*\.", stripped)
162
+ if m_sub:
163
+ subclasses.setdefault(m_sub.group(2), []).append(m_sub.group(1))
164
+ continue
165
+
153
166
  m_dataprop = re.match(r":(\w+)\s+a\s+owl:DatatypeProperty", stripped)
154
167
  if m_dataprop:
155
168
  current_subject = m_dataprop.group(1)
@@ -188,6 +201,8 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
188
201
  "typeValue",
189
202
  "edgeCollectionName",
190
203
  "attributeName",
204
+ "labelsField",
205
+ "labelValue",
191
206
  ):
192
207
  m_a = re.match(rf':(\w+)\s+phys:{annot}\s+"([^"]+)"', stripped)
193
208
  if m_a:
@@ -200,15 +215,24 @@ def turtle_to_mapping(turtle: str) -> MappingBundle:
200
215
  for name in classes:
201
216
  own = [dp for dp in data_props if name in dp["domains"]]
202
217
  props = [{"name": dp["name"], "type": _from_xsd_type(dp.get("range", "xsd:string"))} for dp in own]
203
- entities_cs.append({"name": name, "labels": [name], "properties": props})
218
+ cs_entry: dict[str, Any] = {"name": name, "labels": [name], "properties": props}
219
+ if subclasses.get(name):
220
+ cs_entry["subtypes"] = list(subclasses[name])
221
+ entities_cs.append(cs_entry)
204
222
  ann = annotations.get(name, {})
205
- pm_entry: dict[str, Any] = {
206
- "style": ann.get("mappingStyle", "COLLECTION"),
207
- "collectionName": ann.get("collectionName", name.lower() + "s"),
208
- }
209
- if pm_entry["style"] == "LABEL":
223
+ style = ann.get("mappingStyle", "COLLECTION")
224
+ pm_entry: dict[str, Any] = {"style": style}
225
+ if style == "UNION":
226
+ # A UNION supertype has no collection of its own; its members are its subclasses.
227
+ pm_entry["members"] = list(subclasses.get(name, []))
228
+ else:
229
+ pm_entry["collectionName"] = ann.get("collectionName", name.lower() + "s")
230
+ if style == "LABEL":
210
231
  pm_entry["typeField"] = ann.get("typeField", "type")
211
232
  pm_entry["typeValue"] = ann.get("typeValue", name)
233
+ if style == "MULTI_LABEL":
234
+ pm_entry["labelsField"] = ann.get("labelsField", "labels")
235
+ pm_entry["labelValue"] = ann.get("labelValue", name)
212
236
  fields = {
213
237
  dp["name"]: {"field": annotations[dp["name"]]["attributeName"]}
214
238
  for dp in own
@@ -116,7 +116,7 @@ to prevent a three-way fork.
116
116
 
117
117
  | Module / subpackage | Purpose |
118
118
  | --- | --- |
119
- | `mapping` | `MappingBundle`, `MappingResolver`, `MappingSource`, `PropertyInfo`, `IndexInfo`, `RelationshipStats`; `mapping_from_wire_dict`, `mapping_hash`; collection-name grammar guard (`COLLECTION_NAME_RE`, `is_valid_collection_name`) |
119
+ | `mapping` | `MappingBundle`, `MappingResolver` (incl. supertypes: `SUPERTYPE_STYLES`, `is_supertype`, `supertype_members`), `MappingSource`, `PropertyInfo`, `IndexInfo`, `RelationshipStats`; `mapping_from_wire_dict`, `mapping_hash`; collection-name grammar guard (`COLLECTION_NAME_RE`, `is_valid_collection_name`) |
120
120
  | `owl_turtle` / `owl_rdflib` | OWL/Turtle ⇄ MappingBundle round-trip; rdflib parser behind the `owl` extra (import degrades to `None` without it) |
121
121
  | `aql` | `AqlFragment`, `AqlQuery` value types |
122
122
  | `exec` | `AqlExecutor` protocol, `safe_execute` (bind-var reference checking), `explain_aql` |
@@ -149,6 +149,24 @@ to prevent a three-way fork.
149
149
  - **R5 — Deterministic contract types.** `mapping_hash` over a bundle
150
150
  is stable across processes (canonical JSON) so consumers can use it
151
151
  for cache keys and change detection.
152
+ - **R7 — Label hierarchies (supertypes).** A supertype is a label whose
153
+ instances are the instances of other labels (LDBC's `Message` is every
154
+ `Post` and every `Comment`).
155
+ - **Styles.** It is mapped with one of two entity styles (`SUPERTYPE_STYLES`):
156
+ - `MULTI_LABEL`: the members share one collection, and documents list
157
+ their labels in an array field (`labelsField`, `labelValue`).
158
+ - `UNION`: the members have collections of their own (`members`).
159
+ - **Members** come from the physical `members` list or the conceptual
160
+ entity's `subtypes`.
161
+ - **Resolver.** `MappingResolver.supertype_members()` flattens nested
162
+ supertypes, and refuses unknown members and cycles (`INVALID_MAPPING`).
163
+ A supertype without properties of its own has its members' properties.
164
+ `edge_constrains_target` treats a supertype target as guaranteed when
165
+ every class an edge endpoint can be is a member.
166
+ - **New styles fail closed.** Supertypes get styles of their own, so a
167
+ consumer that does not handle them rejects the mapping instead of
168
+ misreading it.
169
+ - **OWL.** OWL/Turtle round-trips the hierarchy as `rdfs:subClassOf`.
152
170
  - **R6 — Quality gates.** ruff (lint + format), mypy, and pytest run in
153
171
  CI on every push/PR; all three must pass before a release is tagged.
154
172
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arango-query-core"
3
- version = "0.2.4"
3
+ version = "0.2.5"
4
4
  description = "Shared conceptual-model and NL substrate for ArangoDB query transpilers (arango-cypher-py, arango-sparql-py)"
5
5
  readme = "README.md"
6
6
  license = { file = "LICENSE" }
@@ -0,0 +1,190 @@
1
+ """Supertypes (label hierarchies): the MULTI_LABEL and UNION entity styles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pytest
6
+
7
+ from arango_query_core import SUPERTYPE_STYLES, CoreError, MappingBundle, MappingResolver
8
+
9
+
10
+ def _bundle(
11
+ entities: dict, relationships: dict | None = None, conceptual: dict | None = None
12
+ ) -> MappingBundle:
13
+ return MappingBundle(
14
+ conceptual_schema=conceptual or {"entities": [], "relationships": []},
15
+ physical_mapping={"entities": entities, "relationships": relationships or {}},
16
+ metadata={},
17
+ )
18
+
19
+
20
+ _POST = {
21
+ "collectionName": "post",
22
+ "style": "COLLECTION",
23
+ "properties": {"id": {"field": "id"}, "imageFile": {}},
24
+ }
25
+ _COMMENT = {
26
+ "collectionName": "comment",
27
+ "style": "COLLECTION",
28
+ "properties": {"id": {"field": "id"}, "length": {}},
29
+ }
30
+ _HAS_CREATOR = {
31
+ "edgeCollectionName": "has_creator",
32
+ "style": "DEDICATED_COLLECTION",
33
+ "domain": ["Post", "Comment"],
34
+ "range": "Person",
35
+ }
36
+
37
+
38
+ def _pg(**extra) -> MappingResolver:
39
+ return MappingResolver(
40
+ _bundle(
41
+ {
42
+ "Post": _POST,
43
+ "Comment": _COMMENT,
44
+ "Person": {"collectionName": "person", "style": "COLLECTION"},
45
+ "Message": {"style": "UNION", "members": ["Post", "Comment"]},
46
+ **extra,
47
+ },
48
+ {"HAS_CREATOR": _HAS_CREATOR},
49
+ )
50
+ )
51
+
52
+
53
+ def test_both_styles_are_supertype_styles():
54
+ assert {"MULTI_LABEL", "UNION"} == SUPERTYPE_STYLES
55
+
56
+
57
+ def test_union_members_and_is_supertype():
58
+ r = _pg()
59
+ assert r.is_supertype("Message")
60
+ assert not r.is_supertype("Post")
61
+ assert not r.is_supertype("Nowhere")
62
+ assert r.supertype_members("Message") == ["Post", "Comment"]
63
+
64
+
65
+ def test_members_may_come_from_the_conceptual_subtypes():
66
+ r = MappingResolver(
67
+ _bundle(
68
+ {
69
+ "Post": {
70
+ "collectionName": "nodes",
71
+ "style": "LABEL",
72
+ "typeField": "type",
73
+ "typeValue": "Post",
74
+ },
75
+ "Comment": {
76
+ "collectionName": "nodes",
77
+ "style": "LABEL",
78
+ "typeField": "type",
79
+ "typeValue": "Comment",
80
+ },
81
+ "Message": {
82
+ "style": "MULTI_LABEL",
83
+ "collectionName": "nodes",
84
+ "labelsField": "labels",
85
+ "labelValue": "Message",
86
+ },
87
+ },
88
+ conceptual={"entities": [{"name": "Message", "subtypes": ["Post", "Comment"]}]},
89
+ )
90
+ )
91
+ assert r.supertype_members("Message") == ["Post", "Comment"]
92
+
93
+
94
+ def test_nested_supertypes_are_flattened_without_duplicates():
95
+ r = _pg(
96
+ Reply={"style": "UNION", "members": ["Comment"]},
97
+ Content={"style": "UNION", "members": ["Message", "Reply", "Post"]},
98
+ )
99
+ assert r.supertype_members("Content") == ["Post", "Comment"]
100
+
101
+
102
+ @pytest.mark.parametrize(
103
+ "extra,needle",
104
+ [
105
+ ({"Message": {"style": "UNION", "members": ["Post", "Ghost"]}}, "'Ghost', which is not mapped"),
106
+ ({"Message": {"style": "UNION"}}, "declares no members"),
107
+ (
108
+ {
109
+ "Message": {"style": "UNION", "members": ["Thread"]},
110
+ "Thread": {"style": "UNION", "members": ["Message"]},
111
+ },
112
+ "cycle",
113
+ ),
114
+ ],
115
+ )
116
+ def test_invalid_hierarchies_are_refused(extra, needle):
117
+ r = _pg(**extra)
118
+ with pytest.raises(CoreError) as exc:
119
+ r.supertype_members("Message")
120
+ assert exc.value.code == "INVALID_MAPPING"
121
+ assert needle in str(exc.value)
122
+
123
+
124
+ def test_a_concrete_label_is_not_a_supertype():
125
+ with pytest.raises(CoreError, match="is not a supertype"):
126
+ _pg().supertype_members("Post")
127
+
128
+
129
+ def test_supertype_without_properties_has_its_members_properties():
130
+ props = _pg().resolve_properties("Message")
131
+ assert set(props) == {"id", "imageFile", "length"}
132
+
133
+
134
+ def test_an_edge_whose_endpoints_are_all_members_guarantees_the_supertype():
135
+ r = _pg()
136
+ assert r.edge_constrains_target("HAS_CREATOR", "Message", "INBOUND")
137
+ assert r.edge_constrains_target("HAS_CREATOR", "Person", "OUTBOUND")
138
+
139
+
140
+ def test_an_edge_that_can_reach_a_non_member_does_not():
141
+ r = _pg(Forum={"collectionName": "forum", "style": "COLLECTION"})
142
+ r.bundle.physical_mapping["relationships"]["HAS_TAG"] = {
143
+ "edgeCollectionName": "has_tag",
144
+ "style": "DEDICATED_COLLECTION",
145
+ "domain": ["Forum", "Post", "Comment"],
146
+ "range": "Tag",
147
+ }
148
+ assert not r.edge_constrains_target("HAS_TAG", "Message", "INBOUND")
149
+
150
+
151
+ def test_existing_mappings_are_unaffected():
152
+ r = MappingResolver(_bundle({"Post": _POST}))
153
+ assert not r.is_supertype("Post")
154
+ assert set(r.resolve_properties("Post")) == {"id", "imageFile"}
155
+
156
+
157
+ def test_union_and_multi_label_round_trip_through_owl_as_subclasses():
158
+ from arango_query_core import mapping_to_turtle, turtle_to_mapping
159
+
160
+ bundle = _bundle(
161
+ {
162
+ "Post": {"collectionName": "post", "style": "COLLECTION"},
163
+ "Comment": {"collectionName": "comment", "style": "COLLECTION"},
164
+ "Message": {"style": "UNION", "members": ["Post", "Comment"]},
165
+ "Thing": {
166
+ "style": "MULTI_LABEL",
167
+ "collectionName": "nodes",
168
+ "labelsField": "labels",
169
+ "labelValue": "Thing",
170
+ },
171
+ },
172
+ conceptual={
173
+ "entities": [
174
+ {"name": "Post"},
175
+ {"name": "Comment"},
176
+ {"name": "Message"},
177
+ {"name": "Thing", "subtypes": ["Post"]},
178
+ ],
179
+ "relationships": [],
180
+ },
181
+ )
182
+ turtle = mapping_to_turtle(bundle)
183
+ assert ":Post rdfs:subClassOf :Message ." in turtle
184
+ back = turtle_to_mapping(turtle)
185
+ pm = back.physical_mapping["entities"]
186
+ assert pm["Message"] == {"style": "UNION", "members": ["Post", "Comment"]}
187
+ assert pm["Thing"]["labelsField"] == "labels" and pm["Thing"]["labelValue"] == "Thing"
188
+ r = MappingResolver(back)
189
+ assert r.supertype_members("Message") == ["Post", "Comment"]
190
+ assert r.supertype_members("Thing") == ["Post"]