arango-query-core 0.2.4__tar.gz → 0.2.6__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.
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/CHANGELOG.md +39 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/PKG-INFO +1 -1
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/__init__.py +4 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/mapping.py +130 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/owl_turtle.py +30 -6
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/docs/PRD.md +19 -1
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/pyproject.toml +1 -1
- arango_query_core-0.2.6/tests/test_mapping_property_encoding.py +74 -0
- arango_query_core-0.2.6/tests/test_mapping_supertypes.py +190 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/.cursor/rules/fix-upstream-dont-fork.mdc +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/.github/workflows/ci.yml +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/.github/workflows/publish.yml +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/.gitignore +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/LICENSE +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/README.md +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/aql.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/errors.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/exec.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/extensions.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/__init__.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/engine.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/fewshot.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/grounding.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/pathindex.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/postconditions.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/providers.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/seams.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/nl/synthbank.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/owl_rdflib.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/arango_query_core/py.typed +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/fixtures/mappings/pg.owl.ttl +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_mapping_helpers.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_mapping_resolver_normalization.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_mapping_vci_detection.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_engine.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_engine_extraction_and_resilience.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_fewshot.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_grounding.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_pathindex.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_postconditions.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_providers.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_nl_synthbank.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_owl_rdflib.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_owl_turtle_roundtrip.py +0 -0
- {arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_upstream_markers.py +0 -0
|
@@ -6,6 +6,45 @@ 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.6] - 2026-10-29
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Temporal property encodings.** A property may declare how its temporal
|
|
14
|
+
values are stored, `"encoding": "epoch_ms"` (integer milliseconds since the
|
|
15
|
+
epoch) or `"iso8601"` (an ISO-8601 string), read into
|
|
16
|
+
`PropertyInfo.encoding` (empty when not declared) and carried by
|
|
17
|
+
`schema_summary()`. Any other value is refused with `INVALID_MAPPING`.
|
|
18
|
+
The vocabulary is `TEMPORAL_ENCODINGS`. It is a physical detail, like
|
|
19
|
+
`field`, so OWL/Turtle does not carry it. arango-cypher-py lowers temporal
|
|
20
|
+
accessors, functions and arithmetic from it (LDBC WP-41).
|
|
21
|
+
|
|
22
|
+
## [0.2.5] - 2026-10-26
|
|
23
|
+
|
|
24
|
+
### Added
|
|
25
|
+
|
|
26
|
+
- **Supertypes (label hierarchies).** A supertype is a label that stands for
|
|
27
|
+
other labels, such as LDBC's `Message`, which is every `Post` and every
|
|
28
|
+
`Comment`. It is mapped with one of two new entity styles,
|
|
29
|
+
`SUPERTYPE_STYLES`:
|
|
30
|
+
- `MULTI_LABEL`: the members share a collection and list their labels in
|
|
31
|
+
an array field (`labelsField`, `labelValue`);
|
|
32
|
+
- `UNION`: the members have their own collections (`members`).
|
|
33
|
+
- **New resolver methods.** `MappingResolver.is_supertype()` and
|
|
34
|
+
`supertype_members()`. The latter flattens nested supertypes and refuses
|
|
35
|
+
unknown members and cycles with `INVALID_MAPPING`. A supertype without
|
|
36
|
+
properties of its own has its members' properties.
|
|
37
|
+
- **`edge_constrains_target`** treats a supertype target as guaranteed when
|
|
38
|
+
every class an edge endpoint can be is a member: `HAS_CREATOR`'s domain
|
|
39
|
+
`[Post, Comment]` guarantees `Message`.
|
|
40
|
+
- **OWL/Turtle** writes and reads the hierarchy as `rdfs:subClassOf`.
|
|
41
|
+
`turtle_to_mapping` no longer invents a `collectionName` for a `UNION`
|
|
42
|
+
supertype. The rdflib parser does not read `rdfs:subClassOf` yet.
|
|
43
|
+
|
|
44
|
+
The change is additive: mappings without the new styles resolve exactly as
|
|
45
|
+
before. A consumer that switches on `style` and does not handle the new
|
|
46
|
+
values rejects them rather than misreading them.
|
|
47
|
+
|
|
9
48
|
## [0.2.4] - 2026-10-21
|
|
10
49
|
|
|
11
50
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: arango-query-core
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.6
|
|
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,8 @@ 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,
|
|
8
|
+
TEMPORAL_ENCODINGS,
|
|
7
9
|
IndexInfo,
|
|
8
10
|
MappingBundle,
|
|
9
11
|
MappingResolver,
|
|
@@ -23,6 +25,8 @@ except ImportError:
|
|
|
23
25
|
|
|
24
26
|
__all__ = [
|
|
25
27
|
"COLLECTION_NAME_RE",
|
|
28
|
+
"SUPERTYPE_STYLES",
|
|
29
|
+
"TEMPORAL_ENCODINGS",
|
|
26
30
|
"AqlExecutor",
|
|
27
31
|
"AqlFragment",
|
|
28
32
|
"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
|
|
@@ -141,6 +163,14 @@ class IndexInfo:
|
|
|
141
163
|
traversal_safe: bool = True
|
|
142
164
|
|
|
143
165
|
|
|
166
|
+
#: Storage encodings of temporal property values.
|
|
167
|
+
#:
|
|
168
|
+
#: * ``epoch_ms`` — an integer, milliseconds since 1970-01-01T00:00:00Z.
|
|
169
|
+
#: * ``iso8601`` — an ISO-8601 string (``2010-01-03T15:10:31.499+00:00``,
|
|
170
|
+
#: or ``2010-01-03`` for a date).
|
|
171
|
+
TEMPORAL_ENCODINGS = frozenset({"epoch_ms", "iso8601"})
|
|
172
|
+
|
|
173
|
+
|
|
144
174
|
@dataclass(frozen=True)
|
|
145
175
|
class PropertyInfo:
|
|
146
176
|
"""Metadata for a single property/attribute on an entity or relationship.
|
|
@@ -165,6 +195,25 @@ class PropertyInfo:
|
|
|
165
195
|
# / numeric / free_text / boolean / other), inferred by the schema analyzer.
|
|
166
196
|
# Consumers choose a matching strategy from this rather than field names.
|
|
167
197
|
role: str = ""
|
|
198
|
+
# How a temporal value (``type`` date / datetime / …) is stored: one of
|
|
199
|
+
# ``TEMPORAL_ENCODINGS``, or empty when the mapping does not say.
|
|
200
|
+
# Consumers must not guess an encoding: comparisons, components
|
|
201
|
+
# (``.year``) and arithmetic differ between the two.
|
|
202
|
+
encoding: str = ""
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _property_encoding(owner: str, name: str, raw: Any) -> str:
|
|
206
|
+
"""A property's declared ``encoding``, checked against ``TEMPORAL_ENCODINGS``."""
|
|
207
|
+
if raw in (None, ""):
|
|
208
|
+
return ""
|
|
209
|
+
encoding = str(raw)
|
|
210
|
+
if encoding not in TEMPORAL_ENCODINGS:
|
|
211
|
+
raise CoreError(
|
|
212
|
+
f"Property {owner}.{name} declares encoding {encoding!r}; expected one of "
|
|
213
|
+
f"{', '.join(sorted(TEMPORAL_ENCODINGS))}",
|
|
214
|
+
code="INVALID_MAPPING",
|
|
215
|
+
)
|
|
216
|
+
return encoding
|
|
168
217
|
|
|
169
218
|
|
|
170
219
|
@dataclass(frozen=True)
|
|
@@ -391,6 +440,13 @@ class MappingResolver:
|
|
|
391
440
|
return {}
|
|
392
441
|
|
|
393
442
|
props_raw = mapping.get("properties", {})
|
|
443
|
+
if not props_raw and mapping.get("style") in SUPERTYPE_STYLES:
|
|
444
|
+
# A supertype without its own property list has its members' properties.
|
|
445
|
+
merged: dict[str, PropertyInfo] = {}
|
|
446
|
+
for member in self.supertype_members(label_or_type):
|
|
447
|
+
for name, info in self.resolve_properties(member).items():
|
|
448
|
+
merged.setdefault(name, info)
|
|
449
|
+
return merged
|
|
394
450
|
if not isinstance(props_raw, dict):
|
|
395
451
|
return {}
|
|
396
452
|
|
|
@@ -409,6 +465,7 @@ class MappingResolver:
|
|
|
409
465
|
numeric_like=bool(meta.get("numericLike") or meta.get("numeric_like")),
|
|
410
466
|
sample_values=tuple(str(s) for s in samples) if samples else (),
|
|
411
467
|
role=str(meta.get("role") or ""),
|
|
468
|
+
encoding=_property_encoding(label_or_type, name, meta.get("encoding")),
|
|
412
469
|
)
|
|
413
470
|
elif isinstance(meta, str):
|
|
414
471
|
result[name] = PropertyInfo(field=name, type=meta)
|
|
@@ -518,7 +575,16 @@ class MappingResolver:
|
|
|
518
575
|
be in the set **and** every class in the union must map to the same
|
|
519
576
|
physical collection (otherwise multiple collections are reachable and
|
|
520
577
|
IS_SAME_COLLECTION is still needed).
|
|
578
|
+
|
|
579
|
+
A supertype target is guaranteed when every class the endpoint can be
|
|
580
|
+
is one of its members: ``HAS_CREATOR``'s domain ``[Post, Comment]``
|
|
581
|
+
guarantees ``Message``.
|
|
521
582
|
"""
|
|
583
|
+
if self.is_supertype(target_label):
|
|
584
|
+
classes = [endpoint] if isinstance(endpoint, str) else list(endpoint or [])
|
|
585
|
+
members = set(self.supertype_members(target_label))
|
|
586
|
+
return bool(classes) and all(c in members for c in classes)
|
|
587
|
+
|
|
522
588
|
if isinstance(endpoint, str):
|
|
523
589
|
return endpoint == target_label
|
|
524
590
|
|
|
@@ -645,6 +711,68 @@ class MappingResolver:
|
|
|
645
711
|
"""Check if any index on the relationship's edge collection has VCI enabled."""
|
|
646
712
|
return any(idx.vci for idx in self.resolve_indexes(rel_type))
|
|
647
713
|
|
|
714
|
+
# ------------------------------------------------------------------
|
|
715
|
+
# Supertypes (label hierarchies)
|
|
716
|
+
# ------------------------------------------------------------------
|
|
717
|
+
|
|
718
|
+
def is_supertype(self, label: str) -> bool:
|
|
719
|
+
"""True when *label* is mapped with a supertype style (see :data:`SUPERTYPE_STYLES`)."""
|
|
720
|
+
try:
|
|
721
|
+
return self.resolve_entity(label).get("style") in SUPERTYPE_STYLES
|
|
722
|
+
except CoreError:
|
|
723
|
+
return False
|
|
724
|
+
|
|
725
|
+
def _declared_members(self, label: str, emap: JsonObj) -> list[str]:
|
|
726
|
+
members = emap.get("members")
|
|
727
|
+
if members is None:
|
|
728
|
+
for ent in self.bundle.conceptual_schema.get("entities", []) or []:
|
|
729
|
+
if isinstance(ent, dict) and ent.get("name") == label:
|
|
730
|
+
members = ent.get("subtypes")
|
|
731
|
+
break
|
|
732
|
+
if not isinstance(members, list) or not members or not all(isinstance(m, str) and m for m in members):
|
|
733
|
+
raise CoreError(
|
|
734
|
+
f"Supertype {label!r} declares no members: give the physical mapping a 'members' list "
|
|
735
|
+
f"or the conceptual entity a 'subtypes' list",
|
|
736
|
+
code="INVALID_MAPPING",
|
|
737
|
+
)
|
|
738
|
+
return members
|
|
739
|
+
|
|
740
|
+
def supertype_members(self, label: str) -> list[str]:
|
|
741
|
+
"""The concrete (non-supertype) labels a supertype stands for, in declaration order.
|
|
742
|
+
|
|
743
|
+
Nested supertypes are flattened. Raises ``INVALID_MAPPING`` for a
|
|
744
|
+
member that is not mapped or a cycle; raises ``MAPPING_NOT_FOUND`` if
|
|
745
|
+
*label* is not mapped, and ``INVALID_MAPPING`` if it is not a supertype.
|
|
746
|
+
"""
|
|
747
|
+
out: list[str] = []
|
|
748
|
+
|
|
749
|
+
def visit(name: str, path: tuple[str, ...]) -> None:
|
|
750
|
+
if name in path:
|
|
751
|
+
raise CoreError(
|
|
752
|
+
f"Supertype cycle: {' -> '.join((*path, name))}",
|
|
753
|
+
code="INVALID_MAPPING",
|
|
754
|
+
)
|
|
755
|
+
try:
|
|
756
|
+
emap = self.resolve_entity(name)
|
|
757
|
+
except CoreError as e:
|
|
758
|
+
if path:
|
|
759
|
+
raise CoreError(
|
|
760
|
+
f"Supertype {path[-1]!r} lists member {name!r}, which is not mapped",
|
|
761
|
+
code="INVALID_MAPPING",
|
|
762
|
+
) from e
|
|
763
|
+
raise
|
|
764
|
+
if emap.get("style") not in SUPERTYPE_STYLES:
|
|
765
|
+
if not path:
|
|
766
|
+
raise CoreError(f"{name!r} is not a supertype", code="INVALID_MAPPING")
|
|
767
|
+
if name not in out:
|
|
768
|
+
out.append(name)
|
|
769
|
+
return
|
|
770
|
+
for member in self._declared_members(name, emap):
|
|
771
|
+
visit(member, (*path, name))
|
|
772
|
+
|
|
773
|
+
visit(label, ())
|
|
774
|
+
return out
|
|
775
|
+
|
|
648
776
|
def all_entity_labels(self) -> list[str]:
|
|
649
777
|
"""Return all entity type labels defined in the mapping."""
|
|
650
778
|
entities = self.bundle.physical_mapping.get("entities", {})
|
|
@@ -697,6 +825,7 @@ class MappingResolver:
|
|
|
697
825
|
**({"numericLike": True} if p.numeric_like else {}),
|
|
698
826
|
**({"sampleValues": list(p.sample_values)} if p.sample_values else {}),
|
|
699
827
|
**({"role": p.role} if p.role else {}),
|
|
828
|
+
**({"encoding": p.encoding} if p.encoding else {}),
|
|
700
829
|
}
|
|
701
830
|
for name, p in props.items()
|
|
702
831
|
},
|
|
@@ -731,6 +860,7 @@ class MappingResolver:
|
|
|
731
860
|
**({"numericLike": True} if p.numeric_like else {}),
|
|
732
861
|
**({"sampleValues": list(p.sample_values)} if p.sample_values else {}),
|
|
733
862
|
**({"role": p.role} if p.role else {}),
|
|
863
|
+
**({"encoding": p.encoding} if p.encoding else {}),
|
|
734
864
|
}
|
|
735
865
|
for name, p in props.items()
|
|
736
866
|
},
|
|
@@ -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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Temporal property encodings: ``PropertyInfo.encoding``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from arango_query_core import TEMPORAL_ENCODINGS, CoreError, MappingBundle, MappingResolver
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolver(properties: dict, rel_properties: dict | None = None) -> MappingResolver:
|
|
11
|
+
return MappingResolver(
|
|
12
|
+
MappingBundle(
|
|
13
|
+
conceptual_schema={"entities": [], "relationships": []},
|
|
14
|
+
physical_mapping={
|
|
15
|
+
"entities": {
|
|
16
|
+
"Post": {"collectionName": "post", "style": "COLLECTION", "properties": properties},
|
|
17
|
+
"Person": {"collectionName": "person", "style": "COLLECTION", "properties": {}},
|
|
18
|
+
},
|
|
19
|
+
"relationships": {
|
|
20
|
+
"KNOWS": {
|
|
21
|
+
"edgeCollectionName": "knows",
|
|
22
|
+
"style": "DEDICATED_COLLECTION",
|
|
23
|
+
"domain": "Person",
|
|
24
|
+
"range": "Person",
|
|
25
|
+
"properties": rel_properties or {},
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
metadata={},
|
|
30
|
+
)
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_the_vocabulary() -> None:
|
|
35
|
+
assert frozenset({"epoch_ms", "iso8601"}) == TEMPORAL_ENCODINGS
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_a_declared_encoding_is_read_for_entities_and_relationships() -> None:
|
|
39
|
+
resolver = _resolver(
|
|
40
|
+
{"creationDate": {"type": "datetime", "encoding": "epoch_ms"}},
|
|
41
|
+
{"creationDate": {"type": "datetime", "encoding": "iso8601"}},
|
|
42
|
+
)
|
|
43
|
+
assert resolver.resolve_properties("Post")["creationDate"].encoding == "epoch_ms"
|
|
44
|
+
assert resolver.resolve_properties("KNOWS")["creationDate"].encoding == "iso8601"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_no_declaration_means_unknown() -> None:
|
|
48
|
+
props = _resolver({"creationDate": {"type": "datetime"}, "title": "string", "id": None})
|
|
49
|
+
infos = props.resolve_properties("Post")
|
|
50
|
+
assert {name: info.encoding for name, info in infos.items()} == {
|
|
51
|
+
"creationDate": "",
|
|
52
|
+
"title": "",
|
|
53
|
+
"id": "",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.parametrize("bad", ["epochMillis", "ISO8601", "seconds", 13])
|
|
58
|
+
def test_an_unknown_encoding_is_an_invalid_mapping(bad: object) -> None:
|
|
59
|
+
resolver = _resolver({"creationDate": {"type": "datetime", "encoding": bad}})
|
|
60
|
+
with pytest.raises(CoreError, match="Post.creationDate declares encoding") as err:
|
|
61
|
+
resolver.resolve_properties("Post")
|
|
62
|
+
assert err.value.code == "INVALID_MAPPING"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_the_schema_summary_carries_the_encoding_when_declared() -> None:
|
|
66
|
+
summary = _resolver(
|
|
67
|
+
{"creationDate": {"type": "datetime", "encoding": "iso8601"}, "title": {"type": "string"}},
|
|
68
|
+
{"creationDate": {"type": "datetime", "encoding": "epoch_ms"}},
|
|
69
|
+
).schema_summary()
|
|
70
|
+
post = next(e for e in summary["entities"] if e["label"] == "Post")
|
|
71
|
+
assert post["properties"]["creationDate"]["encoding"] == "iso8601"
|
|
72
|
+
assert "encoding" not in post["properties"]["title"]
|
|
73
|
+
knows = next(r for r in summary["relationships"] if r["type"] == "KNOWS")
|
|
74
|
+
assert knows["properties"]["creationDate"]["encoding"] == "epoch_ms"
|
|
@@ -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"]
|
{arango_query_core-0.2.4 → arango_query_core-0.2.6}/.cursor/rules/fix-upstream-dont-fork.mdc
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{arango_query_core-0.2.4 → arango_query_core-0.2.6}/tests/test_mapping_resolver_normalization.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|