From efed551c54e2e5e4c7654c50eac2000a74a56da2 Mon Sep 17 00:00:00 2001 From: schapper Date: Mon, 11 May 2026 14:27:41 -0700 Subject: [PATCH] Refactor relationships Signed-off-by: schapper --- PYDANTIC_GUIDE.md | 277 +++++++++++------- .../schema/buildings/building_part.py | 2 +- .../codegen/extraction/field_constraints.py | 3 + .../tests/codegen_test_support.py | 3 +- .../tests/golden/markdown/venue.md | 2 +- .../tests/test_constraint_description.py | 24 +- .../tests/test_markdown_renderer.py | 4 +- .../src/overture/schema/divisions/division.py | 8 +- .../schema/divisions/division_area.py | 4 +- .../schema/divisions/division_boundary.py | 2 +- .../tests/division_area_baseline_schema.json | 2 +- .../src/overture/schema/system/__init__.py | 2 +- .../src/overture/schema/system/ref/id.py | 4 +- .../src/overture/schema/system/ref/ref.py | 102 ++++--- .../tests/ref/test_ref.py | 44 ++- .../overture/schema/transportation/models.py | 4 +- 16 files changed, 302 insertions(+), 185 deletions(-) diff --git a/PYDANTIC_GUIDE.md b/PYDANTIC_GUIDE.md index ceda6ec01..0d9cfaf66 100644 --- a/PYDANTIC_GUIDE.md +++ b/PYDANTIC_GUIDE.md @@ -542,180 +542,229 @@ Inheriting from `str, Enum` makes enum values work as both enums and strings, wh ### Relationship Patterns -**What are relationships?** Relationships (or "associations") represent connections between different features or models. Think of them like links that connect related pieces of information - for example, a city center that belongs to an administrative area, or a transportation segment that connects to specific intersections. +#### What are relationships? -Pydantic provides several ways to express these relationships, each suited to different use cases and complexity levels. +Relationships represent connections between different features or models. Think of them like links that connect related pieces of information — for example, a building part that is structurally part of a building, or a division area that is administratively nested under a division. -#### 1. Direct References (Foreign Keys) +Pydantic provides several ways to express these relationships, each suited to different use cases and complexity levels. Before choosing a pattern, it's important to understand the **semantic type** of the relationship you're modeling. -The fundamental pattern is a direct reference where one feature "points to" another using an ID field with type safety and semantic information. This creates a one-way relationship - like a building knowing which neighborhood it belongs to, but the neighborhood doesn't automatically know about all its buildings. +--- -```python -from typing import Annotated, Literal -from pydantic import Field -from overture.schema.core import OvertureFeature -from overture.schema.system.ref import Id, Reference, Relationship +#### Semantic Relationship Types -class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]): - """Area polygon that belongs to a division.""" +Every relationship between two features carries a semantic meaning about coupling strength, lifecycle dependency, and ownership. The schema defines four relationship types, ordered from strongest to weakest coupling. The types describe the *nature* of the link, not which feature is "parent" or "child." Direction is implicit: the feature holding the reference is the source, and the type it references is the destination. - # Required reference - every division area must belong to a division - division_id: Annotated[ - Id, - Reference(Relationship.BELONGS_TO, Division), - Field(description="Division this area belongs to") - ] +##### `COMPOSITION` — Structural Whole-Part - # Optional reference - may or may not be associated with a place - place_id: Annotated[ - Id | None, - Reference(Relationship.CONNECTS_TO, Place), - Field(description="Place this area is associated with") - ] = None -``` +A structural whole-part relationship with lifecycle dependency. The part has no independent meaning outside the whole. Deleting the whole invalidates the part. -**Available relationship types (see [Relationship](packages/overture-schema-system/src/overture/schema/system/ref/ref.py)):** +**Test question:** *"If I delete the whole, does keeping the part orphaned make any sense at all?"* If the answer is no, it's `COMPOSITION`. -- **`BELONGS_TO`**: The referencing feature belongs to the referenced feature (division area belongs to division) -- **`CONNECTS_TO`**: The referencing feature connects to the referenced feature (segment connects to connector) -- **`BOUNDARY_OF`**: The referencing feature forms a boundary of the referenced feature (boundary line defines area) +**Examples:** +- `BuildingPart` → `Building` — part *is part of* building +- `DivisionBoundary` → `Division` — boundary line *defines the boundary of* division -#### 2. Association as a Separate Feature (Complex Relationships) +##### `AGGREGATION` — Grouping Without Lifecycle Dependency -When the relationship itself needs to store information, create a dedicated feature to represent that relationship. This is like creating a "relationship record" that describes how two things are connected. +A grouping or collection relationship where both members are independently viable. No lifecycle dependency — the member survives reassignment to another group or orphaning. -**Simple relationship (use Pattern 1):** +**Test question:** *"Can both sides belong to something else or nothing and still be a valid map feature?"* If yes, they form an `AGGREGATION`. -- "Building A belongs to Neighborhood B" - just needs an ID reference +**Examples:** +- `Route` → `Segment` — route *groups* segments +- `TrailSegment` → `NationalPark` — segment *is grouped by* park -**Complex relationship (use Pattern 2):** +##### `HIERARCHY` — Organizational Nesting -- "Admin Area X has City Center Y as its primary center since 2010 with 85% confidence" - the relationship has properties (`type=primary`, `date=2010`, `confidence=85%`) +An organizational or classificatory nesting relationship. This is not about structural assembly — it's about administrative parentage, taxonomy, or categorization. -```python -class AdminCityCenterAssociation(OvertureFeature[Literal["associations"], Literal["admin_city_center"]]): - """Describes how an administrative area relates to a city center.""" +**Test question:** *"Is this about organizational subordination rather than structural assembly?"* If yes, it's `HIERARCHY`. - # The two things being connected - admin_area_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, AdminArea)] - city_center_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, CityCenter)] +**Examples:** +- `DivisionArea` → `Division` — area *is child of* division +- `Division` → `Division` — child division nested under parent - # Information about the relationship itself - relationship_type: Literal["primary_center", "secondary_center"] = "primary_center" - established_date: str | None = None - confidence_score: Annotated[float64, Field(ge=0.0, le=1.0)] | None = None +##### `ASSOCIATION` — Peer-Level Reference + +A peer-level reference with no ownership, containment, or nesting. Neither feature depends on or contains the other. This is the fallback when none of the stronger types apply. + +**Test question:** *"Are these just peers that know about each other?"* If yes, it's `ASSOCIATION`. + +**Examples:** +- `Segment` → `Connector` — segment references its start/end connector +- `Building` → `Address` — a building references its address, neither owns the other + +--- + +#### Selection Priority + +When a relationship could fit multiple types, the choice follows a **diamond decision**: start at the top, fork in the middle based on the *kind* of coupling, and fall through to the bottom only when no stronger type applies. + +```text + COMPOSITION + / \ +AGGREGATION HIERARCHY + \ / + ASSOCIATION ``` -When to use separate association features: +| If the relationship implies... | Use | +|---------------------------------------------------------|----------------| +| Structural whole-part with lifecycle dependency | `COMPOSITION` | +| Geometric boundary definition (lifecycle dependent) | `COMPOSITION` | +| Grouping/collection without lifecycle dependency | `AGGREGATION` | +| Organizational nesting or classification tree | `HIERARCHY` | +| Peer-level reference, no ownership or nesting | `ASSOCIATION` | -- The relationship has properties: confidence scores, dates, types, notes -- Many-to-many connections: one admin area can have multiple city centers, one city center can serve multiple admin areas -- You need to query the relationships: "show me all primary city center relationships established after 2015" +--- -This focuses on the core concept: when relationships carry data, they become features themselves. +#### The `role` Field -#### 3. Collection References +The `Reference` annotation accepts an optional `role` parameter — a snake_case string that further qualifies the relationship from the source's perspective. It has no effect on schema validation; it is informational metadata for documentation and tooling. -When a feature needs to reference multiple other features, use a list of references: +Use `role` when the semantic type alone is ambiguous. For example, multiple `HIERARCHY` references on the same model can be disambiguated: ```python -class Route(OvertureFeature[Literal["transportation"], Literal["route"]]): - """A transportation route that passes through multiple segments.""" +# Without role: two HIERARCHY references to Division — which is which? +parent_division_id: Annotated[Id, Reference(Relationship.HIERARCHY, Division)] +capital_division_ids: Annotated[list[Id], Reference(Relationship.HIERARCHY, Division)] - segment_ids: Annotated[ - list[Id], - Field(min_length=1, description="Ordered list of segments in this route"), - UniqueItemsConstraint(), # No duplicate segments - Reference(Relationship.CONNECTS_TO, TransportationSegment) # All IDs reference segments - ] +# With role: unambiguous +parent_division_id: Annotated[Id, Reference(Relationship.HIERARCHY, Division, role="child_of")] +capital_division_ids: Annotated[list[Id], Reference(Relationship.HIERARCHY, Division, role="has_as_capital")] +``` -class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): - """A building that may contain multiple building parts.""" +The `role` must be a non-empty snake_case string (lowercase letters, digits, underscores). It describes the source's role relative to the target using source-perspective phrasing. - part_ids: Annotated[ - list[Id] | None, - Field(description="Building parts that belong to this building"), - Reference(Relationship.BOUNDARY_OF, BuildingPart) # All IDs reference building parts - ] = None -``` +--- -#### Best Practices +#### Implementation Patterns -##### Always Use Reference Annotations +##### 1. Direct References (Foreign Keys) -Include `Reference` annotations for semantic clarity and documentation: +The fundamental pattern is a direct reference where one feature "points to" another using an ID field with type safety and semantic information. ```python -# Good - complete relationship information -division_id: Annotated[ - Id, - Reference(Relationship.BELONGS_TO, Division), - Field(description="Division this area belongs to") -] +from typing import Annotated, Literal +from pydantic import Field +from overture.schema.core import OvertureFeature +from overture.schema.system.ref import Id, Reference, Relationship -# Avoid - missing semantic information -division_id: Id +# COMPOSITION — part points to its whole +class BuildingPart(OvertureFeature[Literal["buildings"], Literal["building_part"]]): + """A structural part of a building.""" + building_id: Annotated[ + Id, + Reference(Relationship.COMPOSITION, Building, role="part_of"), + Field(description="The building to which this part belongs") + ] + +# HIERARCHY — child points to parent +class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]): + """Area polygon nested under a division.""" + division_id: Annotated[ + Id, + Reference(Relationship.HIERARCHY, Division, role="child_of"), + Field(description="Division ID of the parent division of this area.") + ] + +# ASSOCIATION — peer reference, no ownership +class ConnectorReference(BaseModel): + """Reference to a connector feature.""" + connector_id: Annotated[ + Id, + Reference(Relationship.ASSOCIATION, Connector, role="connects_to") + ] ``` -##### Choose the Right Pattern +##### 2. Association as a Separate Feature (Complex Relationships) + +When the relationship itself needs to store information, create a dedicated feature to represent it. This applies regardless of the semantic type — any of the four types can carry metadata. + +**Simple relationship (use Pattern 1):** +- "Building Part A is part of Building B" — just needs an ID reference. -- **Simple relationships** → Direct references (foreign keys) -- **Relationships with metadata** → Separate association features +**Complex relationship (use Pattern 2):** +- "Admin Area X has City Center Y as its primary center since 2010 with 85% confidence" — the relationship has properties. ```python -# Simple: just a link -admin_area_id: Annotated[Id, Reference(Relationship.BELONGS_TO, AdminArea)] +class AdminCityCenterAssociation( + OvertureFeature[Literal["associations"], Literal["admin_city_center"]] +): + """Describes how an administrative area relates to a city center.""" -# Complex: relationship has properties -class AdminCityCenterAssociation(Feature[...]): - admin_area_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, AdminArea)] - city_center_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, CityCenter)] - relationship_type: Literal["primary", "secondary"] = "primary" + admin_area_id: Annotated[ + Id, + Reference(Relationship.ASSOCIATION, AdminArea) + ] + city_center_id: Annotated[ + Id, + Reference(Relationship.ASSOCIATION, CityCenter) + ] + + # Information about the relationship itself + relationship_type: Literal["primary_center", "secondary_center"] = "primary_center" + established_date: str | None = None + confidence_score: Annotated[float64, Field(ge=0.0, le=1.0)] | None = None ``` -#### Association Pattern Examples +**When to use separate association features:** +- The relationship has properties (confidence scores, dates, types, notes). +- Many-to-many connections exist. +- You need to query the relationships independently. + +##### 3. Collection References -**Transportation Network:** +When a feature needs to reference multiple other features, use a list of references. The semantic type still matters. ```python -# Segments connect to connectors (intersection points) -class Segment(OvertureFeature[Literal["transportation"], Literal["segment"]]): - from_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)] - to_connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, Connector)] +# COMPOSITION — boundary defines two divisions +class DivisionBoundary(OvertureFeature[Literal["divisions"], Literal["division_boundary"]]): + """A boundary line between two divisions.""" + division_ids: Annotated[ + list[Annotated[Id, Reference(Relationship.COMPOSITION, Division, role="boundary_of")]], + Field(min_length=2, max_length=2, description="Left and right divisions"), + ] -# Routes contain multiple segments in order -class Route(Feature[Literal["transportation"], Literal["route"]]): +# AGGREGATION — route groups segments +class Route(OvertureFeature[Literal["transportation"], Literal["route"]]): + """A transportation route passing through multiple segments.""" segment_ids: Annotated[ list[Id], - Reference(Relationship.CONNECTS_TO, TransportationSegment), - Field(description="Ordered list of segments in this route") + Reference(Relationship.AGGREGATION, TransportationSegment, role="groups"), + Field(min_length=1, description="Ordered segments in this route"), + UniqueItemsConstraint(), ] ``` -**Administrative Hierarchy:** +--- -```python -# Division areas belong to divisions -class DivisionArea(OvertureFeature[Literal["divisions"], Literal["division_area"]]): - division_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Division)] +#### Best Practices -# Places belong to administrative areas -class Place(OvertureFeature[Literal["places"], Literal["place"]]): - admin_area_id: Annotated[Id | None, Reference(Relationship.BELONGS_TO, AdminArea)] = None -``` +##### Always Use Reference Annotations -**Building Relationships:** +Include `Reference` annotations for semantic clarity and documentation: ```python -# Building parts belong to buildings -class BuildingPart(OvertureFeature[Literal["buildings"], Literal["building_part"]]): - building_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Building)] +# Good — complete relationship information with semantic type and role +division_id: Annotated[ + Id, + Reference(Relationship.HIERARCHY, Division, role="child_of"), + Field(description="Division ID of the parent division of this area.") +] -# Buildings can reference their address -class Building(OvertureFeature[Literal["buildings"], Literal["building"]]): - address_id: Annotated[Id | None, Reference(Relationship.CONNECTS_TO, Address)] = None +# Avoid — missing semantic information +division_id: Id ``` +##### Choose the Right Semantic Type First, Then the Right Pattern + +1. **Determine the semantic type** using the selection priority and test questions above. +2. **Then choose the implementation pattern:** + - Simple relationships → Direct references (Pattern 1) + - Relationships with metadata → Separate association features (Pattern 2) + - One-to-many references → Collection references (Pattern 3) + ### Discriminated Unions **What is a discriminated union?** A discriminated union is a type that can be backed by one of several different models, where a specific field (the "discriminator") determines which model it actually is. Think of it like a form that changes its fields based on a category selection. diff --git a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py index 980645101..533f4623a 100644 --- a/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py +++ b/packages/overture-schema-buildings-theme/src/overture/schema/buildings/building_part.py @@ -52,5 +52,5 @@ class BuildingPart( building_id: Annotated[ Id, Field(description="The building to which this part belongs"), - Reference(Relationship.BELONGS_TO, Building), + Reference(Relationship.COMPOSITION, Building, role="part_of"), ] diff --git a/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py b/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py index 5cdc3dcd2..0db927065 100644 --- a/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py +++ b/packages/overture-schema-codegen/src/overture/schema/codegen/extraction/field_constraints.py @@ -96,6 +96,9 @@ def describe_field_constraint( target = constraint.relatee target_id = TypeIdentity.of(target) target_str = link_fn(target_id) if link_fn else f"`{target.__name__}`" + if constraint.role: + role_label = constraint.role.replace("_", " ") + return f"References {target_str} ({rel_label}, {role_label})" return f"References {target_str} ({rel_label})" if isinstance(constraint, Interval): desc = _describe_interval(constraint) diff --git a/packages/overture-schema-codegen/tests/codegen_test_support.py b/packages/overture-schema-codegen/tests/codegen_test_support.py index 64facf5a9..4df4c0e04 100644 --- a/packages/overture-schema-codegen/tests/codegen_test_support.py +++ b/packages/overture-schema-codegen/tests/codegen_test_support.py @@ -122,7 +122,8 @@ class Venue( ] capacity: Annotated[int, Field(ge=1)] | None = None resident_ensemble: ( - Annotated[Id, Reference(Relationship.BELONGS_TO, Instrument)] | None + Annotated[Id, Reference(Relationship.AGGREGATION, Instrument, role="part_of")] + | None ) = None diff --git a/packages/overture-schema-codegen/tests/golden/markdown/venue.md b/packages/overture-schema-codegen/tests/golden/markdown/venue.md index edb0578ef..1c76511a4 100644 --- a/packages/overture-schema-codegen/tests/golden/markdown/venue.md +++ b/packages/overture-schema-codegen/tests/golden/markdown/venue.md @@ -15,7 +15,7 @@ A location where musical performances take place. | `description` | `string` (optional) | *At least one of `name`, `description` must be set* | | `geometry` | `geometry` | *Allowed geometry types: Point, Polygon* | | `capacity` | `int64` (optional) | *`≥ 1`* | -| `resident_ensemble` | `Id` (optional) | A unique identifier

*References `Instrument` (belongs to)* | +| `resident_ensemble` | `Id` (optional) | A unique identifier

*References `Instrument` (aggregation, part of)* | ## Constraints diff --git a/packages/overture-schema-codegen/tests/test_constraint_description.py b/packages/overture-schema-codegen/tests/test_constraint_description.py index 9961ef2b2..cd31f2554 100644 --- a/packages/overture-schema-codegen/tests/test_constraint_description.py +++ b/packages/overture-schema-codegen/tests/test_constraint_description.py @@ -382,22 +382,22 @@ def test_geometry_type_all_types(self) -> None: == "Allowed geometry types: LineString, Point, Polygon" ) - def test_reference_belongs_to(self) -> None: + def test_reference_composition(self) -> None: class Target(Identified): pass - constraint = Reference(Relationship.BELONGS_TO, Target) + constraint = Reference(Relationship.COMPOSITION, Target) assert ( - describe_field_constraint(constraint) == "References `Target` (belongs to)" + describe_field_constraint(constraint) == "References `Target` (composition)" ) - def test_reference_connects_to(self) -> None: + def test_reference_association(self) -> None: class Other(Identified): pass - constraint = Reference(Relationship.CONNECTS_TO, Other) + constraint = Reference(Relationship.ASSOCIATION, Other) assert ( - describe_field_constraint(constraint) == "References `Other` (connects to)" + describe_field_constraint(constraint) == "References `Other` (association)" ) def test_reference_link_fn_receives_type_identity(self) -> None: @@ -412,13 +412,13 @@ def link_fn(tid: TypeIdentity) -> str: received.append(tid) return f"[`{tid.name}`](link)" - constraint = Reference(Relationship.BELONGS_TO, Target) + constraint = Reference(Relationship.COMPOSITION, Target) result = describe_field_constraint(constraint, link_fn=link_fn) assert len(received) == 1 assert received[0].obj is Target assert received[0].name == "Target" - assert result == "References [`Target`](link) (belongs to)" + assert result == "References [`Target`](link) (composition)" def test_reference_link_fn_used_in_output(self) -> None: """link_fn return value appears verbatim in the description.""" @@ -426,11 +426,11 @@ def test_reference_link_fn_used_in_output(self) -> None: class Target(Identified): pass - constraint = Reference(Relationship.CONNECTS_TO, Target) + constraint = Reference(Relationship.ASSOCIATION, Target) result = describe_field_constraint( constraint, link_fn=lambda tid: f"[`{tid.name}`](path/to/target)" ) - assert result == "References [`Target`](path/to/target) (connects to)" + assert result == "References [`Target`](path/to/target) (association)" class TestConstraintDisplayText: @@ -442,7 +442,7 @@ def test_link_fn_forwarded_to_reference_constraint(self) -> None: class Target(Identified): pass - constraint = Reference(Relationship.BELONGS_TO, Target) + constraint = Reference(Relationship.COMPOSITION, Target) cs = ConstraintSource(source_ref=None, source_name=None, constraint=constraint) received: list[TypeIdentity] = [] @@ -455,4 +455,4 @@ def link_fn(tid: TypeIdentity) -> str: assert len(received) == 1 assert received[0].obj is Target - assert result == "References [`Target`](link) (belongs to)" + assert result == "References [`Target`](link) (composition)" diff --git a/packages/overture-schema-codegen/tests/test_markdown_renderer.py b/packages/overture-schema-codegen/tests/test_markdown_renderer.py index 5ebddcab9..698f9d70a 100644 --- a/packages/overture-schema-codegen/tests/test_markdown_renderer.py +++ b/packages/overture-schema-codegen/tests/test_markdown_renderer.py @@ -618,7 +618,7 @@ def test_venue_reference_links_when_context_available(self) -> None: lines = result.splitlines() ref_line = next(line for line in lines if "| `resident_ensemble` |" in line) assert "[`Instrument`](instrument.md)" in ref_line - assert "belongs to" in ref_line + assert "aggregation, part of" in ref_line def test_venue_reference_unlinked_without_context(self) -> None: """Reference constraint renders as plain code when no LinkContext.""" @@ -629,7 +629,7 @@ def test_venue_reference_unlinked_without_context(self) -> None: lines = result.splitlines() ref_line = next(line for line in lines if "| `resident_ensemble` |" in line) assert "References `Instrument`" in ref_line - assert "belongs to" in ref_line + assert "aggregation, part of" in ref_line class TestRenderEnumBasic: diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py index 865caca4c..955b8d723 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division.py @@ -116,7 +116,7 @@ class CapitalOfDivisionItem(BaseModel): division_id: Annotated[ Id, Field(description="ID of the division whose capital is the current division."), - Reference(Relationship.CAPITAL_OF, Division), + Reference(Relationship.HIERARCHY, Division, role="capital_of"), ] subtype: DivisionSubtype @@ -139,7 +139,7 @@ class HierarchyItem(BaseModel): the division itself, and any other division that is an ancestor of the division's parent. """).strip() ), - Reference(Relationship.DESCENDANT_OF, Division), + Reference(Relationship.HIERARCHY, Division, role="descendant_of"), ] subtype: DivisionSubtype name: Annotated[ @@ -276,7 +276,7 @@ class Division( parent divisions. """).strip() ), - Reference(Relationship.CHILD_OF, Division), + Reference(Relationship.HIERARCHY, Division, role="child_of"), ] = None admin_level: AdminLevel | None = None @@ -382,7 +382,7 @@ class Division( list[ Annotated[ Id, - Reference(Relationship.CAPITALLED_BY, Division), + Reference(Relationship.HIERARCHY, Division, role="has_as_capital"), ] ] | None, diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py index 677906abb..61a30f7ab 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_area.py @@ -132,9 +132,9 @@ class DivisionArea( division_id: Annotated[ Id, Field( - description="Division ID of the division this area belongs to.", + description="Division ID of the parent division of this area.", ), - Reference(Relationship.BELONGS_TO, Division), + Reference(Relationship.HIERARCHY, Division, role="child_of"), ] country: Annotated[ CountryCodeAlpha2, diff --git a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py index 0fcf751f9..2a6fd886d 100644 --- a/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py +++ b/packages/overture-schema-divisions-theme/src/overture/schema/divisions/division_boundary.py @@ -125,7 +125,7 @@ class DivisionBoundary( list[ Annotated[ Id, - Reference(Relationship.BOUNDARY_OF, Division), + Reference(Relationship.COMPOSITION, Division, role="boundary_of"), ] ], Field( diff --git a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json index 8f800c551..3838c20e0 100644 --- a/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json +++ b/packages/overture-schema-divisions-theme/tests/division_area_baseline_schema.json @@ -517,7 +517,7 @@ "type": "string" }, "division_id": { - "description": "Division ID of the division this area belongs to.", + "description": "Division ID of the parent division of this area.", "minLength": 1, "pattern": "^\\S+$", "title": "Division Id", diff --git a/packages/overture-schema-system/src/overture/schema/system/__init__.py b/packages/overture-schema-system/src/overture/schema/system/__init__.py index 1e4b8be72..26ebf5085 100644 --- a/packages/overture-schema-system/src/overture/schema/system/__init__.py +++ b/packages/overture-schema-system/src/overture/schema/system/__init__.py @@ -144,7 +144,7 @@ >>> class Park(Identified): ... pass >>> class ParkBench(Identified): -... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)] +... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")] """ from . import ( diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/id.py b/packages/overture-schema-system/src/overture/schema/system/ref/id.py index 2ffa5dad8..5e48af68a 100644 --- a/packages/overture-schema-system/src/overture/schema/system/ref/id.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/id.py @@ -44,8 +44,8 @@ class Identified(BaseModel): ... name: str = Field(description = 'Name of the room') ... house_id: Annotated[ ... Id, - ... Reference(Relationship.BELONGS_TO, House) - ... ] = Field(description = "Unique ID of the house the room belongs to.") + ... Reference(Relationship.COMPOSITION, House, role="inside_of") + ... ] = Field(description = "Unique ID of the house the room is inside of.") When combining `Identified` with another Pydantic model that has an `id` field, such as a :class:`~overture.schema.system.feature.Feature`, you must derive from `Identified` first in diff --git a/packages/overture-schema-system/src/overture/schema/system/ref/ref.py b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py index ff712104b..a5e74cd94 100644 --- a/packages/overture-schema-system/src/overture/schema/system/ref/ref.py +++ b/packages/overture-schema-system/src/overture/schema/system/ref/ref.py @@ -2,83 +2,111 @@ Relationships and references between related entities. """ +import re from dataclasses import dataclass -from enum import Enum + +from overture.schema.system.doc import DocumentedEnum from .id import Identified +_SNAKE_CASE_RE = re.compile(r"^[a-z][a-z0-9]*(_[a-z0-9]+)*$") + -class Relationship(Enum): +class Relationship(str, DocumentedEnum): """ - Category of relationship between two values, where the first value refers to the second one. + The kind of relationship that exists between two entities. + + Relationships represent connections between different features or models. Think of them as links + that connect related pieces of information, like a building part that is structurally part of a + building; or a division area that is administratively nested under a division. + + Every kind of relationship between two entities says something about how tightly those entities + are connected, for example: whether one depends on the other to exist (composition); whether + both members are strongly-connected but independently viable (aggregation); whether one of the + two is superior while the other is subordinate (hierarchy); or whether they are simply peers + with a loose affiliation to one another (association). + + The kinds of relationship can be thought of as forming a diamond-shaped hierarchy where + composition, the strongest form of relationship, appears at the top; aggregation and hierarchy + as independent relationship types that are weaker than composition but stronger than + association, appears in the middle; and association, the weakest and least well-defined form of + relationship, is at the bottom. + + COMPOSITION + / \\ + AGGREGATION HIERARCHY + \\ / + ASSOCIATION - If we call the first value, the one that holds the reference, the relator; and the second value, - value, the one that is referred to, as the relatee; then this value represents the relationship - from the perspective of the relator. + Note that the *kind* of a relationship does not say anything about the *directionality* of the + relationship. The fact that F is in a hierarchy relationship with G does not, without outside + information, tell you whether F is the parent or the child. """ - def __init__(self, value: str, doc: str) -> None: - self._value_ = value - self.__doc__ = doc - - BELONGS_TO = "belongs_to", "The relator belongs to the relatee" - BOUNDARY_OF = "boundary_of", "The relator is a boundary of the relatee" - CAPITAL_OF = "capital_of", "The relator is a capital of the relatee" - CAPITALLED_BY = "capitalled_by", "The relator has the relatee as its capital" - CHILD_OF = "child_of", "The relator is a child of the relatee" - CONNECTS_TO = "connects_to", "The relator connects to the relatee" - DESCENDANT_OF = ( - "descendant_of", - "The relator is a hierarchical descendant of the relatee", + COMPOSITION = ( + "composition", + "A structural whole-part relationship with lifecycle dependency", ) + AGGREGATION = "aggregation", "A grouping relationship without lifecycle dependency" + HIERARCHY = ( + "hierarchy", + "A parent/child relationship within a hierarchy such as an organization or taxonomy", + ) + ASSOCIATION = "association", "A peer-level reference without ownership or nesting" @dataclass(frozen=True, slots=True) class Reference: """ - Annotation class describing a relationship between two values where the relatee is referenced - by its unique ID. + Annotation class describing a relationship between two values where the relator refers to the + relatee by the latter's unique ID. + + The relator, which is the subject or source of the relationship, is the type annotated with an + instance of this class ("the thing that relates"). The relatee is type that is the object or + target of the relationship ("the thing related to"). Parameters ---------- relationship : Relationship - The kind of relationship between the relator (the type annotated with an instance of this - class that is said to "hold the reference") and the relatee. + The category of the relationship between the relator and the relatee. relatee : type[Identified] - The type that is the object or target of the relationship ("the thing related to"). - - Attributes - ---------- - relationship : Relationship - The kind of relationship between the relator (the type annotated with an instance of this - class that is said to "hold the reference") and the relatee. - relatee : type[Identifier] - The type that is the object or target of the relationship ("the thing related to"). + The type that is the target of the relationship. + role : str | None + An optional snake_case descriptor that further describes the relationship from the + perspective of the relator. This field has no effect on schema validation; it is + informational metadata for documentation and tooling. Examples -------- - A hypothetical ParkBench model holds a foreign key relationship to the model of the park the - bench belongs to. + A hypothetical ParkBench model holds a foreign key relationship to the model of the park that + contains it. >>> from typing import Annotated >>> from overture.schema.system.ref import Id, Identified >>> class Park(Identified): ... pass >>> class ParkBench(Identified): - ... park_id: Annotated[Id, Reference(Relationship.BELONGS_TO, Park)] + ... park_id: Annotated[Id, Reference(Relationship.COMPOSITION, Park, role="located_in")] """ relationship: Relationship relatee: type[Identified] + role: str | None = None def __post_init__(self) -> None: if not isinstance(self.relationship, Relationship): raise TypeError( - f"`relationship` must be a member of the `Relationship` enumeration, but {self.relationship} is a `{type(self.relationship).__name__}`" + f"`relationship` must be a member of the `Relationship` enumeration, " + f"but {self.relationship} is a `{type(self.relationship).__name__}`" ) if not isinstance(self.relatee, type) or not issubclass( self.relatee, Identified ): raise TypeError( - f"`relatee` must be a type derived from `Identified`, but {self.relatee} is a `{type(self.relatee).__name__}`" + f"`relatee` must be a type derived from `Identified`, " + f"but {self.relatee} is a `{type(self.relatee).__name__}`" + ) + if self.role is not None and not _SNAKE_CASE_RE.match(self.role): + raise ValueError( + f"`role` must be a non-empty snake_case string, but got {self.role!r}" ) diff --git a/packages/overture-schema-system/tests/ref/test_ref.py b/packages/overture-schema-system/tests/ref/test_ref.py index 98bc0d610..b32d858f6 100644 --- a/packages/overture-schema-system/tests/ref/test_ref.py +++ b/packages/overture-schema-system/tests/ref/test_ref.py @@ -9,19 +9,53 @@ def test_reference_err_not_a_relationship() -> None: Reference("foo", Identified) # type: ignore[arg-type] -def test_reference_err_referee_not_a_feature_type() -> None: +def test_reference_err_relatee_not_a_feature_type() -> None: with pytest.raises(TypeError): - Reference(Relationship.BELONGS_TO, str) # type: ignore[arg-type] + Reference(Relationship.COMPOSITION, str) # type: ignore[arg-type] -def test_reference_err_referee_not_a_type() -> None: +def test_reference_err_relatee_not_a_type() -> None: with pytest.raises(TypeError): - Reference(Relationship.BELONGS_TO, "foo") # type: ignore[arg-type] + Reference(Relationship.COMPOSITION, "foo") # type: ignore[arg-type] + + +def test_reference_err_role_not_snake_case() -> None: + with pytest.raises(ValueError): + Reference(Relationship.COMPOSITION, Identified, role="NotSnakeCase") + + +def test_reference_err_role_empty() -> None: + with pytest.raises(ValueError): + Reference(Relationship.COMPOSITION, Identified, role="") + + +def test_reference_err_role_has_spaces() -> None: + with pytest.raises(ValueError): + Reference(Relationship.COMPOSITION, Identified, role="has spaces") @pytest.mark.parametrize("relationship", tuple(Relationship)) -def test_reference_ok(relationship: Relationship) -> None: +def test_reference_ok_without_role(relationship: Relationship) -> None: ref = Reference(relationship, Identified) assert ref.relationship is relationship assert ref.relatee is Identified + assert ref.role is None + + +@pytest.mark.parametrize("relationship", tuple(Relationship)) +def test_reference_ok_with_role(relationship: Relationship) -> None: + ref = Reference(relationship, Identified, role="belongs_to") + + assert ref.relationship is relationship + assert ref.relatee is Identified + assert ref.role == "belongs_to" + + +@pytest.mark.parametrize( + "role", + ["part_of", "child_of", "connects_to", "boundary_of", "has_as_capital"], +) +def test_reference_valid_roles(role: str) -> None: + ref = Reference(Relationship.COMPOSITION, Identified, role=role) + assert ref.role == role diff --git a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py index 246d2efea..c4fbae42c 100644 --- a/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py +++ b/packages/overture-schema-transportation-theme/src/overture/schema/transportation/models.py @@ -54,7 +54,9 @@ class ConnectorReference(BaseModel): # Required - connector_id: Annotated[Id, Reference(Relationship.CONNECTS_TO, _connector_type())] + connector_id: Annotated[ + Id, Reference(Relationship.ASSOCIATION, _connector_type(), role="connects_to") + ] @no_extra_fields