ferro-orm 0.3.4__tar.gz → 0.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/CHANGELOG.md +13 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/Cargo.lock +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/Cargo.toml +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/PKG-INFO +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/relationships.md +10 -3
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/coming-soon.md +5 -5
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/tutorial.md +8 -8
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/migrations.md +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/models-and-fields.md +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/relationships.md +22 -23
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/testing.md +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/index.md +2 -2
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/migration-sqlalchemy.md +2 -2
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/pyproject.toml +1 -1
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/scripts/demo_queries.py +5 -4
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/__init__.py +5 -4
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/base.py +7 -5
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/fields.py +66 -3
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/metaclass.py +146 -64
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/__init__.py +2 -2
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/builder.py +59 -5
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/relations/__init__.py +12 -6
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/relations/descriptors.py +10 -8
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_bridge.py +10 -9
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_nullability.py +22 -10
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_auto_migrate.py +22 -11
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_composite_unique.py +11 -11
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_documentation_features.py +7 -6
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_metaclass_internals.py +69 -80
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_one_to_one.py +6 -4
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_relationship_engine.py +137 -9
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema_constraints.py +5 -2
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_shadow_fk_types.py +14 -12
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PERMISSIONS.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PYPI_CHECKLIST.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PYPI_SETUP.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/generated/wheels.generated.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/pull_request_template.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/ci.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/packaging-smoke.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/publish-docs.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/publish.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/release.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.gitignore +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.pre-commit-config.yaml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.python-version +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/CONTRIBUTING.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/LICENSE +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/README.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/TEST_RESULTS.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/fields.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/model.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/query.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/transactions.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/utilities.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/changelog.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/architecture.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/identity-map.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/performance.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/type-safety.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/contributing.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/faq.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/installation.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/next-steps.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/backend.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/database.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/mutations.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/queries.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/transactions.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/multiple-databases.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/pagination.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/soft-deletes.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/timestamps.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/stylesheets/extra.css +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/why-ferro.md +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/justfile +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/mkdocs.yml +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/backend.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/connection.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_annotation_utils.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_core.pyi +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_shadow_fk_types.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/composite_uniques.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/migrations/__init__.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/migrations/alembic.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/models.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/py.typed +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/nodes.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/schema_metadata.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/state.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/lib.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/operations.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/query.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/schema.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/state.rs +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/__init__.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/conftest.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/db_backends.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_aggregation.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_autogenerate.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_type_mapping.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_bulk_update.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_connection.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_constraints.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_crud.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_db_backends.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_deletion.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_docs_examples.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_field_wrapper.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_helpers.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_hydration.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_metadata.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_models.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_query_builder.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_refresh.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema_enum_annotations.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_static_contracts.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_string_search.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_structural_types.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_temporal_types.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_transactions.py +0 -0
- {ferro_orm-0.3.4 → ferro_orm-0.4.0}/uv.lock +0 -0
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
# CHANGELOG
|
|
2
2
|
|
|
3
3
|
|
|
4
|
+
## v0.4.0 (2026-04-27)
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
- Correct BackRef type hinting for all/first
|
|
9
|
+
([`6171923`](https://github.com/syn54x/ferro-orm/commit/617192328d77a8159c671ed6e469dc489c462e42))
|
|
10
|
+
|
|
11
|
+
### Features
|
|
12
|
+
|
|
13
|
+
- Redesign relationship declarations
|
|
14
|
+
([`911e77d`](https://github.com/syn54x/ferro-orm/commit/911e77d15a63df893543bfe6500c5283e8f066f3))
|
|
15
|
+
|
|
16
|
+
|
|
4
17
|
## v0.3.4 (2026-04-25)
|
|
5
18
|
|
|
6
19
|
### Bug Fixes
|
|
@@ -9,16 +9,23 @@ Complete reference for relationship types.
|
|
|
9
9
|
show_source: false
|
|
10
10
|
heading_level: 3
|
|
11
11
|
|
|
12
|
-
##
|
|
12
|
+
## Relation
|
|
13
13
|
|
|
14
|
-
::: ferro.
|
|
14
|
+
::: ferro.query.builder.Relation
|
|
15
15
|
options:
|
|
16
16
|
show_source: false
|
|
17
17
|
heading_level: 3
|
|
18
18
|
|
|
19
19
|
## BackRef
|
|
20
20
|
|
|
21
|
-
::: ferro.
|
|
21
|
+
::: ferro.fields.BackRef
|
|
22
|
+
options:
|
|
23
|
+
show_source: false
|
|
24
|
+
heading_level: 3
|
|
25
|
+
|
|
26
|
+
## ManyToMany
|
|
27
|
+
|
|
28
|
+
::: ferro.fields.ManyToMany
|
|
22
29
|
options:
|
|
23
30
|
show_source: false
|
|
24
31
|
heading_level: 3
|
|
@@ -425,21 +425,21 @@ Document the exception hierarchy and import paths:
|
|
|
425
425
|
- `docs/guide/relationships.md` (lines 176-289)
|
|
426
426
|
|
|
427
427
|
**Description:**
|
|
428
|
-
Many-to-many relationships are defined with `
|
|
428
|
+
Many-to-many relationships are defined with `ManyToMany(...)`, but the join tables are not automatically created during `auto_migrate=True`.
|
|
429
429
|
|
|
430
430
|
**Example (Partially Working):**
|
|
431
431
|
```python
|
|
432
432
|
from typing import Annotated
|
|
433
433
|
|
|
434
|
-
from ferro import BackRef, Field,
|
|
434
|
+
from ferro import BackRef, Field, ManyToMany, Model, Relation
|
|
435
435
|
|
|
436
436
|
class Post(Model):
|
|
437
437
|
id: int | None = Field(default=None, primary_key=True)
|
|
438
|
-
tags:
|
|
438
|
+
tags: Relation[list["Tag"]] = ManyToMany(related_name="posts")
|
|
439
439
|
|
|
440
440
|
class Tag(Model):
|
|
441
441
|
id: int | None = Field(default=None, primary_key=True)
|
|
442
|
-
posts:
|
|
442
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
443
443
|
|
|
444
444
|
# Models created, but join table 'post_tags' is NOT auto-created
|
|
445
445
|
# 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
|
|
|
467
467
|
```python
|
|
468
468
|
class User(Model):
|
|
469
469
|
id: int
|
|
470
|
-
profile:
|
|
470
|
+
profile: "Profile" = BackRef()
|
|
471
471
|
|
|
472
472
|
class Profile(Model):
|
|
473
473
|
id: int
|
|
@@ -26,14 +26,14 @@ Let's create a blog with users, posts, and comments:
|
|
|
26
26
|
import asyncio
|
|
27
27
|
from datetime import datetime
|
|
28
28
|
from typing import Annotated
|
|
29
|
-
from ferro import Model, Field, ForeignKey, BackRef, connect
|
|
29
|
+
from ferro import Model, Field, ForeignKey, BackRef, Relation, connect
|
|
30
30
|
|
|
31
31
|
class User(Model):
|
|
32
32
|
id: int | None = Field(default=None, primary_key=True)
|
|
33
33
|
username: str = Field(unique=True)
|
|
34
34
|
email: str = Field(unique=True)
|
|
35
|
-
posts:
|
|
36
|
-
comments:
|
|
35
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
36
|
+
comments: Relation[list["Comment"]] = BackRef()
|
|
37
37
|
|
|
38
38
|
class Post(Model):
|
|
39
39
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -42,7 +42,7 @@ class Post(Model):
|
|
|
42
42
|
published: bool = False
|
|
43
43
|
created_at: datetime = datetime.now()
|
|
44
44
|
author: Annotated[User, ForeignKey(related_name="posts")]
|
|
45
|
-
comments:
|
|
45
|
+
comments: Relation[list["Comment"]] = BackRef()
|
|
46
46
|
|
|
47
47
|
class Comment(Model):
|
|
48
48
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -305,14 +305,14 @@ Here's the full tutorial code:
|
|
|
305
305
|
import asyncio
|
|
306
306
|
from datetime import datetime
|
|
307
307
|
from typing import Annotated
|
|
308
|
-
from ferro import Model, Field, ForeignKey, BackRef, connect
|
|
308
|
+
from ferro import Model, Field, ForeignKey, BackRef, Relation, connect
|
|
309
309
|
|
|
310
310
|
class User(Model):
|
|
311
311
|
id: int | None = Field(default=None, primary_key=True)
|
|
312
312
|
username: str = Field(unique=True)
|
|
313
313
|
email: str = Field(unique=True)
|
|
314
|
-
posts:
|
|
315
|
-
comments:
|
|
314
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
315
|
+
comments: Relation[list["Comment"]] = BackRef()
|
|
316
316
|
|
|
317
317
|
class Post(Model):
|
|
318
318
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -321,7 +321,7 @@ class Post(Model):
|
|
|
321
321
|
published: bool = False
|
|
322
322
|
created_at: datetime = datetime.now()
|
|
323
323
|
author: Annotated[User, ForeignKey(related_name="posts")]
|
|
324
|
-
comments:
|
|
324
|
+
comments: Relation[list["Comment"]] = BackRef()
|
|
325
325
|
|
|
326
326
|
class Comment(Model):
|
|
327
327
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -301,7 +301,7 @@ class Post(Model):
|
|
|
301
301
|
|
|
302
302
|
```python
|
|
303
303
|
class Student(Model):
|
|
304
|
-
courses:
|
|
304
|
+
courses: Relation[list["Course"]] = ManyToMany(related_name="students")
|
|
305
305
|
|
|
306
306
|
# Automatically generates join table:
|
|
307
307
|
# CREATE TABLE student_courses (
|
|
@@ -159,7 +159,7 @@ class OrgMembership(Model):
|
|
|
159
159
|
|
|
160
160
|
**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.
|
|
161
161
|
|
|
162
|
-
**Many-to-many join tables:** When you use `
|
|
162
|
+
**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.
|
|
163
163
|
|
|
164
164
|
See also [Schema management / migrations](migrations.md) for how composite uniques appear in Alembic metadata.
|
|
165
165
|
|
|
@@ -8,12 +8,12 @@ Relationships in Ferro are **lazy** — data is never fetched until you explicit
|
|
|
8
8
|
|
|
9
9
|
### API Styles
|
|
10
10
|
|
|
11
|
-
Like scalar field constraints ([assignment vs `Annotated[..., Field(...)]`](models-and-fields.md#field-constraints)),
|
|
11
|
+
Like scalar field constraints ([assignment vs `Annotated[..., Field(...)]`](models-and-fields.md#field-constraints)), relationship metadata can be declared in two equivalent styles:
|
|
12
12
|
|
|
13
|
-
- **
|
|
14
|
-
- **
|
|
13
|
+
- **Helper-style** (`BackRef()`, `ManyToMany(...)`): Recommended relationship helpers
|
|
14
|
+
- **Field-style** (`Field(back_ref=True)`, `Field(many_to_many=True, ...)`): Lower-level `Field()` syntax
|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
Collection relationships are typed with `Relation[list[T]]`, which reflects the lazy query-like object returned at runtime.
|
|
17
17
|
|
|
18
18
|
### Lazy Loading Behavior
|
|
19
19
|
|
|
@@ -51,16 +51,16 @@ erDiagram
|
|
|
51
51
|
}
|
|
52
52
|
```
|
|
53
53
|
|
|
54
|
-
###
|
|
54
|
+
### Helper-style (with `BackRef()`)
|
|
55
55
|
|
|
56
56
|
```python
|
|
57
57
|
from typing import Annotated
|
|
58
|
-
from ferro import Model, ForeignKey, BackRef
|
|
58
|
+
from ferro import Model, ForeignKey, BackRef, Relation
|
|
59
59
|
|
|
60
60
|
class Author(Model):
|
|
61
61
|
id: int
|
|
62
62
|
name: str
|
|
63
|
-
posts:
|
|
63
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
64
64
|
|
|
65
65
|
class Post(Model):
|
|
66
66
|
id: int
|
|
@@ -68,15 +68,15 @@ class Post(Model):
|
|
|
68
68
|
author: Annotated[Author, ForeignKey(related_name="posts")]
|
|
69
69
|
```
|
|
70
70
|
|
|
71
|
-
###
|
|
71
|
+
### Field-style (with `Field(back_ref=True)`)
|
|
72
72
|
|
|
73
73
|
```python
|
|
74
|
-
from ferro import Model, ForeignKey, Field
|
|
74
|
+
from ferro import Model, ForeignKey, Field, Relation
|
|
75
75
|
|
|
76
76
|
class Author(Model):
|
|
77
77
|
id: int
|
|
78
78
|
name: str
|
|
79
|
-
posts: list["Post"]
|
|
79
|
+
posts: Relation[list["Post"]] = Field(back_ref=True)
|
|
80
80
|
|
|
81
81
|
class Post(Model):
|
|
82
82
|
id: int
|
|
@@ -84,7 +84,7 @@ class Post(Model):
|
|
|
84
84
|
author: Annotated[Author, ForeignKey(related_name="posts")]
|
|
85
85
|
```
|
|
86
86
|
|
|
87
|
-
You can also use `Annotated` with `Field`: `posts: Annotated[list["Post"]
|
|
87
|
+
You can also use `Annotated` with `Field`: `posts: Annotated[Relation[list["Post"]], Field(back_ref=True)]`
|
|
88
88
|
|
|
89
89
|
### Shadow Fields
|
|
90
90
|
|
|
@@ -152,7 +152,7 @@ from ferro import Model, ForeignKey, BackRef
|
|
|
152
152
|
class User(Model):
|
|
153
153
|
id: int
|
|
154
154
|
username: str
|
|
155
|
-
profile:
|
|
155
|
+
profile: "Profile" = BackRef() # Note: singular relationships do not use Relation
|
|
156
156
|
|
|
157
157
|
class Profile(Model):
|
|
158
158
|
id: int
|
|
@@ -183,7 +183,7 @@ profile_user = await profile.user # Returns User instance
|
|
|
183
183
|
|
|
184
184
|
## Many-to-Many
|
|
185
185
|
|
|
186
|
-
Defined using `
|
|
186
|
+
Defined using `ManyToMany(...)`. Ferro automatically manages the hidden join table required for this relationship.
|
|
187
187
|
|
|
188
188
|
```mermaid
|
|
189
189
|
erDiagram
|
|
@@ -198,37 +198,36 @@ erDiagram
|
|
|
198
198
|
}
|
|
199
199
|
```
|
|
200
200
|
|
|
201
|
-
###
|
|
201
|
+
### Helper-style (with `ManyToMany()` / `BackRef()`)
|
|
202
202
|
|
|
203
203
|
```python
|
|
204
|
-
from
|
|
205
|
-
from ferro import Model, ManyToManyField, BackRef
|
|
204
|
+
from ferro import Model, ManyToMany, BackRef, Relation
|
|
206
205
|
|
|
207
206
|
class Student(Model):
|
|
208
207
|
id: int
|
|
209
208
|
name: str
|
|
210
|
-
courses:
|
|
209
|
+
courses: Relation[list["Course"]] = ManyToMany(related_name="students")
|
|
211
210
|
|
|
212
211
|
class Course(Model):
|
|
213
212
|
id: int
|
|
214
213
|
title: str
|
|
215
|
-
students:
|
|
214
|
+
students: Relation[list["Student"]] = BackRef()
|
|
216
215
|
```
|
|
217
216
|
|
|
218
|
-
###
|
|
217
|
+
### Field-style (with `Field(...)`)
|
|
219
218
|
|
|
220
219
|
```python
|
|
221
|
-
from ferro import Model,
|
|
220
|
+
from ferro import Model, Field, Relation
|
|
222
221
|
|
|
223
222
|
class Student(Model):
|
|
224
223
|
id: int
|
|
225
224
|
name: str
|
|
226
|
-
courses:
|
|
225
|
+
courses: Relation[list["Course"]] = Field(many_to_many=True, related_name="students")
|
|
227
226
|
|
|
228
227
|
class Course(Model):
|
|
229
228
|
id: int
|
|
230
229
|
title: str
|
|
231
|
-
students: list["Student"]
|
|
230
|
+
students: Relation[list["Student"]] = Field(back_ref=True)
|
|
232
231
|
```
|
|
233
232
|
|
|
234
233
|
### Join Table
|
|
@@ -308,7 +307,7 @@ class Employee(Model):
|
|
|
308
307
|
id: int
|
|
309
308
|
name: str
|
|
310
309
|
manager: Annotated["Employee", ForeignKey(related_name="reports")] | None = None
|
|
311
|
-
reports:
|
|
310
|
+
reports: Relation[list["Employee"]] = BackRef()
|
|
312
311
|
|
|
313
312
|
# Usage
|
|
314
313
|
manager = await Employee.create(name="Jane")
|
|
@@ -74,7 +74,7 @@ If no external Postgres URL is set and local PostgreSQL server binaries are unav
|
|
|
74
74
|
|
|
75
75
|
### Bridge-Boundary Regressions
|
|
76
76
|
|
|
77
|
-
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 (`
|
|
77
|
+
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()`).
|
|
78
78
|
|
|
79
79
|
Use these conventions:
|
|
80
80
|
|
|
@@ -29,12 +29,12 @@
|
|
|
29
29
|
```python
|
|
30
30
|
import asyncio
|
|
31
31
|
from typing import Annotated
|
|
32
|
-
from ferro import Model, Field, ForeignKey, BackRef, connect
|
|
32
|
+
from ferro import Model, Field, ForeignKey, BackRef, Relation, connect
|
|
33
33
|
|
|
34
34
|
class Author(Model):
|
|
35
35
|
id: int | None = Field(default=None, primary_key=True)
|
|
36
36
|
name: str
|
|
37
|
-
posts:
|
|
37
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
38
38
|
|
|
39
39
|
class Post(Model):
|
|
40
40
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -89,11 +89,11 @@ class Post(Base):
|
|
|
89
89
|
# Ferro
|
|
90
90
|
from typing import Annotated
|
|
91
91
|
|
|
92
|
-
from ferro import BackRef, Field, ForeignKey, Model
|
|
92
|
+
from ferro import BackRef, Field, ForeignKey, Model, Relation
|
|
93
93
|
|
|
94
94
|
class User(Model):
|
|
95
95
|
id: int | None = Field(default=None, primary_key=True)
|
|
96
|
-
posts:
|
|
96
|
+
posts: Relation[list["Post"]] = BackRef()
|
|
97
97
|
|
|
98
98
|
class Post(Model):
|
|
99
99
|
id: int | None = Field(default=None, primary_key=True)
|
|
@@ -19,8 +19,9 @@ from ferro import (
|
|
|
19
19
|
BackRef,
|
|
20
20
|
FerroField,
|
|
21
21
|
ForeignKey,
|
|
22
|
-
|
|
22
|
+
ManyToMany,
|
|
23
23
|
Model,
|
|
24
|
+
Relation,
|
|
24
25
|
connect,
|
|
25
26
|
transaction,
|
|
26
27
|
)
|
|
@@ -40,7 +41,7 @@ class Category(Model):
|
|
|
40
41
|
id: Annotated[int | None, FerroField(primary_key=True)] = None
|
|
41
42
|
name: str
|
|
42
43
|
# Reverse lookup marker (Zero-Boilerplate)
|
|
43
|
-
products:
|
|
44
|
+
products: Relation[list["Product"]] = BackRef()
|
|
44
45
|
|
|
45
46
|
|
|
46
47
|
class Product(Model):
|
|
@@ -59,13 +60,13 @@ class Product(Model):
|
|
|
59
60
|
class Actor(Model):
|
|
60
61
|
id: Annotated[int | None, FerroField(primary_key=True)] = None
|
|
61
62
|
name: str
|
|
62
|
-
movies:
|
|
63
|
+
movies: Relation[list["Movie"]] = ManyToMany(related_name="actors")
|
|
63
64
|
|
|
64
65
|
|
|
65
66
|
class Movie(Model):
|
|
66
67
|
id: Annotated[int | None, FerroField(primary_key=True)] = None
|
|
67
68
|
title: str
|
|
68
|
-
actors:
|
|
69
|
+
actors: Relation[list[Actor]] = BackRef()
|
|
69
70
|
|
|
70
71
|
|
|
71
72
|
async def run_demo():
|
|
@@ -17,10 +17,10 @@ from ._core import (
|
|
|
17
17
|
from ._core import (
|
|
18
18
|
connect as _core_connect,
|
|
19
19
|
)
|
|
20
|
-
from .base import FerroField, FerroNullable, ForeignKey
|
|
21
|
-
from .fields import Field
|
|
20
|
+
from .base import FerroField, FerroNullable, ForeignKey
|
|
21
|
+
from .fields import BackRef, Field, ManyToMany
|
|
22
22
|
from .models import Model, transaction
|
|
23
|
-
from .query import
|
|
23
|
+
from .query import Relation
|
|
24
24
|
|
|
25
25
|
# Set up the Ferro logger
|
|
26
26
|
_logger = logging.getLogger("ferro")
|
|
@@ -58,8 +58,9 @@ __all__ = [
|
|
|
58
58
|
"FerroNullable",
|
|
59
59
|
"Field",
|
|
60
60
|
"ForeignKey",
|
|
61
|
-
"ManyToManyField",
|
|
62
61
|
"BackRef",
|
|
62
|
+
"ManyToMany",
|
|
63
|
+
"Relation",
|
|
63
64
|
"version",
|
|
64
65
|
"create_tables",
|
|
65
66
|
"reset_engine",
|
|
@@ -140,7 +140,9 @@ class ForeignKey:
|
|
|
140
140
|
self.unique = unique
|
|
141
141
|
self.nullable = _validate_nullable_option(nullable, "ForeignKey")
|
|
142
142
|
if str(self.on_delete).upper() == "SET NULL" and self.nullable is False:
|
|
143
|
-
raise ValueError(
|
|
143
|
+
raise ValueError(
|
|
144
|
+
"ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'"
|
|
145
|
+
)
|
|
144
146
|
#: First type argument of ``Annotated[..., ForeignKey]``; set by the metaclass
|
|
145
147
|
#: for Alembic nullability inference (forward fields are not in ``model_fields``).
|
|
146
148
|
self.relation_annotation: Any | None = None
|
|
@@ -160,8 +162,8 @@ def foreign_key_allows_none(metadata: "ForeignKey") -> bool | None:
|
|
|
160
162
|
return annotation_allows_none(relation_annotation)
|
|
161
163
|
|
|
162
164
|
|
|
163
|
-
class
|
|
164
|
-
"""Describe metadata for a many-to-many relationship
|
|
165
|
+
class ManyToManyRelation:
|
|
166
|
+
"""Describe internal metadata for a many-to-many relationship
|
|
165
167
|
|
|
166
168
|
Attributes:
|
|
167
169
|
|
|
@@ -178,7 +180,7 @@ class ManyToManyField:
|
|
|
178
180
|
>>>
|
|
179
181
|
>>> class Post(Model):
|
|
180
182
|
... id: Annotated[int, FerroField(primary_key=True)]
|
|
181
|
-
... tags:
|
|
183
|
+
... tags: Relation[list["Tag"]] = ManyToMany(related_name="posts")
|
|
182
184
|
"""
|
|
183
185
|
|
|
184
186
|
def __init__(self, related_name: str, through: str | None = None):
|
|
@@ -196,7 +198,7 @@ class ManyToManyField:
|
|
|
196
198
|
>>>
|
|
197
199
|
>>> class User(Model):
|
|
198
200
|
... id: Annotated[int, FerroField(primary_key=True)]
|
|
199
|
-
... teams:
|
|
201
|
+
... teams: Relation[list["Team"]] = ManyToMany(related_name="members", through="team_members")
|
|
200
202
|
"""
|
|
201
203
|
self.to = None # Resolved later
|
|
202
204
|
self.related_name = related_name
|
|
@@ -33,6 +33,9 @@ def Field(
|
|
|
33
33
|
unique: bool = ...,
|
|
34
34
|
index: bool = ...,
|
|
35
35
|
back_ref: bool = ...,
|
|
36
|
+
many_to_many: bool = ...,
|
|
37
|
+
related_name: str | None = ...,
|
|
38
|
+
through: str | None = ...,
|
|
36
39
|
nullable: FerroNullable = ...,
|
|
37
40
|
alias: str | None = ...,
|
|
38
41
|
alias_priority: int | None = ...,
|
|
@@ -81,6 +84,9 @@ def Field(
|
|
|
81
84
|
unique: bool = ...,
|
|
82
85
|
index: bool = ...,
|
|
83
86
|
back_ref: bool = ...,
|
|
87
|
+
many_to_many: bool = ...,
|
|
88
|
+
related_name: str | None = ...,
|
|
89
|
+
through: str | None = ...,
|
|
84
90
|
nullable: FerroNullable = ...,
|
|
85
91
|
alias: str | None = ...,
|
|
86
92
|
alias_priority: int | None = ...,
|
|
@@ -129,6 +135,9 @@ def Field(
|
|
|
129
135
|
unique: bool = ...,
|
|
130
136
|
index: bool = ...,
|
|
131
137
|
back_ref: bool = ...,
|
|
138
|
+
many_to_many: bool = ...,
|
|
139
|
+
related_name: str | None = ...,
|
|
140
|
+
through: str | None = ...,
|
|
132
141
|
nullable: FerroNullable = ...,
|
|
133
142
|
alias: str | None = ...,
|
|
134
143
|
alias_priority: int | None = ...,
|
|
@@ -176,6 +185,9 @@ def Field(
|
|
|
176
185
|
unique: bool = ...,
|
|
177
186
|
index: bool = ...,
|
|
178
187
|
back_ref: bool = ...,
|
|
188
|
+
many_to_many: bool = ...,
|
|
189
|
+
related_name: str | None = ...,
|
|
190
|
+
through: str | None = ...,
|
|
179
191
|
nullable: FerroNullable = ...,
|
|
180
192
|
default_factory: Callable[[], Any] | Callable[[dict[str, Any]], Any],
|
|
181
193
|
alias: str | None = ...,
|
|
@@ -224,6 +236,9 @@ def Field(
|
|
|
224
236
|
unique: bool = ...,
|
|
225
237
|
index: bool = ...,
|
|
226
238
|
back_ref: bool = ...,
|
|
239
|
+
many_to_many: bool = ...,
|
|
240
|
+
related_name: str | None = ...,
|
|
241
|
+
through: str | None = ...,
|
|
227
242
|
nullable: FerroNullable = ...,
|
|
228
243
|
default_factory: Callable[[], _T] | Callable[[dict[str, Any]], _T],
|
|
229
244
|
alias: str | None = ...,
|
|
@@ -272,6 +287,9 @@ def Field(
|
|
|
272
287
|
unique: bool = ...,
|
|
273
288
|
index: bool = ...,
|
|
274
289
|
back_ref: bool = ...,
|
|
290
|
+
many_to_many: bool = ...,
|
|
291
|
+
related_name: str | None = ...,
|
|
292
|
+
through: str | None = ...,
|
|
275
293
|
nullable: FerroNullable = ...,
|
|
276
294
|
alias: str | None = ...,
|
|
277
295
|
alias_priority: int | None = ...,
|
|
@@ -319,6 +337,9 @@ def Field(
|
|
|
319
337
|
unique: bool | Any = _Unset,
|
|
320
338
|
index: bool | Any = _Unset,
|
|
321
339
|
back_ref: bool | Any = _Unset,
|
|
340
|
+
many_to_many: bool | Any = _Unset,
|
|
341
|
+
related_name: str | None | Any = _Unset,
|
|
342
|
+
through: str | None | Any = _Unset,
|
|
322
343
|
nullable: FerroNullable | Any = _Unset,
|
|
323
344
|
default_factory: Callable[[], Any]
|
|
324
345
|
| Callable[[dict[str, Any]], Any]
|
|
@@ -369,8 +390,11 @@ def Field(
|
|
|
369
390
|
unique: Add a **single-column** uniqueness constraint for this column in Ferro.
|
|
370
391
|
Multi-column uniqueness is declared with ``__ferro_composite_uniques__`` on the model.
|
|
371
392
|
index: Request an index for this column in Ferro.
|
|
372
|
-
back_ref: Mark this field as a reverse relationship
|
|
373
|
-
|
|
393
|
+
back_ref: Mark this field as a reverse relationship. This is the lower-level
|
|
394
|
+
equivalent of assigning ``BackRef()`` as the field default.
|
|
395
|
+
many_to_many: Mark this field as a many-to-many relationship.
|
|
396
|
+
related_name: Reverse relationship field name used by many-to-many relationships.
|
|
397
|
+
through: Optional join table name used by many-to-many relationships.
|
|
374
398
|
nullable: Alembic ``Column.nullable`` override for :func:`~ferro.migrations.get_metadata`.
|
|
375
399
|
``\"infer\"`` (default) derives nullability from the field annotation.
|
|
376
400
|
default_factory: A callable to generate the default value. The callable can either take 0 arguments
|
|
@@ -451,6 +475,12 @@ def Field(
|
|
|
451
475
|
ferro_kwargs["index"] = index
|
|
452
476
|
if back_ref is not _Unset:
|
|
453
477
|
ferro_kwargs["back_ref"] = back_ref
|
|
478
|
+
if many_to_many is not _Unset:
|
|
479
|
+
ferro_kwargs["many_to_many"] = many_to_many
|
|
480
|
+
if related_name is not _Unset:
|
|
481
|
+
ferro_kwargs["related_name"] = related_name
|
|
482
|
+
if through is not _Unset:
|
|
483
|
+
ferro_kwargs["through"] = through
|
|
454
484
|
if nullable is not _Unset:
|
|
455
485
|
_validate_nullable_option(nullable, "Field")
|
|
456
486
|
ferro_kwargs["nullable"] = nullable
|
|
@@ -512,4 +542,37 @@ def Field(
|
|
|
512
542
|
)
|
|
513
543
|
|
|
514
544
|
|
|
515
|
-
|
|
545
|
+
class BackRef:
|
|
546
|
+
"""Declare a reverse relationship field.
|
|
547
|
+
|
|
548
|
+
``BackRef()`` is a convenience wrapper around ``Field(back_ref=True)``.
|
|
549
|
+
"""
|
|
550
|
+
|
|
551
|
+
def __new__(cls, **kwargs: Any) -> Any:
|
|
552
|
+
return Field(back_ref=True, **kwargs)
|
|
553
|
+
|
|
554
|
+
@classmethod
|
|
555
|
+
def __class_getitem__(cls, _item: Any) -> Any:
|
|
556
|
+
raise TypeError(
|
|
557
|
+
"BackRef[...] is no longer a type annotation. Use "
|
|
558
|
+
"Relation[list[T]] = BackRef() for collection back-references."
|
|
559
|
+
)
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def ManyToMany(
|
|
563
|
+
*,
|
|
564
|
+
related_name: str,
|
|
565
|
+
through: str | None = None,
|
|
566
|
+
**kwargs: Any,
|
|
567
|
+
) -> Any:
|
|
568
|
+
"""Declare a many-to-many relationship field."""
|
|
569
|
+
|
|
570
|
+
return Field(
|
|
571
|
+
many_to_many=True,
|
|
572
|
+
related_name=related_name,
|
|
573
|
+
through=through,
|
|
574
|
+
**kwargs,
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
__all__ = ["Field", "BackRef", "ManyToMany", "FERRO_FIELD_EXTRA_KEY"]
|