Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions docs/api/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 5 additions & 5 deletions docs/coming-soon.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions docs/getting-started/tutorial.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/models-and-fields.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
45 changes: 22 additions & 23 deletions docs/guide/relationships.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -51,40 +51,40 @@ 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
title: str
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
title: str
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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion docs/howto/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
4 changes: 2 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions docs/migration-sqlalchemy.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions scripts/demo_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@
BackRef,
FerroField,
ForeignKey,
ManyToManyField,
ManyToMany,
Model,
Relation,
connect,
transaction,
)
Expand All @@ -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):
Expand All @@ -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():
Expand Down
9 changes: 5 additions & 4 deletions src/ferro/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand Down
Loading
Loading