Skip to content

Commit 61dabd7

Browse files
authored
Merge pull request #92 from CaliLuke/feat/typedb-3.8.0-support
Polymorphic role player type resolution and comprehensive query tests
2 parents 7c28956 + 20ea0a5 commit 61dabd7

42 files changed

Lines changed: 9475 additions & 339 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/SKILL.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,40 @@ class Company(Entity):
138138
name: Name = Flag(Key)
139139
```
140140

141+
### Role Cardinality
142+
143+
For relations where the same role has multiple players of the same type:
144+
145+
```python
146+
from type_bridge import Relation, Role, Card, TypeFlags
147+
148+
# Exactly 2 Memory entities playing the same role
149+
class IsSimilarTo(Relation):
150+
flags = TypeFlags(name="is_similar_to")
151+
similar_memory: Role[Memory] = Role("similar_memory", Memory, Card(2, 2))
152+
153+
# Generates: relation is_similar_to, relates similar_memory @card(2..2);
154+
```
155+
156+
**Card variations for roles:**
157+
158+
| Python | TypeQL | Meaning |
159+
| ------------ | ------------- | ----------------------------------- |
160+
| `Card(2, 2)` | `@card(2..2)` | Exactly 2 players |
161+
| `Card(1, 3)` | `@card(1..3)` | Between 1 and 3 players |
162+
| `Card(2)` | `@card(2..)` | At least 2 players (no upper bound) |
163+
164+
**Creating instances with multiple role players:**
165+
166+
```python
167+
memory1 = Memory(content=Content("First memory"))
168+
memory2 = Memory(content=Content("Second memory"))
169+
170+
# Pass a list for roles with multiple players
171+
similarity = IsSimilarTo(similar_memory=[memory1, memory2])
172+
manager.insert(similarity)
173+
```
174+
141175
---
142176

143177
## CRUD Operations
@@ -372,6 +406,41 @@ animal_manager = Animal.manager(db)
372406
all_animals = animal_manager.all()
373407
```
374408

409+
### Polymorphic Role Players
410+
411+
When a relation role uses an abstract type, queried role players are resolved to their concrete types:
412+
413+
```python
414+
# Abstract base
415+
class Profile(Entity):
416+
flags = TypeFlags(name="profile", abstract=True)
417+
profile_id: ProfileId = Flag(Key)
418+
419+
# Concrete subtypes
420+
class Person(Profile):
421+
flags = TypeFlags(name="person")
422+
email: Email | None = None
423+
424+
class Organization(Profile):
425+
flags = TypeFlags(name="org")
426+
website: Website | None = None
427+
428+
# Relation with abstract role type
429+
class Authorship(Relation):
430+
flags = TypeFlags(name="authorship")
431+
author: Role[Profile] = Role("author", Profile) # Abstract!
432+
post: Role[Post] = Role("post", Post)
433+
434+
# Query - role players resolved to concrete types
435+
authorships = Authorship.manager(db).all()
436+
for auth in authorships:
437+
# author is Person or Organization, NOT abstract Profile
438+
if isinstance(auth.author, Person):
439+
print(f"Person: {auth.author.email}")
440+
elif isinstance(auth.author, Organization):
441+
print(f"Org: {auth.author.website}")
442+
```
443+
375444
### Serialization
376445

377446
```python

docs/api/crud.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,31 @@ for employment in all_employments:
900900
print(f"{employment.employee.name}: {employment.position}")
901901
```
902902

903+
#### Polymorphic Role Player Resolution
904+
905+
When a role accepts an abstract entity type, queried role players are automatically resolved to their **concrete types**:
906+
907+
```python
908+
# Role accepts abstract type
909+
class Authorship(Relation):
910+
author: Role[Profile] = Role("author", Profile) # Profile is abstract
911+
post: Role[Post] = Role("post", Post)
912+
913+
# Person and Organization both extend Profile
914+
authorships = Authorship.manager(db).all()
915+
916+
for auth in authorships:
917+
# author is resolved to Person or Organization, NOT abstract Profile
918+
# Use isinstance() for instance checks, issubclass() for class checks
919+
assert issubclass(type(auth.author), Profile) # Always true
920+
if isinstance(auth.author, Person):
921+
print(f"Person email: {auth.author.email}") # Person-specific attr
922+
elif isinstance(auth.author, Organization):
923+
print(f"Org website: {auth.author.website}") # Org-specific attr
924+
```
925+
926+
This works in all query methods: `get()`, `all()`, `get_by_iid()`, and `filter().execute()`.
927+
903928
#### Get Relations with Filters
904929

905930
Filter by both attributes and role players:

docs/api/generator.md

Lines changed: 59 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ Generate TypeBridge Python models from TypeDB schema files (`.tql`).
66

77
The generator eliminates manual synchronization between TypeDB schemas and Python code. Instead of writing both `.tql` and Python classes, you write the schema once in TypeQL and generate type-safe Python models.
88

9-
```
9+
```text
1010
schema.tql → generator → attributes.py
1111
→ entities.py
1212
→ relations.py
@@ -47,7 +47,7 @@ generate_models(schema, "./myapp/models/")
4747

4848
## CLI Reference
4949

50-
```
50+
```text
5151
Usage: python -m type_bridge.generator [OPTIONS] SCHEMA
5252
5353
Arguments:
@@ -65,7 +65,7 @@ The `--output` directory is **required**. We recommend a dedicated directory lik
6565

6666
## Generated Package Structure
6767

68-
```
68+
```text
6969
myapp/models/
7070
├── __init__.py # Package exports, SCHEMA_VERSION, schema_text()
7171
├── attributes.py # Attribute class definitions
@@ -97,26 +97,26 @@ print(schema_text())
9797

9898
The generator supports the full TypeDB 3.0 schema syntax:
9999

100-
| Feature | Status |
101-
|---------|--------|
102-
| Attributes with value types ||
103-
| `@abstract` types ||
104-
| `@independent` attributes ||
105-
| `sub` inheritance ||
106-
| `@regex` constraints ||
107-
| `@values` constraints ||
108-
| `@range` constraints ||
109-
| `@key` / `@unique` ||
110-
| `@card` on owns ||
111-
| `@card` on plays ||
112-
| `@card` on relates ||
113-
| `@cascade` on owns ||
114-
| `@subkey` on owns ||
115-
| `@distinct` on relates ||
116-
| Role overrides (`as`) ||
117-
| Functions (`fun`) ||
118-
| Structs (`struct`) ||
119-
| `#` and `//` comments ||
100+
| Feature | Status |
101+
| --------------------------- | ------ |
102+
| Attributes with value types | |
103+
| `@abstract` types | |
104+
| `@independent` attributes | |
105+
| `sub` inheritance | |
106+
| `@regex` constraints | |
107+
| `@values` constraints | |
108+
| `@range` constraints | |
109+
| `@key` / `@unique` | |
110+
| `@card` on owns | |
111+
| `@card` on plays | |
112+
| `@card` on relates | |
113+
| `@cascade` on owns | |
114+
| `@subkey` on owns | |
115+
| `@distinct` on relates | |
116+
| Role overrides (`as`) | |
117+
| Functions (`fun`) | |
118+
| Structs (`struct`) | |
119+
| `#` and `//` comments | |
120120

121121
### Attributes
122122

@@ -266,6 +266,16 @@ class Employment(Relation):
266266
employer: Role[entities.Company] = Role("employer", entities.Company)
267267
employee: Role[entities.Person] = Role("employee", entities.Person)
268268

269+
class Friendship(SocialRelation):
270+
flags = TypeFlags(name="friendship")
271+
# Symmetric role - multiple friends allowed per friendship
272+
friend: Role[entities.Person] = Role("friend", entities.Person, cardinality=Card(0, 1000))
273+
274+
class Parentship(Relation):
275+
flags = TypeFlags(name="parentship")
276+
parent: Role[entities.Person] = Role("parent", entities.Person, cardinality=Card(1, 2))
277+
child: Role[entities.Person] = Role("child", entities.Person, cardinality=Card(1))
278+
269279
class Contribution(Relation):
270280
flags = TypeFlags(name="contribution", abstract=True)
271281
contributor: Role[entities.Contributor] = Role("contributor", entities.Contributor)
@@ -377,15 +387,15 @@ relation friendship,
377387

378388
The following cardinality rules apply to attributes on both **entities** and **relations**:
379389

380-
| TypeQL | Python Type | Default |
381-
|--------|-------------|---------|
382-
| `@card(1)` or `@card(1..1)` | `Type` | Required |
383-
| `@card(0..1)` or no annotation | `Type \| None = None` | Optional |
384-
| `@card(0..)` | `list[Type] = Flag(Card(min=0))` | Optional list |
385-
| `@card(1..)` | `list[Type] = Flag(Card(min=1))` | Required list |
386-
| `@card(2..5)` | `list[Type] = Flag(Card(2, 5))` | Bounded list |
387-
| `@key` | `Type = Flag(Key)` | Key (implies required) |
388-
| `@unique` | `Type = Flag(Unique)` | Unique (implies required) |
390+
| TypeQL | Python Type | Default |
391+
| ------------------------------ | -------------------------------- | ------------------------- |
392+
| `@card(1)` or `@card(1..1)` | `Type` | Required |
393+
| `@card(0..1)` or no annotation | `Type \| None = None` | Optional |
394+
| `@card(0..)` | `list[Type] = Flag(Card(min=0))` | Optional list |
395+
| `@card(1..)` | `list[Type] = Flag(Card(min=1))` | Required list |
396+
| `@card(2..5)` | `list[Type] = Flag(Card(2, 5))` | Bounded list |
397+
| `@key` | `Type = Flag(Key)` | Key (implies required) |
398+
| `@unique` | `Type = Flag(Unique)` | Unique (implies required) |
389399

390400
**Inheritance:** Child types inherit cardinality constraints from parent types. A child can override inherited constraints by redeclaring the attribute with a different `@card`.
391401

@@ -420,14 +430,14 @@ entity user,
420430
owns username @key;
421431
```
422432

423-
| Annotation | Effect |
424-
|------------|--------|
425-
| `# @prefix(XXX)` | Adds `prefix: ClassVar[str] = "XXX"` |
426-
| `# @internal` | Sets `internal = True` on the spec |
427-
| `# @case(SNAKE_CASE)` | Uses specified case for type name |
428-
| `# @transform(xxx)` | Adds `transform = "xxx"` attribute |
429-
| `# @tags(a, b, c)` | Adds list annotation |
430-
| `# Any other comment` | Becomes the class docstring |
433+
| Annotation | Effect |
434+
| --------------------- | ------------------------------------ |
435+
| `# @prefix(XXX)` | Adds `prefix: ClassVar[str] = "XXX"` |
436+
| `# @internal` | Sets `internal = True` on the spec |
437+
| `# @case(SNAKE_CASE)` | Uses specified case for type name |
438+
| `# @transform(xxx)` | Adds `transform = "xxx"` attribute |
439+
| `# @tags(a, b, c)` | Adds list annotation |
440+
| `# Any other comment` | Becomes the class docstring |
431441

432442
## Functions
433443

@@ -479,14 +489,14 @@ fun karma_sum_and_squares() -> double, double:
479489

480490
### Function Return Types
481491

482-
| TypeQL | Parsed `return_type` |
483-
|--------|---------------------|
484-
| `-> { type }` | `"{ type }"` |
485-
| `-> { t1, t2 }` | `"{ t1, t2 }"` |
486-
| `-> type` | `"type"` |
487-
| `-> t1, t2` | `"t1, t2"` |
488-
| `-> t1, t2?` | `"t1, t2?"` |
489-
| `-> bool` | `"bool"` |
492+
| TypeQL | Parsed `return_type` |
493+
| --------------- | -------------------- |
494+
| `-> { type }` | `"{ type }"` |
495+
| `-> { t1, t2 }` | `"{ t1, t2 }"` |
496+
| `-> type` | `"type"` |
497+
| `-> t1, t2` | `"t1, t2"` |
498+
| `-> t1, t2?` | `"t1, t2?"` |
499+
| `-> bool` | `"bool"` |
490500

491501
## API Reference
492502

@@ -582,7 +592,7 @@ class ParameterSpec:
582592

583593
### 1. Keep Generated Code Separate
584594

585-
```
595+
```text
586596
myapp/
587597
├── models/ # Generated (don't edit!)
588598
│ ├── __init__.py

docs/api/relations.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ class Employment(Relation):
103103
```
104104

105105
**Components**:
106+
106107
- `field_name`: Python field name (e.g., `employee`, `employer`)
107108
- `Role[EntityType]`: Type hint for role player type
108109
- `Role("role_name", EntityType)`: Role definition with TypeDB role name and player type
@@ -173,10 +174,12 @@ results = manager.filter(
173174
### Available Methods
174175

175176
**Numeric fields**:
177+
176178
- `.gt(value)`, `.lt(value)`, `.gte(value)`, `.lte(value)`
177179
- `.eq(value)`, `.neq(value)`
178180

179181
**String fields**:
182+
180183
- `.contains(value)` - Substring match
181184
- `.like(value)` - Regex pattern
182185
- `.regex(value)` - Regex pattern (alias)
@@ -242,6 +245,7 @@ email plays trace:origin;
242245
```
243246

244247
### CRUD and queries
248+
245249
- Filtering or deleting by a multi-player role uses the actual player’s key attributes and works across all allowed entity types.
246250
- Role uniqueness rules still apply: keep role names distinct (TypeDB requirement).
247251

@@ -277,6 +281,7 @@ class Employment(Relation):
277281
```
278282

279283
**When explicit flags ARE needed:**
284+
280285
- `abstract=True` - Abstract relations
281286
- `base=True` - Python-only base classes
282287
- Custom `name` - Override type name
@@ -641,6 +646,43 @@ person plays authorship:author;
641646
content plays authorship:content; # Abstract type in role player definition
642647
```
643648

649+
### Polymorphic Role Player Type Resolution
650+
651+
When querying relations with polymorphic role players, TypeBridge resolves each role player to its **concrete type**, not the abstract declared type. This enables type-safe access to subtype-specific attributes:
652+
653+
```python
654+
# Query authorships - role players are resolved to concrete types
655+
authorships = Authorship.manager(db).all()
656+
657+
for authorship in authorships:
658+
content = authorship.content
659+
660+
# content is Article, Video, or other concrete subtype - NOT abstract Content
661+
if isinstance(content, Article):
662+
print(f"Article body: {content.body}")
663+
elif isinstance(content, Video):
664+
print(f"Video URL: {content.url}")
665+
666+
# Common attributes from abstract type are always accessible
667+
print(f"Title: {content.title}")
668+
```
669+
670+
**How it works**:
671+
672+
TypeBridge uses TypeDB's `label()` built-in function to fetch the actual type of each role player, then resolves it to the correct Python class. This happens automatically in:
673+
674+
- `RelationManager.get()` - filter-based queries
675+
- `RelationManager.all()` - fetch all relations
676+
- `RelationManager.get_by_iid()` - IID-based lookup
677+
- `RelationQuery.execute()` - chainable query execution
678+
679+
**Benefits**:
680+
681+
- Access concrete type-specific attributes directly
682+
- Use `isinstance()` for type-safe branching
683+
- Proper Python type inference in IDEs
684+
- No need to manually resolve types from IIDs
685+
644686
## Best Practices
645687

646688
### 1. Use Descriptive Role Names

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ ignore = [
9090
[tool.ruff.lint.per-file-ignores]
9191
"examples/*.py" = ["F841", "E402"]
9292
# Allow unused variables, Function level import in examples for demonstration
93-
"tests/*.py" = ["F841"] # Allow unused variables in tests
93+
"tests/*.py" = ["F841", "N806"] # Allow unused variables and PascalCase class types in tests
9494

9595
[tool.pytest.ini_options]
9696
testpaths = ["tests"]

0 commit comments

Comments
 (0)