diff --git a/docs/api/relationships.md b/docs/api/relationships.md index 0e1278e..e21e912 100644 --- a/docs/api/relationships.md +++ b/docs/api/relationships.md @@ -9,16 +9,23 @@ Complete reference for relationship types. show_source: false heading_level: 3 -## ManyToManyField +## Relation -::: ferro.base.ManyToManyField +::: ferro.query.builder.Relation options: show_source: false heading_level: 3 ## BackRef -::: ferro.query.builder.BackRef +::: ferro.fields.BackRef + options: + show_source: false + heading_level: 3 + +## ManyToMany + +::: ferro.fields.ManyToMany options: show_source: false heading_level: 3 diff --git a/docs/coming-soon.md b/docs/coming-soon.md index a174983..6e02285 100644 --- a/docs/coming-soon.md +++ b/docs/coming-soon.md @@ -425,21 +425,21 @@ Document the exception hierarchy and import paths: - `docs/guide/relationships.md` (lines 176-289) **Description:** -Many-to-many relationships are defined with `ManyToManyField`, but the join tables are not automatically created during `auto_migrate=True`. +Many-to-many relationships are defined with `ManyToMany(...)`, but the join tables are not automatically created during `auto_migrate=True`. **Example (Partially Working):** ```python from typing import Annotated -from ferro import BackRef, Field, ManyToManyField, Model +from ferro import BackRef, Field, ManyToMany, Model, Relation class Post(Model): id: int | None = Field(default=None, primary_key=True) - tags: Annotated[list["Tag"], ManyToManyField(related_name="posts")] = None + tags: Relation[list["Tag"]] = ManyToMany(related_name="posts") class Tag(Model): id: int | None = Field(default=None, primary_key=True) - posts: BackRef[list["Post"]] | None = None + posts: Relation[list["Post"]] = BackRef() # Models created, but join table 'post_tags' is NOT auto-created # This causes errors when trying to use M2M methods: @@ -467,7 +467,7 @@ Documentation states that one-to-one reverse relations automatically return a si ```python class User(Model): id: int - profile: BackRef["Profile"] | None = None + profile: "Profile" = BackRef() class Profile(Model): id: int diff --git a/docs/getting-started/tutorial.md b/docs/getting-started/tutorial.md index 50dda26..a323375 100644 --- a/docs/getting-started/tutorial.md +++ b/docs/getting-started/tutorial.md @@ -26,14 +26,14 @@ Let's create a blog with users, posts, and comments: import asyncio from datetime import datetime from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, connect +from ferro import Model, Field, ForeignKey, BackRef, Relation, connect class User(Model): id: int | None = Field(default=None, primary_key=True) username: str = Field(unique=True) email: str = Field(unique=True) - posts: BackRef[list["Post"]] | None = None - comments: BackRef[list["Comment"]] | None = None + posts: Relation[list["Post"]] = BackRef() + comments: Relation[list["Comment"]] = BackRef() class Post(Model): id: int | None = Field(default=None, primary_key=True) @@ -42,7 +42,7 @@ class Post(Model): published: bool = False created_at: datetime = datetime.now() author: Annotated[User, ForeignKey(related_name="posts")] - comments: BackRef[list["Comment"]] | None = None + comments: Relation[list["Comment"]] = BackRef() class Comment(Model): id: int | None = Field(default=None, primary_key=True) @@ -305,14 +305,14 @@ Here's the full tutorial code: import asyncio from datetime import datetime from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, connect +from ferro import Model, Field, ForeignKey, BackRef, Relation, connect class User(Model): id: int | None = Field(default=None, primary_key=True) username: str = Field(unique=True) email: str = Field(unique=True) - posts: BackRef[list["Post"]] | None = None - comments: BackRef[list["Comment"]] | None = None + posts: Relation[list["Post"]] = BackRef() + comments: Relation[list["Comment"]] = BackRef() class Post(Model): id: int | None = Field(default=None, primary_key=True) @@ -321,7 +321,7 @@ class Post(Model): published: bool = False created_at: datetime = datetime.now() author: Annotated[User, ForeignKey(related_name="posts")] - comments: BackRef[list["Comment"]] | None = None + comments: Relation[list["Comment"]] = BackRef() class Comment(Model): id: int | None = Field(default=None, primary_key=True) diff --git a/docs/guide/migrations.md b/docs/guide/migrations.md index 1fcbdbb..9beaf29 100644 --- a/docs/guide/migrations.md +++ b/docs/guide/migrations.md @@ -301,7 +301,7 @@ class Post(Model): ```python class Student(Model): - courses: Annotated[list["Course"], ManyToManyField(related_name="students")] + courses: Relation[list["Course"]] = ManyToMany(related_name="students") # Automatically generates join table: # CREATE TABLE student_courses ( diff --git a/docs/guide/models-and-fields.md b/docs/guide/models-and-fields.md index 4563716..e0755ce 100644 --- a/docs/guide/models-and-fields.md +++ b/docs/guide/models-and-fields.md @@ -159,7 +159,7 @@ class OrgMembership(Model): **Wire format:** Declarations use nested tuples in Python; the schema JSON sent to the Rust engine uses nested lists (`ferro_composite_uniques`) because JSON has no tuple type. -**Many-to-many join tables:** When you use `ManyToManyField` without a custom `through` table, Ferro creates a default join table with two foreign-key columns. That table automatically gets a composite unique on those two columns so the same link cannot be stored twice. If you already have duplicate rows in such a table, adding this constraint in a migration may require a data cleanup step first. +**Many-to-many join tables:** When you use `ManyToMany(...)` without a custom `through` table, Ferro creates a default join table with two foreign-key columns. That table automatically gets a composite unique on those two columns so the same link cannot be stored twice. If you already have duplicate rows in such a table, adding this constraint in a migration may require a data cleanup step first. See also [Schema management / migrations](migrations.md) for how composite uniques appear in Alembic metadata. diff --git a/docs/guide/relationships.md b/docs/guide/relationships.md index 1af285e..a651814 100644 --- a/docs/guide/relationships.md +++ b/docs/guide/relationships.md @@ -8,12 +8,12 @@ Relationships in Ferro are **lazy** — data is never fetched until you explicit ### API Styles -Like scalar field constraints ([assignment vs `Annotated[..., Field(...)]`](models-and-fields.md#field-constraints)), relationships can be declared in two equivalent styles: +Like scalar field constraints ([assignment vs `Annotated[..., Field(...)]`](models-and-fields.md#field-constraints)), relationship metadata can be declared in two equivalent styles: -- **Annotated-style** (`BackRef`): Type-first approach using `typing.Annotated` -- **Pydantic-style** (`Field(back_ref=True)`): Familiar `Field()` syntax +- **Helper-style** (`BackRef()`, `ManyToMany(...)`): Recommended relationship helpers +- **Field-style** (`Field(back_ref=True)`, `Field(many_to_many=True, ...)`): Lower-level `Field()` syntax -Choose one style and use it consistently. Do not mix `BackRef` and `back_ref=True` on the same field. +Collection relationships are typed with `Relation[list[T]]`, which reflects the lazy query-like object returned at runtime. ### Lazy Loading Behavior @@ -51,16 +51,16 @@ erDiagram } ``` -### Annotated-style (with `BackRef`) +### Helper-style (with `BackRef()`) ```python from typing import Annotated -from ferro import Model, ForeignKey, BackRef +from ferro import Model, ForeignKey, BackRef, Relation class Author(Model): id: int name: str - posts: BackRef[list["Post"]] | None = None + posts: Relation[list["Post"]] = BackRef() class Post(Model): id: int @@ -68,15 +68,15 @@ class Post(Model): author: Annotated[Author, ForeignKey(related_name="posts")] ``` -### Pydantic-style (with `Field(back_ref=True)`) +### Field-style (with `Field(back_ref=True)`) ```python -from ferro import Model, ForeignKey, Field +from ferro import Model, ForeignKey, Field, Relation class Author(Model): id: int name: str - posts: list["Post"] | None = Field(default=None, back_ref=True) + posts: Relation[list["Post"]] = Field(back_ref=True) class Post(Model): id: int @@ -84,7 +84,7 @@ class Post(Model): author: Annotated[Author, ForeignKey(related_name="posts")] ``` -You can also use `Annotated` with `Field`: `posts: Annotated[list["Post"] | None, Field(back_ref=True)] = None` +You can also use `Annotated` with `Field`: `posts: Annotated[Relation[list["Post"]], Field(back_ref=True)]` ### Shadow Fields @@ -152,7 +152,7 @@ from ferro import Model, ForeignKey, BackRef class User(Model): id: int username: str - profile: BackRef["Profile"] | None = None # Note: singular, not list + profile: "Profile" = BackRef() # Note: singular relationships do not use Relation class Profile(Model): id: int @@ -183,7 +183,7 @@ profile_user = await profile.user # Returns User instance ## Many-to-Many -Defined using `ManyToManyField`. Ferro automatically manages the hidden join table required for this relationship. +Defined using `ManyToMany(...)`. Ferro automatically manages the hidden join table required for this relationship. ```mermaid erDiagram @@ -198,37 +198,36 @@ erDiagram } ``` -### Annotated-style (with `BackRef`) +### Helper-style (with `ManyToMany()` / `BackRef()`) ```python -from typing import Annotated -from ferro import Model, ManyToManyField, BackRef +from ferro import Model, ManyToMany, BackRef, Relation class Student(Model): id: int name: str - courses: Annotated[list["Course"], ManyToManyField(related_name="students")] = None + courses: Relation[list["Course"]] = ManyToMany(related_name="students") class Course(Model): id: int title: str - students: BackRef[list["Student"]] | None = None + students: Relation[list["Student"]] = BackRef() ``` -### Pydantic-style (with `Field(back_ref=True)`) +### Field-style (with `Field(...)`) ```python -from ferro import Model, ManyToManyField, Field +from ferro import Model, Field, Relation class Student(Model): id: int name: str - courses: Annotated[list["Course"], ManyToManyField(related_name="students")] = None + courses: Relation[list["Course"]] = Field(many_to_many=True, related_name="students") class Course(Model): id: int title: str - students: list["Student"] | None = Field(default=None, back_ref=True) + students: Relation[list["Student"]] = Field(back_ref=True) ``` ### Join Table @@ -308,7 +307,7 @@ class Employee(Model): id: int name: str manager: Annotated["Employee", ForeignKey(related_name="reports")] | None = None - reports: BackRef[list["Employee"]] | None = None + reports: Relation[list["Employee"]] = BackRef() # Usage manager = await Employee.create(name="Jane") diff --git a/docs/howto/testing.md b/docs/howto/testing.md index 7dce2d3..066f8a4 100644 --- a/docs/howto/testing.md +++ b/docs/howto/testing.md @@ -74,7 +74,7 @@ If no external Postgres URL is set and local PostgreSQL server binaries are unav ### Bridge-Boundary Regressions -When a bug involves values crossing the Python/Rust bridge, preserve the public API shape in the regression test. These issues often depend on whether a value travels as JSON (`Query.all()`, `Query.count()`, `Query.update()`, `Query.delete()`) or as a typed Python value passed directly to Rust (`ManyToManyField.add()`, `.remove()`, `.clear()`). +When a bug involves values crossing the Python/Rust bridge, preserve the public API shape in the regression test. These issues often depend on whether a value travels as JSON (`Query.all()`, `Query.count()`, `Query.update()`, `Query.delete()`) or as a typed Python value passed directly to Rust (`ManyToMany(...).add()`, `.remove()`, `.clear()`). Use these conventions: diff --git a/docs/index.md b/docs/index.md index bbf7c97..180809f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -29,12 +29,12 @@ ```python import asyncio from typing import Annotated -from ferro import Model, Field, ForeignKey, BackRef, connect +from ferro import Model, Field, ForeignKey, BackRef, Relation, connect class Author(Model): id: int | None = Field(default=None, primary_key=True) name: str - posts: BackRef[list["Post"]] | None = None + posts: Relation[list["Post"]] = BackRef() class Post(Model): id: int | None = Field(default=None, primary_key=True) diff --git a/docs/migration-sqlalchemy.md b/docs/migration-sqlalchemy.md index e6292f5..9a6aeed 100644 --- a/docs/migration-sqlalchemy.md +++ b/docs/migration-sqlalchemy.md @@ -89,11 +89,11 @@ class Post(Base): # Ferro from typing import Annotated -from ferro import BackRef, Field, ForeignKey, Model +from ferro import BackRef, Field, ForeignKey, Model, Relation class User(Model): id: int | None = Field(default=None, primary_key=True) - posts: BackRef[list["Post"]] | None = None + posts: Relation[list["Post"]] = BackRef() class Post(Model): id: int | None = Field(default=None, primary_key=True) diff --git a/scripts/demo_queries.py b/scripts/demo_queries.py index ee738d6..309306b 100644 --- a/scripts/demo_queries.py +++ b/scripts/demo_queries.py @@ -19,8 +19,9 @@ BackRef, FerroField, ForeignKey, - ManyToManyField, + ManyToMany, Model, + Relation, connect, transaction, ) @@ -40,7 +41,7 @@ class Category(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str # Reverse lookup marker (Zero-Boilerplate) - products: BackRef[list["Product"]] = None + products: Relation[list["Product"]] = BackRef() class Product(Model): @@ -59,13 +60,13 @@ class Product(Model): class Actor(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - movies: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["Movie"]] = ManyToMany(related_name="actors") class Movie(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None title: str - actors: BackRef[list[Actor]] = None + actors: Relation[list[Actor]] = BackRef() async def run_demo(): diff --git a/src/ferro/__init__.py b/src/ferro/__init__.py index 9f64037..2af4988 100644 --- a/src/ferro/__init__.py +++ b/src/ferro/__init__.py @@ -17,10 +17,10 @@ from ._core import ( connect as _core_connect, ) -from .base import FerroField, FerroNullable, ForeignKey, ManyToManyField -from .fields import Field +from .base import FerroField, FerroNullable, ForeignKey +from .fields import BackRef, Field, ManyToMany from .models import Model, transaction -from .query import BackRef +from .query import Relation # Set up the Ferro logger _logger = logging.getLogger("ferro") @@ -58,8 +58,9 @@ async def connect(url: str, auto_migrate: bool = False) -> None: "FerroNullable", "Field", "ForeignKey", - "ManyToManyField", "BackRef", + "ManyToMany", + "Relation", "version", "create_tables", "reset_engine", diff --git a/src/ferro/base.py b/src/ferro/base.py index d8af72c..881617d 100644 --- a/src/ferro/base.py +++ b/src/ferro/base.py @@ -140,7 +140,9 @@ def __init__( self.unique = unique self.nullable = _validate_nullable_option(nullable, "ForeignKey") if str(self.on_delete).upper() == "SET NULL" and self.nullable is False: - raise ValueError("ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'") + raise ValueError( + "ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'" + ) #: First type argument of ``Annotated[..., ForeignKey]``; set by the metaclass #: for Alembic nullability inference (forward fields are not in ``model_fields``). self.relation_annotation: Any | None = None @@ -160,8 +162,8 @@ def foreign_key_allows_none(metadata: "ForeignKey") -> bool | None: return annotation_allows_none(relation_annotation) -class ManyToManyField: - """Describe metadata for a many-to-many relationship +class ManyToManyRelation: + """Describe internal metadata for a many-to-many relationship Attributes: @@ -178,7 +180,7 @@ class ManyToManyField: >>> >>> class Post(Model): ... id: Annotated[int, FerroField(primary_key=True)] - ... tags: Annotated[list[int], ManyToManyField("posts")] + ... tags: Relation[list["Tag"]] = ManyToMany(related_name="posts") """ def __init__(self, related_name: str, through: str | None = None): @@ -196,7 +198,7 @@ def __init__(self, related_name: str, through: str | None = None): >>> >>> class User(Model): ... id: Annotated[int, FerroField(primary_key=True)] - ... teams: Annotated[list[int], ManyToManyField("members", through="team_members")] + ... teams: Relation[list["Team"]] = ManyToMany(related_name="members", through="team_members") """ self.to = None # Resolved later self.related_name = related_name diff --git a/src/ferro/fields.py b/src/ferro/fields.py index 4b67280..f5016b7 100644 --- a/src/ferro/fields.py +++ b/src/ferro/fields.py @@ -33,6 +33,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., alias: str | None = ..., alias_priority: int | None = ..., @@ -81,6 +84,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., alias: str | None = ..., alias_priority: int | None = ..., @@ -129,6 +135,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., alias: str | None = ..., alias_priority: int | None = ..., @@ -176,6 +185,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., default_factory: Callable[[], Any] | Callable[[dict[str, Any]], Any], alias: str | None = ..., @@ -224,6 +236,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., default_factory: Callable[[], _T] | Callable[[dict[str, Any]], _T], alias: str | None = ..., @@ -272,6 +287,9 @@ def Field( unique: bool = ..., index: bool = ..., back_ref: bool = ..., + many_to_many: bool = ..., + related_name: str | None = ..., + through: str | None = ..., nullable: FerroNullable = ..., alias: str | None = ..., alias_priority: int | None = ..., @@ -319,6 +337,9 @@ def Field( unique: bool | Any = _Unset, index: bool | Any = _Unset, back_ref: bool | Any = _Unset, + many_to_many: bool | Any = _Unset, + related_name: str | None | Any = _Unset, + through: str | None | Any = _Unset, nullable: FerroNullable | Any = _Unset, default_factory: Callable[[], Any] | Callable[[dict[str, Any]], Any] @@ -369,8 +390,11 @@ def Field( unique: Add a **single-column** uniqueness constraint for this column in Ferro. Multi-column uniqueness is declared with ``__ferro_composite_uniques__`` on the model. index: Request an index for this column in Ferro. - back_ref: Mark this field as a reverse relationship (same as BackRef in the type). - Do not use together with a BackRef annotation on the same field. + back_ref: Mark this field as a reverse relationship. This is the lower-level + equivalent of assigning ``BackRef()`` as the field default. + many_to_many: Mark this field as a many-to-many relationship. + related_name: Reverse relationship field name used by many-to-many relationships. + through: Optional join table name used by many-to-many relationships. nullable: Alembic ``Column.nullable`` override for :func:`~ferro.migrations.get_metadata`. ``\"infer\"`` (default) derives nullability from the field annotation. default_factory: A callable to generate the default value. The callable can either take 0 arguments @@ -451,6 +475,12 @@ def Field( ferro_kwargs["index"] = index if back_ref is not _Unset: ferro_kwargs["back_ref"] = back_ref + if many_to_many is not _Unset: + ferro_kwargs["many_to_many"] = many_to_many + if related_name is not _Unset: + ferro_kwargs["related_name"] = related_name + if through is not _Unset: + ferro_kwargs["through"] = through if nullable is not _Unset: _validate_nullable_option(nullable, "Field") ferro_kwargs["nullable"] = nullable @@ -512,4 +542,37 @@ def Field( ) -__all__ = ["Field", "FERRO_FIELD_EXTRA_KEY"] +class BackRef: + """Declare a reverse relationship field. + + ``BackRef()`` is a convenience wrapper around ``Field(back_ref=True)``. + """ + + def __new__(cls, **kwargs: Any) -> Any: + return Field(back_ref=True, **kwargs) + + @classmethod + def __class_getitem__(cls, _item: Any) -> Any: + raise TypeError( + "BackRef[...] is no longer a type annotation. Use " + "Relation[list[T]] = BackRef() for collection back-references." + ) + + +def ManyToMany( + *, + related_name: str, + through: str | None = None, + **kwargs: Any, +) -> Any: + """Declare a many-to-many relationship field.""" + + return Field( + many_to_many=True, + related_name=related_name, + through=through, + **kwargs, + ) + + +__all__ = ["Field", "BackRef", "ManyToMany", "FERRO_FIELD_EXTRA_KEY"] diff --git a/src/ferro/metaclass.py b/src/ferro/metaclass.py index bfec904..897ad39 100644 --- a/src/ferro/metaclass.py +++ b/src/ferro/metaclass.py @@ -16,9 +16,9 @@ from ._core import register_model_schema from ._shadow_fk_types import shadow_annotation_for_foreign_key -from .base import FerroField, ForeignKey, ManyToManyField +from .base import FerroField, ForeignKey, ManyToManyRelation from .fields import FERRO_FIELD_EXTRA_KEY -from .query import BackRef, FieldProxy +from .query import FieldProxy, Relation from .relations.descriptors import ForwardDescriptor from .schema_metadata import build_model_schema from .state import _MODEL_REGISTRY_PY, _PENDING_RELATIONS @@ -56,14 +56,15 @@ def __new__(mcs, name, bases, namespace, **kwargs): return cls @staticmethod - def _field_has_back_ref(obj: Any) -> bool: - """Return True if obj is a FieldInfo with back_ref=True in its Ferro extra.""" + def _field_ferro_payload(obj: Any) -> dict[str, Any]: + """Return Ferro metadata payload from a wrapped FieldInfo.""" if not isinstance(obj, FieldInfo): - return False + return {} extra = getattr(obj, "json_schema_extra", None) if not isinstance(extra, dict): - return False - return extra.get(FERRO_FIELD_EXTRA_KEY, {}).get("back_ref") is True + return {} + payload = extra.get(FERRO_FIELD_EXTRA_KEY, {}) + return payload if isinstance(payload, dict) else {} @staticmethod def _strip_optional_union(hint: Any) -> Any: @@ -79,8 +80,8 @@ def _strip_optional_union(hint: Any) -> Any: return hint @staticmethod - def _backref_marker_from_annotation(hint: Any) -> Any: - """Inner type used to detect ``BackRef`` (unwraps ``Annotated``, ``| None``).""" + def _relationship_marker_from_annotation(hint: Any) -> Any: + """Inner type used to inspect relationship annotations.""" if get_origin(hint) is Annotated: args = get_args(hint) if args: @@ -89,43 +90,85 @@ def _backref_marker_from_annotation(hint: Any) -> Any: return ModelMetaclass._strip_optional_union(hint) @staticmethod - def _is_back_ref_field( - field_name: str, hint: Any, namespace: dict - ) -> tuple[bool, bool]: - """ - Check if a field is a back-reference. + def _legacy_back_ref_error(field_name: str) -> TypeError: + return TypeError( + f"Field '{field_name}' uses deprecated BackRef[...] annotation syntax. " + "Use Relation[list[T]] = BackRef() for collection back-references." + ) - Returns: - (is_back_type, is_back_field): Booleans indicating type-side and field-side BackRef - """ + @staticmethod + def _relationship_field_payload( + field_name: str, hint: Any, namespace: dict + ) -> dict[str, Any]: + """Return relationship metadata supplied by ferro.Field helpers.""" origin = get_origin(hint) - - # Type-side back-ref: BackRef[...] in annotation (or inside Annotated), optional ``| None`` - marker = ModelMetaclass._backref_marker_from_annotation(hint) - is_back_type = get_origin(marker) is BackRef - if not is_back_type and isinstance(hint, str) and "BackRef" in hint: - is_back_type = True - if ( - not is_back_type - and isinstance(hint, ForwardRef) - and "BackRef" in hint.__forward_arg__ - ): - is_back_type = True - - # Field-side back-ref: Field(back_ref=True) as default or in Annotated - is_back_field = False default_val = namespace.get(field_name) - if ModelMetaclass._field_has_back_ref(default_val): - is_back_field = True - if not is_back_field and origin is Annotated: + payload = ModelMetaclass._field_ferro_payload(default_val) + if payload: + return payload + + if origin is Annotated: for metadata in get_args(hint)[1:]: - if isinstance( - metadata, FieldInfo - ) and ModelMetaclass._field_has_back_ref(metadata): - is_back_field = True - break + payload = ModelMetaclass._field_ferro_payload(metadata) + if payload: + return payload + + return {} + + @staticmethod + def _relation_target_from_annotation(field_name: str, hint: Any) -> Any: + """Extract T from Relation[list[T]] for collection relationships.""" + marker = ModelMetaclass._relationship_marker_from_annotation(hint) + if isinstance(marker, str): + return ModelMetaclass._relation_target_from_string(field_name, marker) + if get_origin(marker) is not Relation: + raise TypeError( + f"Field '{field_name}' must be annotated as Relation[list[T]] " + "when using BackRef(), ManyToMany(), or relationship Field flags." + ) + + args = get_args(marker) + if not args: + raise TypeError(f"Field '{field_name}' must specify Relation[list[T]].") + + relation_arg = ModelMetaclass._strip_optional_union(args[0]) + if get_origin(relation_arg) is not list: + raise TypeError( + f"Field '{field_name}' must use Relation[list[T]] for collection relationships." + ) + + inner_args = get_args(relation_arg) + if not inner_args: + raise TypeError(f"Field '{field_name}' must specify Relation[list[T]].") + return ModelMetaclass._strip_optional_union(inner_args[0]) - return is_back_type, is_back_field + @staticmethod + def _relation_target_from_string(field_name: str, hint: str) -> str: + """Extract T from a string ``Relation[list[T]]`` annotation.""" + normalized = hint.replace(" ", "") + prefix = "Relation[list[" + if not normalized.startswith(prefix) or not normalized.endswith("]]"): + raise TypeError( + f"Field '{field_name}' must be annotated as Relation[list[T]] " + "when using BackRef(), ManyToMany(), or relationship Field flags." + ) + target = normalized[len(prefix) : -2] + return target.strip("\"'") + + @staticmethod + def _annotation_is_plain_list(hint: Any) -> bool: + marker = ModelMetaclass._relationship_marker_from_annotation(hint) + if get_origin(marker) is list: + return True + return isinstance(marker, str) and marker.replace(" ", "").startswith("list[") + + @staticmethod + def _annotation_looks_like_back_ref(hint: Any) -> bool: + if isinstance(hint, str) and "BackRef" in hint: + return True + if isinstance(hint, ForwardRef) and "BackRef" in hint.__forward_arg__: + return True + return False @staticmethod def _resolve_deferred_annotations(namespace: dict) -> dict[str, Any]: @@ -141,11 +184,15 @@ def _resolve_deferred_annotations(namespace: dict) -> dict[str, Any]: try: # Format 1: Value (evaluated) return namespace["__annotate_func__"](1) - except Exception: + except Exception as value_error: + if "BackRef[...]" in str(value_error): + raise value_error try: # Format 2: ForwardRef (non-evaluated objects) return namespace["__annotate_func__"](2) - except Exception: + except Exception as forward_error: + if "BackRef[...]" in str(forward_error): + raise forward_error pass return namespace.get("__annotations__", {}) @@ -155,7 +202,7 @@ def _scan_relationship_annotations( annotations: dict, namespace: dict, model_name: str ) -> tuple[dict, list]: """ - Scan annotations for relationship fields (BackRef, ForeignKey, ManyToManyField). + Scan annotations for relationship fields (BackRef, ForeignKey, ManyToMany). Returns: (local_relations, fields_to_remove): Relationship metadata and fields to hide from Pydantic @@ -164,21 +211,53 @@ def _scan_relationship_annotations( fields_to_remove = [] for field_name, hint in list(annotations.items()): - is_back_type, is_back_field = ModelMetaclass._is_back_ref_field( + if ModelMetaclass._annotation_looks_like_back_ref(hint): + raise ModelMetaclass._legacy_back_ref_error(field_name) + + relationship_payload = ModelMetaclass._relationship_field_payload( field_name, hint, namespace ) + is_back_field = relationship_payload.get("back_ref") is True + is_m2m_field = relationship_payload.get("many_to_many") is True - if is_back_type and is_back_field: + if is_back_field and is_m2m_field: raise TypeError( - f"Cannot use both BackRef and Field(back_ref=True) on the same " - f"field '{field_name}'." + f"Field '{field_name}' cannot be both back_ref and many_to_many." ) - if is_back_type or is_back_field: + if is_back_field: + marker = ModelMetaclass._relationship_marker_from_annotation(hint) + if get_origin(marker) is Relation: + ModelMetaclass._relation_target_from_annotation(field_name, hint) + elif ModelMetaclass._annotation_is_plain_list(hint): + raise TypeError( + f"Field '{field_name}' uses a plain list annotation. Use " + "Relation[list[T]] = BackRef() for collection back-references." + ) local_relations[field_name] = "BackRef" fields_to_remove.append(field_name) continue + if is_m2m_field: + target = ModelMetaclass._relation_target_from_annotation( + field_name, hint + ) + related_name = relationship_payload.get("related_name") + if not related_name: + raise TypeError( + f"Field '{field_name}' uses many_to_many=True but did not " + "provide related_name." + ) + metadata = ManyToManyRelation( + related_name=related_name, + through=relationship_payload.get("through"), + ) + metadata.to = target + local_relations[field_name] = metadata + _PENDING_RELATIONS.append((model_name, field_name, metadata)) + fields_to_remove.append(field_name) + continue + origin = get_origin(hint) if origin is Annotated: args = get_args(hint) @@ -192,20 +271,11 @@ def _scan_relationship_annotations( fields_to_remove.append(field_name) break - if isinstance(metadata, ManyToManyField): - origin_inner = get_origin(args[0]) - if origin_inner is list: - inner_args = get_args(args[0]) - if inner_args: - metadata.to = ModelMetaclass._strip_optional_union( - inner_args[0] - ) - else: - metadata.to = ModelMetaclass._strip_optional_union(args[0]) - local_relations[field_name] = metadata - _PENDING_RELATIONS.append((model_name, field_name, metadata)) - fields_to_remove.append(field_name) - break + if metadata.__class__.__name__ == "ManyToManyField": + raise TypeError( + "ManyToManyField(...) is no longer supported. Use " + "Relation[list[T]] = ManyToMany(...)." + ) return local_relations, fields_to_remove @@ -290,7 +360,19 @@ def _parse_ferro_field_metadata(cls) -> dict[str, FerroField]: if isinstance(extra, dict): wrapped_payload = extra.get(FERRO_FIELD_EXTRA_KEY) if wrapped_payload: - wrapped_metadata = FerroField(**wrapped_payload) + field_payload = { + key: wrapped_payload[key] + for key in ( + "primary_key", + "autoincrement", + "unique", + "index", + "nullable", + ) + if key in wrapped_payload + } + if field_payload: + wrapped_metadata = FerroField(**field_payload) if annotated_metadata and wrapped_metadata: raise TypeError( diff --git a/src/ferro/query/__init__.py b/src/ferro/query/__init__.py index 784ada4..636540b 100644 --- a/src/ferro/query/__init__.py +++ b/src/ferro/query/__init__.py @@ -1,6 +1,6 @@ """Expose query-building primitives used by Ferro models""" -from .builder import BackRef, Query +from .builder import Query, Relation from .nodes import FieldProxy, QueryNode -__all__ = ["Query", "BackRef", "QueryNode", "FieldProxy"] +__all__ = ["Query", "Relation", "QueryNode", "FieldProxy"] diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index 2c10df5..820c2ee 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -165,7 +165,9 @@ async def all(self) -> list[T]: from ..state import _CURRENT_TRANSACTION tx_id = _CURRENT_TRANSACTION.get() - results = await fetch_filtered(self.model_cls, _query_def_to_json(query_def), tx_id) + results = await fetch_filtered( + self.model_cls, _query_def_to_json(query_def), tx_id + ) for instance in results: if hasattr(self.model_cls, "_fix_types"): self.model_cls._fix_types(instance) @@ -385,14 +387,14 @@ def __repr__(self): return f"" -class BackRef(Query[T]): - """Represent reverse relationship queries with Query typing support +class Relation(Query[T]): + """Represent lazy collection relationship queries with typing support Examples: >>> class User(Model): ... id: Annotated[int, FerroField(primary_key=True)] ... name: str - ... posts: BackRef[list["Post"]] | None = None + ... posts: Relation[list["Post"]] = BackRef() >>> class Post(Model): ... id: Annotated[int, FerroField(primary_key=True)] @@ -405,28 +407,50 @@ class BackRef(Query[T]): True """ + def _m2m( + self, join_table: str, source_col: str, target_col: str, source_id: Any + ) -> "Relation[T]": + super()._m2m(join_table, source_col, target_col, source_id) + return self + + def where(self, node: "QueryNode") -> "Relation[T]": + super().where(node) + return self + + def order_by(self, field: Any, direction: str = "asc") -> "Relation[T]": + super().order_by(field, direction) + return self + + def limit(self, value: int) -> "Relation[T]": + super().limit(value) + return self + + def offset(self, value: int) -> "Relation[T]": + super().offset(value) + return self + # NOTE ON TYPING: # - # Users commonly annotate reverse collections as BackRef[list[Model]] to encode + # Users annotate collection relationships as Relation[list[Model]] to encode # cardinality (one-to-many / many-to-many). Since Query.all() is typed as list[T], # that would naively become list[list[Model]] in IDEs. # - # We fix hinting by overriding BackRef.{all,first} with overloads that interpret - # BackRef[T] as a query whose *rows* are model instances, regardless of whether + # We fix hinting by overriding Relation.{all,first} with overloads that interpret + # Relation[T] as a query whose *rows* are model instances, regardless of whether # T is written as Model or list[Model] in the field annotation. if TYPE_CHECKING: @overload - async def all(self: "BackRef[list[E]]") -> list[E]: ... + async def all(self: "Relation[list[E]]") -> list[E]: ... @overload - async def all(self: "BackRef[E]") -> list[E]: ... + async def all(self: "Relation[E]") -> list[E]: ... @overload - async def first(self: "BackRef[list[E]]") -> E | None: ... + async def first(self: "Relation[list[E]]") -> E | None: ... @overload - async def first(self: "BackRef[E]") -> E | None: ... + async def first(self: "Relation[E]") -> E | None: ... async def all(self): # type: ignore[override] return await super().all() diff --git a/src/ferro/relations/__init__.py b/src/ferro/relations/__init__.py index 80fa372..e67e00c 100644 --- a/src/ferro/relations/__init__.py +++ b/src/ferro/relations/__init__.py @@ -2,8 +2,12 @@ from typing import ForwardRef from .._core import register_model_schema -from .._shadow_fk_types import pk_python_type_for_model, reconcile_shadow_fk_types, schema_fragment_for_pk -from ..base import ForeignKey, ManyToManyField +from .._shadow_fk_types import ( + pk_python_type_for_model, + reconcile_shadow_fk_types, + schema_fragment_for_pk, +) +from ..base import ForeignKey, ManyToManyRelation from ..schema_metadata import build_model_schema from ..state import ( # noqa: F401 _JOIN_TABLE_REGISTRY, @@ -39,13 +43,13 @@ def resolve_relationships(): ) rel.to = target_model - # 2. Cross-validate with BackRef + # 2. Cross-validate with declared reverse relation field. target_model = rel.to if not hasattr(target_model, rel.related_name): raise RuntimeError( f"Model '{model_name}' defines a relationship to '{target_model.__name__}' " f"with related_name='{rel.related_name}', but '{target_model.__name__}' " - f"does not have that field defined as a BackRef (or back_ref=True)." + f"does not have that field defined as BackRef()/Field(back_ref=True)." ) # 3. Inject Descriptor into target model @@ -59,7 +63,7 @@ def resolve_relationships(): is_one_to_one=getattr(rel, "unique", False), ), ) - elif isinstance(rel, ManyToManyField): + elif isinstance(rel, ManyToManyRelation): # Resolve join table if not rel.through: # Default join table name: alphabetized model names @@ -104,7 +108,9 @@ def resolve_relationships(): source_schema = schema_fragment_for_pk( pk_python_type_for_model(_MODEL_REGISTRY_PY[model_name]) ) - target_schema = schema_fragment_for_pk(pk_python_type_for_model(target_model)) + target_schema = schema_fragment_for_pk( + pk_python_type_for_model(target_model) + ) join_schema = { "properties": { source_col: { diff --git a/src/ferro/relations/descriptors.py b/src/ferro/relations/descriptors.py index 4ca017b..c3916d5 100644 --- a/src/ferro/relations/descriptors.py +++ b/src/ferro/relations/descriptors.py @@ -43,9 +43,9 @@ def __get__(self, instance, owner): pk_val = getattr(instance, pk_field) if self.is_m2m: - from ..query.builder import Query + from ..query.builder import Relation - return Query(self._target_model)._m2m( + return Relation(self._target_model)._m2m( self.join_table, self.source_col, self.target_col, pk_val ) @@ -59,14 +59,16 @@ def __get__(self, instance, owner): pk_val = getattr(instance, pk_field) - query = self._target_model.where( - getattr(self._target_model, f"{self.field_name}_id") == pk_val - ) - if self.is_one_to_one: - return query.first() + return self._target_model.where( + getattr(self._target_model, f"{self.field_name}_id") == pk_val + ).first() - return query + from ..query.builder import Relation + + return Relation(self._target_model).where( + getattr(self._target_model, f"{self.field_name}_id") == pk_val + ) class ForwardDescriptor(BaseModel): diff --git a/tests/test_alembic_bridge.py b/tests/test_alembic_bridge.py index 98a7334..0f33dfb 100644 --- a/tests/test_alembic_bridge.py +++ b/tests/test_alembic_bridge.py @@ -9,8 +9,9 @@ Field, FerroField, ForeignKey, - ManyToManyField, + ManyToMany, Model, + Relation, clear_registry, reset_engine, ) @@ -36,7 +37,7 @@ class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: Annotated[str, FerroField(unique=True, index=True)] is_active: bool = True - posts: BackRef["Post"] = None + posts: Relation[list["Post"]] = BackRef() class Post(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -71,7 +72,7 @@ def test_foreign_key_unique_true_propagates_to_shadow_column(): class Parent(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) - child: BackRef["Child"] = None + child: "Child" = BackRef() class Child(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -91,12 +92,12 @@ def test_m2m_translation(): class Actor(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - movies: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["Movie"]] = ManyToMany(related_name="actors") class Movie(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None title: str - actors: BackRef[Actor] = None + actors: Relation[list["Actor"]] = BackRef() metadata = get_metadata() @@ -120,12 +121,12 @@ def test_uuid_m2m_join_table_uses_uuid_capable_column_types(): class UuidTeam(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str - members: Annotated[list["UuidMember"], ManyToManyField(related_name="teams")] = None + members: Relation[list["UuidMember"]] = ManyToMany(related_name="teams") class UuidMember(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) email: str - teams: BackRef[UuidTeam] = None + teams: Relation[list["UuidTeam"]] = BackRef() metadata = get_metadata() join_table = metadata.tables["uuidteam_members"] @@ -144,7 +145,7 @@ def test_uuid_foreign_key_shadow_column_type(): class UuidAlembicOrg(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) name: str - members: BackRef[list["UuidAlembicMember"]] = None + members: Relation[list["UuidAlembicMember"]] = BackRef() class UuidAlembicMember(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) @@ -164,7 +165,7 @@ def test_on_delete_translation(): class Category(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - products: BackRef["Product"] = None + products: Relation[list["Product"]] = BackRef() class Product(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None diff --git a/tests/test_alembic_nullability.py b/tests/test_alembic_nullability.py index 08ca662..46ebac2 100644 --- a/tests/test_alembic_nullability.py +++ b/tests/test_alembic_nullability.py @@ -7,10 +7,18 @@ import pytest from pydantic import ValidationError -from ferro import FerroField, Field, ForeignKey, Model, clear_registry, reset_engine +from ferro import ( + BackRef, + FerroField, + Field, + ForeignKey, + Model, + Relation, + clear_registry, + reset_engine, +) from ferro._annotation_utils import annotation_allows_none from ferro.migrations import get_metadata -from ferro.query import BackRef @pytest.fixture(autouse=True) @@ -142,13 +150,15 @@ def test_infer_fk_shadow_required(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildReq"]] = None + children: Relation[list["ChildReq"]] = BackRef() class ChildReq(Model): id: Annotated[int, FerroField(primary_key=True)] parent: Annotated[Parent, ForeignKey(related_name="children")] - assert ChildReq.__ferro_schema__["properties"]["parent_id"]["ferro_nullable"] is False + assert ( + ChildReq.__ferro_schema__["properties"]["parent_id"]["ferro_nullable"] is False + ) t = get_metadata().tables["childreq"] assert t.c.parent_id.nullable is False @@ -157,7 +167,7 @@ def test_required_fk_shadow_rejects_missing_value(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildReqValidation"]] = None + children: Relation[list["ChildReqValidation"]] = BackRef() class ChildReqValidation(Model): id: Annotated[int, FerroField(primary_key=True)] @@ -177,13 +187,15 @@ def test_infer_fk_shadow_optional(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildOpt"]] = None + children: Relation[list["ChildOpt"]] = BackRef() class ChildOpt(Model): id: Annotated[int, FerroField(primary_key=True)] parent: Annotated[Parent | None, ForeignKey(related_name="children")] = None - assert ChildOpt.__ferro_schema__["properties"]["parent_id"]["ferro_nullable"] is True + assert ( + ChildOpt.__ferro_schema__["properties"]["parent_id"]["ferro_nullable"] is True + ) t = get_metadata().tables["childopt"] assert t.c.parent_id.nullable is True @@ -229,7 +241,7 @@ def test_override_foreign_key_nullable_false_optional_relation(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildOv"]] = None + children: Relation[list["ChildOv"]] = BackRef() class ChildOv(Model): id: Annotated[int, FerroField(primary_key=True)] @@ -246,7 +258,7 @@ def test_override_foreign_key_nullable_true_required_relation(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildFkTrue"]] = None + children: Relation[list["ChildFkTrue"]] = BackRef() class ChildFkTrue(Model): id: Annotated[int, FerroField(primary_key=True)] @@ -263,7 +275,7 @@ def test_on_delete_set_null_infers_nullable_shadow_fk(): class Parent(Model): id: Annotated[int, FerroField(primary_key=True)] name: str - children: BackRef[list["ChildSetNull"]] = None + children: Relation[list["ChildSetNull"]] = BackRef() class ChildSetNull(Model): id: Annotated[int, FerroField(primary_key=True)] diff --git a/tests/test_auto_migrate.py b/tests/test_auto_migrate.py index 9589d5c..fd027f1 100644 --- a/tests/test_auto_migrate.py +++ b/tests/test_auto_migrate.py @@ -5,9 +5,8 @@ from pydantic import Field import ferro -from ferro import Model -from ferro.base import FerroField, ManyToManyField -from ferro.query import BackRef +from ferro import BackRef, ManyToMany, Model, Relation +from ferro.base import FerroField pytestmark = pytest.mark.backend_matrix @@ -62,12 +61,12 @@ async def test_m2m_join_table_created_during_auto_migrate(db_url): class Actor(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - movies: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["Movie"]] = ManyToMany(related_name="actors") class Movie(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None title: str - actors: BackRef[Actor] = None + actors: Relation[list["Actor"]] = BackRef() await connect(db_url, auto_migrate=True) @@ -110,12 +109,12 @@ async def test_uuid_m2m_join_table_columns_inherit_pk_type_and_nullability(db_ur class UuidActor(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) name: str - movies: Annotated[list["UuidMovie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["UuidMovie"]] = ManyToMany(related_name="actors") class UuidMovie(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) title: str - actors: BackRef[UuidActor] = None + actors: Relation[list["UuidActor"]] = BackRef() await connect(db_url, auto_migrate=True) @@ -127,8 +126,20 @@ class UuidMovie(Model): conn.close() columns = {row[1]: row for row in rows} - assert columns["uuidactor_id"][2].upper() in {"UUID", "UUID_TEXT", "TEXT", "CHAR", "VARCHAR"} - assert columns["uuidmovie_id"][2].upper() in {"UUID", "UUID_TEXT", "TEXT", "CHAR", "VARCHAR"} + assert columns["uuidactor_id"][2].upper() in { + "UUID", + "UUID_TEXT", + "TEXT", + "CHAR", + "VARCHAR", + } + assert columns["uuidmovie_id"][2].upper() in { + "UUID", + "UUID_TEXT", + "TEXT", + "CHAR", + "VARCHAR", + } assert columns["uuidactor_id"][3] == 1 assert columns["uuidmovie_id"][3] == 1 @@ -150,12 +161,12 @@ async def test_uuid_m2m_relationship_query_serializes_source_id(db_url): class UuidTag(Model): id: UUID = FerroFieldFn(default_factory=uuid4, primary_key=True) name: str = "" - posts: BackRef[list["UuidPost"]] | None = None + posts: Relation[list["UuidPost"]] = BackRef() class UuidPost(Model): id: UUID = FerroFieldFn(default_factory=uuid4, primary_key=True) title: str = "" - tags: Annotated[list[UuidTag], ManyToManyField(related_name="posts")] = None + tags: Relation[list[UuidTag]] = ManyToMany(related_name="posts") await connect(db_url, auto_migrate=True) diff --git a/tests/test_composite_unique.py b/tests/test_composite_unique.py index 6d06224..8067539 100644 --- a/tests/test_composite_unique.py +++ b/tests/test_composite_unique.py @@ -1,7 +1,7 @@ """Composite unique constraints and default M2M join-table uniqueness (TDD).""" import sqlite3 -from typing import Annotated, ClassVar +from typing import ClassVar import pytest import sqlalchemy as sa @@ -9,8 +9,9 @@ from ferro import ( BackRef, Field, - ManyToManyField, + ManyToMany, Model, + Relation, clear_registry, connect, reset_engine, @@ -94,12 +95,12 @@ async def test_m2m_duplicate_link_rejected(db_url): class Actor(Model): id: int | None = Field(default=None, primary_key=True) name: str - movies: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["Movie"]] = ManyToMany(related_name="actors") class Movie(Model): id: int | None = Field(default=None, primary_key=True) title: str - actors: BackRef[Actor] = None + actors: Relation[list["Actor"]] = BackRef() await connect(db_url, auto_migrate=True) @@ -127,12 +128,12 @@ class PairRow(Model): class Actor(Model): id: int | None = Field(default=None, primary_key=True) name: str - movies: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None + movies: Relation[list["Movie"]] = ManyToMany(related_name="actors") class Movie(Model): id: int | None = Field(default=None, primary_key=True) title: str - actors: BackRef[Actor] = None + actors: Relation[list["Actor"]] = BackRef() metadata = get_metadata() @@ -228,10 +229,7 @@ class VeryLongCompositeUniqueModelNameForIndexTruncationTest(Model): composite_rows = [ r for r in rows - if r[1] - and "UNIQUE" in r[1].upper() - and col_a in r[1] - and col_b in r[1] + if r[1] and "UNIQUE" in r[1].upper() and col_a in r[1] and col_b in r[1] ] assert composite_rows, f"expected unique composite index on {table}, got: {rows}" idx_name = composite_rows[0][0] @@ -361,7 +359,9 @@ def test_build_sa_table_warns_on_invalid_composite_unique_group(): def test_single_column_composite_unique_raises_with_guidance(): """A single-column group must error with guidance toward Field(unique=True).""" - with pytest.raises(RuntimeError, match="at least two columns|Field\\(unique=True\\)"): + with pytest.raises( + RuntimeError, match="at least two columns|Field\\(unique=True\\)" + ): class BadSingle(Model): __ferro_composite_uniques__: ClassVar[tuple[tuple[str, ...], ...]] = ( diff --git a/tests/test_documentation_features.py b/tests/test_documentation_features.py index 07b822b..85d4e24 100644 --- a/tests/test_documentation_features.py +++ b/tests/test_documentation_features.py @@ -21,8 +21,9 @@ FerroField, Field, ForeignKey, - ManyToManyField, + ManyToMany, Model, + Relation, connect, create_tables, transaction, @@ -46,8 +47,8 @@ class User(Model): email: Annotated[str, FerroField(unique=True, index=True)] is_active: bool = True role: UserRole = UserRole.USER - posts: BackRef[list["Post"]] = None - comments: BackRef[list["Comment"]] = None + posts: Relation[list["Post"]] = BackRef() + comments: Relation[list["Comment"]] = BackRef() class Post(Model): @@ -59,8 +60,8 @@ class Post(Model): published: bool = False created_at: datetime = Field(default_factory=datetime.now) author: Annotated[User, ForeignKey(related_name="posts")] - comments: BackRef[list["Comment"]] = None - tags: Annotated[list["Tag"], ManyToManyField(related_name="posts")] = None + comments: Relation[list["Comment"]] = BackRef() + tags: Relation[list["Tag"]] = ManyToMany(related_name="posts") class Comment(Model): @@ -78,7 +79,7 @@ class Tag(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: Annotated[str, FerroField(unique=True)] - posts: BackRef[list["Post"]] = None + posts: Relation[list["Post"]] = BackRef() class Product(Model): diff --git a/tests/test_metaclass_internals.py b/tests/test_metaclass_internals.py index 726a2d4..258109c 100644 --- a/tests/test_metaclass_internals.py +++ b/tests/test_metaclass_internals.py @@ -11,136 +11,125 @@ import pytest from pydantic.fields import FieldInfo -from ferro import Model -from ferro.base import FerroField, ForeignKey, ManyToManyField +from ferro import BackRef, Model, Relation +from ferro.base import FerroField, ForeignKey, ManyToManyRelation from ferro.fields import FERRO_FIELD_EXTRA_KEY from ferro.metaclass import ModelMetaclass -from ferro.query import BackRef -class TestFieldHasBackRef: - """Test _field_has_back_ref static method.""" +class TestFieldFerroPayload: + """Test _field_ferro_payload static method.""" def test_non_field_info_returns_false(self): - """Non-FieldInfo objects should return False.""" - assert not ModelMetaclass._field_has_back_ref("not a field") - assert not ModelMetaclass._field_has_back_ref(123) - assert not ModelMetaclass._field_has_back_ref(None) + """Non-FieldInfo objects should return an empty payload.""" + assert ModelMetaclass._field_ferro_payload("not a field") == {} + assert ModelMetaclass._field_ferro_payload(123) == {} + assert ModelMetaclass._field_ferro_payload(None) == {} def test_field_info_without_extra_returns_false(self): - """FieldInfo without json_schema_extra should return False.""" + """FieldInfo without json_schema_extra should return an empty payload.""" field = FieldInfo(annotation=int, default=None) - assert not ModelMetaclass._field_has_back_ref(field) + assert ModelMetaclass._field_ferro_payload(field) == {} def test_field_info_with_non_dict_extra_returns_false(self): - """FieldInfo with non-dict json_schema_extra should return False.""" + """FieldInfo with non-dict json_schema_extra should return an empty payload.""" field = FieldInfo(annotation=int, default=None) field.json_schema_extra = "not a dict" - assert not ModelMetaclass._field_has_back_ref(field) + assert ModelMetaclass._field_ferro_payload(field) == {} - def test_field_info_with_back_ref_true_returns_true(self): - """FieldInfo with back_ref=True in Ferro extra should return True.""" + def test_field_info_with_back_ref_true_returns_payload(self): + """FieldInfo with Ferro extra should return that payload.""" field = FieldInfo( annotation=int, default=None, json_schema_extra={FERRO_FIELD_EXTRA_KEY: {"back_ref": True}}, ) - assert ModelMetaclass._field_has_back_ref(field) + assert ModelMetaclass._field_ferro_payload(field) == {"back_ref": True} - def test_field_info_with_back_ref_false_returns_false(self): - """FieldInfo with back_ref=False should return False.""" + def test_field_info_with_many_to_many_returns_payload(self): + """FieldInfo with many_to_many metadata should return that payload.""" field = FieldInfo( annotation=int, default=None, - json_schema_extra={FERRO_FIELD_EXTRA_KEY: {"back_ref": False}}, + json_schema_extra={ + FERRO_FIELD_EXTRA_KEY: { + "many_to_many": True, + "related_name": "users", + } + }, ) - assert not ModelMetaclass._field_has_back_ref(field) + assert ModelMetaclass._field_ferro_payload(field)["related_name"] == "users" -class TestIsBackRefField: - """Test _is_back_ref_field static method.""" +class TestRelationshipFieldPayload: + """Test relationship Field metadata helpers.""" - def test_backref_type_annotation(self): - """BackRef[...] in annotation should be detected.""" - hint = BackRef[int] - namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False + def test_backref_type_annotation_raises_migration_error(self): + """BackRef[...] in annotation should now raise migration guidance.""" + with pytest.raises( + TypeError, match="Relation\\[list\\[T\\]\\] = BackRef\\(\\)" + ): + BackRef[int] - def test_annotated_backref(self): - """Annotated[BackRef[...], ...] should be detected.""" - hint = Annotated[BackRef[int], "metadata"] - namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False - - def test_backref_pep604_optional_union(self): - """``BackRef[...] | None`` should be detected as a type-side back-reference.""" - hint = BackRef[int] | None - namespace = {"field": None} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False - - def test_annotated_backref_optional_union(self): - """``Annotated[BackRef[...] | None, ...]`` should be detected.""" - hint = Annotated[BackRef[int] | None, "metadata"] - namespace = {"field": None} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False - - def test_string_with_backref(self): - """String annotation containing 'BackRef' should be detected.""" + def test_string_with_backref_is_legacy(self): + """String annotation containing 'BackRef' should be detected as legacy.""" hint = "BackRef[User]" - namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False + assert ModelMetaclass._annotation_looks_like_back_ref(hint) is True - def test_forward_ref_with_backref(self): - """ForwardRef containing 'BackRef' should be detected.""" + def test_forward_ref_with_backref_is_legacy(self): + """ForwardRef containing 'BackRef' should be detected as legacy.""" hint = ForwardRef("BackRef[User]") - namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is True - assert is_field is False + assert ModelMetaclass._annotation_looks_like_back_ref(hint) is True def test_field_with_back_ref_true(self): - """Field(back_ref=True) in namespace should be detected.""" - hint = list[int] + """Field(back_ref=True) in namespace should be returned as payload.""" + hint = Relation[list[int]] field = FieldInfo( annotation=int, default=None, json_schema_extra={FERRO_FIELD_EXTRA_KEY: {"back_ref": True}}, ) namespace = {"field": field} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is False - assert is_field is True + payload = ModelMetaclass._relationship_field_payload("field", hint, namespace) + assert payload == {"back_ref": True} def test_annotated_with_field_back_ref(self): - """Annotated[..., Field(back_ref=True)] should be detected.""" + """Annotated[..., Field(back_ref=True)] should be returned as payload.""" field_info = FieldInfo( annotation=int, default=None, json_schema_extra={FERRO_FIELD_EXTRA_KEY: {"back_ref": True}}, ) - hint = Annotated[list[int], field_info] + hint = Annotated[Relation[list[int]], field_info] namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is False - assert is_field is True + payload = ModelMetaclass._relationship_field_payload("field", hint, namespace) + assert payload == {"back_ref": True} def test_neither_type_nor_field(self): - """Regular field should return (False, False).""" + """Regular field should return no relationship payload.""" hint = int namespace = {} - is_type, is_field = ModelMetaclass._is_back_ref_field("field", hint, namespace) - assert is_type is False - assert is_field is False + assert ( + ModelMetaclass._relationship_field_payload("field", hint, namespace) == {} + ) + + def test_relation_target_from_annotation(self): + """Relation[list[T]] should expose T for relationship metadata.""" + assert ( + ModelMetaclass._relation_target_from_annotation( + "field", Relation[list[int]] + ) + is int + ) + + def test_relation_target_from_string_annotation(self): + """String Relation[list[T]] annotations should expose T.""" + assert ( + ModelMetaclass._relation_target_from_annotation( + "field", 'Relation[list["Course"]]' + ) + == "Course" + ) class TestResolveDeferredAnnotations: @@ -202,7 +191,7 @@ def test_no_foreign_keys_no_changes(self): """No ForeignKeys should leave annotations/namespace unchanged.""" annotations = {"name": str} namespace = {} - local_relations = {"posts": ManyToManyField(related_name="users")} + local_relations = {"posts": ManyToManyRelation(related_name="users")} ModelMetaclass._inject_shadow_fields(annotations, namespace, local_relations) diff --git a/tests/test_one_to_one.py b/tests/test_one_to_one.py index 2eb490f..da38ee4 100644 --- a/tests/test_one_to_one.py +++ b/tests/test_one_to_one.py @@ -32,7 +32,7 @@ async def test_one_to_one_relationship(db_url): class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - profile: BackRef["Profile"] = None + profile: "Profile" = BackRef() class Profile(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -68,7 +68,7 @@ async def test_one_to_one_unique_index_in_sqlite(db_url): class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - profile: BackRef["Profile"] = None + profile: "Profile" = BackRef() class Profile(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -98,11 +98,13 @@ class Profile(Model): @pytest.mark.asyncio @pytest.mark.postgres_only -async def test_one_to_one_unique_index_in_postgres(db_url, postgres_base_url, db_schema_name): +async def test_one_to_one_unique_index_in_postgres( + db_url, postgres_base_url, db_schema_name +): class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - profile: BackRef["Profile"] = None + profile: "Profile" = BackRef() class Profile(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None diff --git a/tests/test_relationship_engine.py b/tests/test_relationship_engine.py index f99a0a6..19568f6 100644 --- a/tests/test_relationship_engine.py +++ b/tests/test_relationship_engine.py @@ -7,7 +7,9 @@ FerroField, Field, ForeignKey, + ManyToMany, Model, + Relation, clear_registry, reset_engine, ) @@ -34,7 +36,7 @@ class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str # Reverse marker - posts: BackRef[list["Post"]] = None + posts: Relation[list["Post"]] = BackRef() class Post(Model): @@ -44,6 +46,128 @@ class Post(Model): author: Annotated[User, ForeignKey(related_name="posts")] +def test_relation_back_ref_helper_declares_reverse_relation(): + """Relation[list[T]] = BackRef() declares a reverse collection relation.""" + + class Role(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + candidates: Relation[list["Candidate"]] = BackRef() + + class Candidate(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + role: Annotated[Role, ForeignKey(related_name="candidates")] + + assert Role.ferro_relations["candidates"] == "BackRef" + assert "candidates" not in Role.model_fields + role = Role(name="Engineering") + assert role.name == "Engineering" + + from ferro.relations import resolve_relationships + + resolve_relationships() + assert hasattr(Role, "candidates") + assert Role.candidates is not None + + +def test_relation_back_ref_field_equivalent_declares_reverse_relation(): + """Field(back_ref=True) is the lower-level equivalent of BackRef().""" + + class RoleViaField(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + candidates: Relation[list["CandidateViaField"]] = Field(back_ref=True) + + class CandidateViaField(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + role: Annotated[RoleViaField, ForeignKey(related_name="candidates")] + + assert RoleViaField.ferro_relations["candidates"] == "BackRef" + assert "candidates" not in RoleViaField.model_fields + + from ferro.relations import resolve_relationships + + resolve_relationships() + assert hasattr(RoleViaField, "candidates") + + +def test_relation_many_to_many_helper_declares_collection_relation(): + """Relation[list[T]] = ManyToMany(...) declares a many-to-many relation.""" + + class Student(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + courses: Relation[list["Course"]] = ManyToMany(related_name="students") + + class Course(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + title: str + students: Relation[list["Student"]] = BackRef() + + rel = Student.ferro_relations["courses"] + assert rel.related_name == "students" + assert rel.through is None + assert rel.to == "Course" + assert "courses" not in Student.model_fields + + from ferro.relations import resolve_relationships + + resolve_relationships() + assert hasattr(Student, "courses") + assert hasattr(Course, "students") + + +def test_relation_many_to_many_field_equivalent_declares_collection_relation(): + """Field(many_to_many=True, ...) is the lower-level equivalent of ManyToMany().""" + + class StudentViaField(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + name: str + courses: Relation[list["CourseViaField"]] = Field( + many_to_many=True, related_name="students", through="enrollments" + ) + + class CourseViaField(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + title: str + students: Relation[list["StudentViaField"]] = BackRef() + + rel = StudentViaField.ferro_relations["courses"] + assert rel.related_name == "students" + assert rel.through == "enrollments" + assert rel.to == "CourseViaField" + assert "courses" not in StudentViaField.model_fields + + +def test_old_backref_type_marker_raises_migration_error(): + """Old BackRef[...] type-marker syntax fails with actionable guidance.""" + + with pytest.raises(TypeError, match="Relation\\[list\\[T\\]\\] = BackRef\\(\\)"): + + class OldRole(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + candidates: BackRef[list[int]] = None + + +def test_backref_plain_list_annotation_raises_migration_error(): + """BackRef() collection fields must use Relation[list[T]], not list[T].""" + + with pytest.raises(TypeError, match="Relation\\[list\\[T\\]\\] = BackRef\\(\\)"): + + class PlainListRole(Model): + id: Annotated[int | None, FerroField(primary_key=True)] = None + candidates: list[int] = BackRef() + + +def test_many_to_many_field_import_removed_from_public_api(): + """ManyToManyField is no longer exported as public API.""" + import ferro + + assert not hasattr(ferro, "ManyToManyField") + + def test_metadata_discovery(): """Verify that the Metaclass finds ForeignKey and BackRef annotations.""" # Before resolution @@ -88,7 +212,7 @@ class Post(Model): class Author(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - posts: BackRef[list[Post]] = None + posts: Relation[list[Post]] = BackRef() # Initially it's a string/ForwardRef raw_to = Post.ferro_relations["author"].to @@ -112,7 +236,7 @@ class User(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str # WRONG NAME HERE - wrong_name: BackRef[list["Post"]] = None + wrong_name: Relation[list["Post"]] = BackRef() class Post(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -132,7 +256,7 @@ def test_back_ref_via_field_default(): class UserViaField(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - posts: list["PostViaField"] | None = Field(default=None, back_ref=True) + posts: Relation[list["PostViaField"]] = Field(back_ref=True) class PostViaField(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -154,7 +278,7 @@ def test_back_ref_via_annotated_field(): class UserAnnotated(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - posts: Annotated[list["PostAnnotated"] | None, Field(back_ref=True)] = None + posts: Annotated[Relation[list["PostAnnotated"]], Field(back_ref=True)] class PostAnnotated(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -170,18 +294,22 @@ class PostAnnotated(Model): assert hasattr(UserAnnotated, "posts") -def test_back_ref_and_field_back_ref_raises(): - """Cannot use both BackRef and Field(back_ref=True) on the same field.""" +def test_back_ref_and_many_to_many_flags_raise(): + """Cannot mark one relation field as both reverse and many-to-many.""" with pytest.raises( TypeError, - match="Cannot use both BackRef and Field\\(back_ref=True\\) on the same field 'posts'", + match="cannot be both back_ref and many_to_many", ): class UserDouble(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None username: str - posts: BackRef[list["PostDouble"]] = Field(default=None, back_ref=True) + posts: Relation[list["PostDouble"]] = Field( + back_ref=True, + many_to_many=True, + related_name="users", + ) class PostDouble(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None diff --git a/tests/test_schema_constraints.py b/tests/test_schema_constraints.py index 0b912ae..a4c9ca6 100644 --- a/tests/test_schema_constraints.py +++ b/tests/test_schema_constraints.py @@ -9,6 +9,7 @@ FerroField, ForeignKey, Model, + Relation, clear_registry, connect, reset_engine, @@ -28,6 +29,7 @@ def cleanup(): @pytest.mark.sqlite_only async def test_runtime_create_tables_respects_explicit_nullable_override(db_url): """Rust DDL should honor the same explicit nullable override that Alembic sees.""" + class NullableOverrideRow(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None field_a: int | None = Field(default=None, nullable=False) @@ -48,10 +50,11 @@ class NullableOverrideRow(Model): @pytest.mark.sqlite_only async def test_foreign_key_constraint_exists(db_url): """Verify that Rust generates the actual FOREIGN KEY constraint in SQL.""" + class Category(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - products: BackRef[list["Product"]] = None + products: Relation[list["Product"]] = BackRef() class Product(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None @@ -91,7 +94,7 @@ async def test_foreign_key_constraint_exists_in_postgres( class Category(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - products: BackRef[list["Product"]] = None + products: Relation[list["Product"]] = BackRef() class Product(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None diff --git a/tests/test_shadow_fk_types.py b/tests/test_shadow_fk_types.py index fb17d85..b148638 100644 --- a/tests/test_shadow_fk_types.py +++ b/tests/test_shadow_fk_types.py @@ -7,7 +7,7 @@ import pytest import ferro -from ferro import BackRef, FerroField, Field, ForeignKey, Model, connect +from ferro import BackRef, FerroField, Field, ForeignKey, Model, Relation, connect from ferro._shadow_fk_types import ( is_fallback_shadow_annotation, pk_python_type_for_model, @@ -75,7 +75,7 @@ class ReconcileChild(Model): class ReconcileParent(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None name: str - children: BackRef[list[ReconcileChild]] = None + children: Relation[list[ReconcileChild]] = BackRef() assert is_fallback_shadow_annotation(ReconcileChild.__annotations__["parent_id"]) @@ -94,7 +94,7 @@ async def test_uuid_fk_create_get_dump(db_url): class UuidIssueParent(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str - children: BackRef[list["UuidIssueChild"]] = None + children: Relation[list["UuidIssueChild"]] = BackRef() class UuidIssueChild(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -108,7 +108,9 @@ class UuidIssueChild(Model): fetched = await UuidIssueChild.get(child.id) assert fetched.parent_id == parent.id - by_shadow = await UuidIssueChild.where(UuidIssueChild.parent_id == parent.id).first() + by_shadow = await UuidIssueChild.where( + UuidIssueChild.parent_id == parent.id + ).first() assert by_shadow is not None assert by_shadow.id == child.id @@ -137,7 +139,7 @@ class UuidFrwChild(Model): class UuidFrwParent(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str - children: BackRef[list[UuidFrwChild]] = None + children: Relation[list[UuidFrwChild]] = BackRef() await connect(db_url, auto_migrate=True) @@ -154,7 +156,7 @@ def test_uuid_child_model_validate_accepts_string_parent_id(): class VParent(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) name: str - children: BackRef[list["VChild"]] = None + children: Relation[list["VChild"]] = BackRef() class VChild(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) @@ -178,13 +180,13 @@ def test_nullable_fk_annotation_does_not_crash(): class NullableFkParent(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) - children: BackRef[list["NullableFkChild"]] = None + children: Relation[list["NullableFkChild"]] = BackRef() class NullableFkChild(Model): id: UUID = Field(default_factory=uuid4, primary_key=True) - parent: Annotated[NullableFkParent | None, ForeignKey(related_name="children")] = ( - None - ) + parent: Annotated[ + NullableFkParent | None, ForeignKey(related_name="children") + ] = None resolve_relationships() assert NullableFkChild.ferro_relations["parent"].to is NullableFkParent @@ -199,7 +201,7 @@ async def test_uuid_fk_save_after_reparenting(db_url): class UuidMutParent(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) name: str - kids: BackRef[list["UuidMutChild"]] = None + kids: Relation[list["UuidMutChild"]] = BackRef() class UuidMutChild(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) @@ -231,7 +233,7 @@ async def test_uuid_fk_bulk_create(db_url): class UuidBulkParent(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4) name: str - items: BackRef[list["UuidBulkItem"]] = None + items: Relation[list["UuidBulkItem"]] = BackRef() class UuidBulkItem(Model): id: Annotated[UUID, FerroField(primary_key=True)] = Field(default_factory=uuid4)