Skip to content

Commit b13b9a6

Browse files
committed
feat(orm): cross-type attribute lookup with concrete-class narrowing (#117)
Add Entity.has / Relation.has / <Concrete>.has for finding instances that own a given attribute, optionally filtered by value or expression. Cross-type form (Entity.has(db, Name)) returns mixed concrete subtypes across all entities owning Name. Concrete-class form (HLPerson.has(db, Name)) narrows to that type and its TypeDB subtypes via isa polymorphism — abstract base subclasses get subtype matching for free without any extra branching. Relation results always include hydrated role players. Entity lookups remain single-query; relations are re-fetched per result through concrete_class.manager(connection).get(_iid=iid) to reuse the existing relation manager's role-player extraction. The relation path is therefore N+1 in returned relations, which is acceptable for typical attribute-keyed result sets and is explicitly disclosed in the public docstring. Key changes: - New crud/has_lookup.py with _build_has_query as the single source of truth for query construction, and _hydrate_results split into _hydrate_entity (wildcard fast path) and _hydrate_relation_via_manager (delegates to manager.get for role players). - New TypeDBType.has classmethod that dispatches kind and computes narrow_type via the cls is base_cls check. - Reverse _attribute_owners index in ModelRegistry, populated from Entity / Relation __init_subclass__, plus Attribute.get_owners() for static discovery without a database connection. - Narrowed query uses the existing $t sub <type>; $x isa! $t pattern from crud/typedb_manager.py:597-616. The first attempt with label($x) was rejected by TypeDB 3 because $x is an Object variable, not a Type variable — hence the type-variable dance. Tests: 32 unit + 25 integration covering cross-type, narrowed, and abstract base subclass paths plus role-player hydration on both concrete and cross-type relation receivers.
1 parent cf5bcab commit b13b9a6

9 files changed

Lines changed: 1165 additions & 0 deletions

File tree

Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
"""Integration tests for cross-type attribute lookup (Entity.has / Relation.has).
2+
3+
Tests the TypeQL patterns:
4+
match entity $e; $x isa $e, has Name "Alice"; ...
5+
match relation $r; $x isa $r, has Name $n; ...
6+
"""
7+
8+
import pytest
9+
10+
from type_bridge import (
11+
Entity,
12+
Flag,
13+
Integer,
14+
Key,
15+
Relation,
16+
Role,
17+
SchemaManager,
18+
String,
19+
TypeFlags,
20+
)
21+
22+
# ── Test models ─────────────────────────────────────────────────────
23+
24+
25+
class HLName(String):
26+
"""Shared attribute across entities and relation."""
27+
28+
pass
29+
30+
31+
class HLRegion(String):
32+
pass
33+
34+
35+
class HLSalary(Integer):
36+
pass
37+
38+
39+
class HLPerson(Entity):
40+
flags = TypeFlags(name="hl_person")
41+
name: HLName = Flag(Key)
42+
43+
44+
class HLCompany(Entity):
45+
flags = TypeFlags(name="hl_company")
46+
name: HLName = Flag(Key)
47+
region: HLRegion | None = None
48+
49+
50+
class HLEmployment(Relation):
51+
flags = TypeFlags(name="hl_employment")
52+
employee: Role[HLPerson] = Role("employee", HLPerson)
53+
employer: Role[HLCompany] = Role("employer", HLCompany)
54+
name: HLName | None = None
55+
region: HLRegion | None = None
56+
salary: HLSalary | None = None
57+
58+
59+
# ── Tests ───────────────────────────────────────────────────────────
60+
61+
62+
@pytest.mark.integration
63+
class TestEntityHas:
64+
@pytest.fixture(autouse=True)
65+
def setup(self, clean_db):
66+
self.db = clean_db
67+
sm = SchemaManager(clean_db)
68+
sm.register(HLPerson)
69+
sm.register(HLCompany)
70+
sm.register(HLEmployment)
71+
sm.sync_schema(force=True)
72+
73+
# Insert test data
74+
HLPerson.manager(clean_db).insert(
75+
HLPerson(name=HLName("Alice")),
76+
)
77+
HLPerson.manager(clean_db).insert(
78+
HLPerson(name=HLName("Bob")),
79+
)
80+
HLCompany.manager(clean_db).insert(
81+
HLCompany(name=HLName("Acme"), region=HLRegion("US")),
82+
)
83+
HLCompany.manager(clean_db).insert(
84+
HLCompany(name=HLName("Globex"), region=HLRegion("EU")),
85+
)
86+
87+
# Insert relation
88+
alice = HLPerson.manager(clean_db).get(name="Alice")[0]
89+
acme = HLCompany.manager(clean_db).get(name="Acme")[0]
90+
HLEmployment.manager(clean_db).insert(
91+
HLEmployment(
92+
employee=alice,
93+
employer=acme,
94+
name=HLName("Engineer"),
95+
region=HLRegion("US"),
96+
salary=HLSalary(120000),
97+
),
98+
)
99+
100+
bob = HLPerson.manager(clean_db).get(name="Bob")[0]
101+
globex = HLCompany.manager(clean_db).get(name="Globex")[0]
102+
HLEmployment.manager(clean_db).insert(
103+
HLEmployment(
104+
employee=bob,
105+
employer=globex,
106+
name=HLName("Manager"),
107+
region=HLRegion("EU"),
108+
salary=HLSalary(150000),
109+
),
110+
)
111+
112+
def test_entity_has_exact_match(self):
113+
"""Entity.has with exact value returns only matching entities."""
114+
results = Entity.has(self.db, HLName, "Alice")
115+
assert len(results) == 1
116+
person = results[0]
117+
assert isinstance(person, HLPerson)
118+
assert person.name.value == "Alice"
119+
120+
def test_entity_has_no_value_returns_all_with_attr(self):
121+
"""Entity.has with no value returns all entities owning that attribute."""
122+
results = Entity.has(self.db, HLName)
123+
assert len(results) == 4 # 2 persons + 2 companies
124+
types = {type(r).__name__ for r in results}
125+
assert types == {"HLPerson", "HLCompany"}
126+
127+
def test_entity_has_shared_attr_returns_only_entities(self):
128+
"""Entity.has with Region returns only entities, not relations."""
129+
results = Entity.has(self.db, HLRegion)
130+
assert len(results) == 2 # 2 companies only
131+
for r in results:
132+
assert isinstance(r, HLCompany)
133+
134+
def test_entity_has_comparison(self):
135+
"""Entity.has with comparison expression filters correctly."""
136+
results = Entity.has(self.db, HLName, HLName.gt(HLName("B")))
137+
names = {r.name.value for r in results if isinstance(r, (HLPerson, HLCompany))}
138+
# "Bob" > "B" and "Globex" > "B" (string comparison)
139+
assert "Bob" in names
140+
assert "Globex" in names
141+
assert "Alice" not in names
142+
assert "Acme" not in names
143+
144+
def test_entity_has_with_attribute_instance(self):
145+
"""Entity.has with Attribute instance does exact match."""
146+
results = Entity.has(self.db, HLName, HLName("Bob"))
147+
assert len(results) == 1
148+
person = results[0]
149+
assert isinstance(person, HLPerson)
150+
assert person.name.value == "Bob"
151+
152+
def test_entity_has_returns_iid(self):
153+
"""Returned instances have _iid set."""
154+
results = Entity.has(self.db, HLName, "Alice")
155+
assert len(results) == 1
156+
assert results[0]._iid is not None
157+
158+
def test_entity_has_mixed_types_correct_classes(self):
159+
"""Results contain correct concrete classes, not base Entity."""
160+
results = Entity.has(self.db, HLName)
161+
for r in results:
162+
assert type(r) in (HLPerson, HLCompany)
163+
assert type(r) is not Entity
164+
165+
def test_entity_has_no_match_returns_empty(self):
166+
"""Entity.has with a value that matches nothing returns empty list."""
167+
results = Entity.has(self.db, HLName, "ZZZ_NoSuchName")
168+
assert results == []
169+
170+
def test_entity_has_contains_expression(self):
171+
"""Entity.has with contains expression filters by substring."""
172+
results = Entity.has(self.db, HLName, HLName.contains(HLName("li")))
173+
names = {r.name.value for r in results if isinstance(r, (HLPerson, HLCompany))}
174+
assert "Alice" in names
175+
assert "Bob" not in names
176+
177+
# ── Concrete-class narrowing ────────────────────────────────────
178+
179+
def test_concrete_class_narrows_to_subclass(self):
180+
"""HLPerson.has(HLName) must return only HLPerson, not HLCompany."""
181+
results = HLPerson.has(self.db, HLName)
182+
assert len(results) == 2 # Alice + Bob, NOT Acme/Globex
183+
names: set[str] = set()
184+
for r in results:
185+
assert isinstance(r, HLPerson)
186+
names.add(r.name.value)
187+
assert names == {"Alice", "Bob"}
188+
189+
def test_concrete_class_excludes_other_entity_types(self):
190+
"""HLPerson.has(HLName, "Acme") must return [] — Acme is HLCompany."""
191+
results = HLPerson.has(self.db, HLName, "Acme")
192+
assert results == []
193+
194+
def test_concrete_class_narrows_with_attribute_unique_to_one_type(self):
195+
"""HLCompany.has(HLRegion) returns all companies with a region."""
196+
results = HLCompany.has(self.db, HLRegion)
197+
assert len(results) == 2
198+
for r in results:
199+
assert isinstance(r, HLCompany)
200+
201+
def test_base_entity_has_remains_cross_type(self):
202+
"""Regression guard: Entity.has stays cross-type after narrowing lands."""
203+
results = Entity.has(self.db, HLName)
204+
assert len(results) == 4 # 2 HLPerson + 2 HLCompany
205+
types = {type(r).__name__ for r in results}
206+
assert types == {"HLPerson", "HLCompany"}
207+
208+
209+
@pytest.mark.integration
210+
class TestRelationHas:
211+
@pytest.fixture(autouse=True)
212+
def setup(self, clean_db):
213+
self.db = clean_db
214+
sm = SchemaManager(clean_db)
215+
sm.register(HLPerson)
216+
sm.register(HLCompany)
217+
sm.register(HLEmployment)
218+
sm.sync_schema(force=True)
219+
220+
# Insert entities
221+
HLPerson.manager(clean_db).insert(HLPerson(name=HLName("Alice")))
222+
HLCompany.manager(clean_db).insert(HLCompany(name=HLName("Acme"), region=HLRegion("US")))
223+
224+
# Insert relation
225+
alice = HLPerson.manager(clean_db).get(name="Alice")[0]
226+
acme = HLCompany.manager(clean_db).get(name="Acme")[0]
227+
HLEmployment.manager(clean_db).insert(
228+
HLEmployment(
229+
employee=alice,
230+
employer=acme,
231+
name=HLName("Engineer"),
232+
region=HLRegion("US"),
233+
salary=HLSalary(120000),
234+
),
235+
)
236+
237+
def test_relation_has_exact_match(self):
238+
"""Relation.has with exact value returns only matching relations."""
239+
results = Relation.has(self.db, HLName, "Engineer")
240+
assert len(results) == 1
241+
emp = results[0]
242+
assert isinstance(emp, HLEmployment)
243+
assert emp.name is not None
244+
assert emp.name.value == "Engineer"
245+
246+
def test_relation_has_no_value_returns_all(self):
247+
"""Relation.has with no value returns all relations owning that attr."""
248+
results = Relation.has(self.db, HLName)
249+
assert len(results) == 1
250+
assert isinstance(results[0], HLEmployment)
251+
252+
def test_relation_has_shared_attr_only_relations(self):
253+
"""Relation.has with Region returns only relations, not entities."""
254+
results = Relation.has(self.db, HLRegion)
255+
assert len(results) == 1
256+
assert isinstance(results[0], HLEmployment)
257+
258+
def test_relation_has_returns_iid(self):
259+
"""Returned relation instances have _iid set."""
260+
results = Relation.has(self.db, HLName, "Engineer")
261+
assert len(results) == 1
262+
assert results[0]._iid is not None
263+
264+
def test_relation_has_integer_attr(self):
265+
"""Relation.has with integer attribute works."""
266+
results = Relation.has(self.db, HLSalary, 120000)
267+
assert len(results) == 1
268+
assert isinstance(results[0], HLEmployment)
269+
270+
def test_relation_has_comparison_expression(self):
271+
"""Relation.has with comparison expression on integer attr."""
272+
results = Relation.has(self.db, HLSalary, HLSalary.gt(HLSalary(100000)))
273+
assert len(results) == 1
274+
emp = results[0]
275+
assert isinstance(emp, HLEmployment)
276+
assert emp.salary is not None
277+
assert emp.salary.value == 120000
278+
279+
def test_relation_has_no_match_returns_empty(self):
280+
"""Relation.has with unmatched value returns empty list."""
281+
results = Relation.has(self.db, HLName, "NoSuchRelation")
282+
assert results == []
283+
284+
# ── Concrete-class narrowing ────────────────────────────────────
285+
286+
def test_concrete_relation_narrows_to_subclass(self):
287+
"""HLEmployment.has narrows the match to its concrete type."""
288+
results = HLEmployment.has(self.db, HLName)
289+
assert len(results) == 1
290+
assert isinstance(results[0], HLEmployment)
291+
292+
def test_base_relation_has_remains_cross_type(self):
293+
"""Regression guard: Relation.has stays cross-type after narrowing lands."""
294+
results = Relation.has(self.db, HLName)
295+
assert len(results) == 1
296+
assert isinstance(results[0], HLEmployment)
297+
298+
# ── Role-player hydration (Option B) ────────────────────────────
299+
300+
def test_concrete_relation_hydrates_role_players(self):
301+
"""HLEmployment.has must return relations with role players populated."""
302+
results = HLEmployment.has(self.db, HLName, "Engineer")
303+
assert len(results) == 1
304+
emp = results[0]
305+
assert isinstance(emp, HLEmployment)
306+
assert emp.employee is not None
307+
assert isinstance(emp.employee, HLPerson)
308+
assert emp.employee.name.value == "Alice"
309+
assert emp.employer is not None
310+
assert isinstance(emp.employer, HLCompany)
311+
assert emp.employer.name.value == "Acme"
312+
313+
def test_cross_type_relation_hydrates_role_players(self):
314+
"""Relation.has (cross-type) must also hydrate role players (Option B)."""
315+
results = Relation.has(self.db, HLName)
316+
assert len(results) == 1
317+
emp = results[0]
318+
assert isinstance(emp, HLEmployment)
319+
assert emp.employee is not None
320+
assert emp.employee.name.value == "Alice"
321+
assert emp.employer is not None
322+
assert emp.employer.name.value == "Acme"
323+
324+
def test_relation_has_returns_iid_after_hydration(self):
325+
"""Regression guard: switching to manager.get must still set _iid."""
326+
results = Relation.has(self.db, HLName)
327+
assert len(results) == 1
328+
assert results[0]._iid is not None

0 commit comments

Comments
 (0)