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.
Files changed (126) hide show
  1. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/CHANGELOG.md +13 -0
  2. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/Cargo.lock +1 -1
  3. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/Cargo.toml +1 -1
  4. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/PKG-INFO +1 -1
  5. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/relationships.md +10 -3
  6. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/coming-soon.md +5 -5
  7. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/tutorial.md +8 -8
  8. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/migrations.md +1 -1
  9. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/models-and-fields.md +1 -1
  10. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/relationships.md +22 -23
  11. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/testing.md +1 -1
  12. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/index.md +2 -2
  13. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/migration-sqlalchemy.md +2 -2
  14. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/pyproject.toml +1 -1
  15. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/scripts/demo_queries.py +5 -4
  16. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/__init__.py +5 -4
  17. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/base.py +7 -5
  18. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/fields.py +66 -3
  19. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/metaclass.py +146 -64
  20. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/__init__.py +2 -2
  21. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/builder.py +59 -5
  22. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/relations/__init__.py +12 -6
  23. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/relations/descriptors.py +10 -8
  24. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_bridge.py +10 -9
  25. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_nullability.py +22 -10
  26. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_auto_migrate.py +22 -11
  27. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_composite_unique.py +11 -11
  28. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_documentation_features.py +7 -6
  29. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_metaclass_internals.py +69 -80
  30. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_one_to_one.py +6 -4
  31. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_relationship_engine.py +137 -9
  32. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema_constraints.py +5 -2
  33. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_shadow_fk_types.py +14 -12
  34. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  35. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  36. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PERMISSIONS.md +0 -0
  37. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PYPI_CHECKLIST.md +0 -0
  38. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/PYPI_SETUP.md +0 -0
  39. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/generated/wheels.generated.yml +0 -0
  40. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/pull_request_template.md +0 -0
  41. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/ci.yml +0 -0
  42. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/packaging-smoke.yml +0 -0
  43. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/publish-docs.yml +0 -0
  44. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/publish.yml +0 -0
  45. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.github/workflows/release.yml +0 -0
  46. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.gitignore +0 -0
  47. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.pre-commit-config.yaml +0 -0
  48. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/.python-version +0 -0
  49. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/CONTRIBUTING.md +0 -0
  50. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/LICENSE +0 -0
  51. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/README.md +0 -0
  52. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/TEST_RESULTS.md +0 -0
  53. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/fields.md +0 -0
  54. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/model.md +0 -0
  55. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/query.md +0 -0
  56. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/transactions.md +0 -0
  57. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/api/utilities.md +0 -0
  58. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/changelog.md +0 -0
  59. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/architecture.md +0 -0
  60. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/identity-map.md +0 -0
  61. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/performance.md +0 -0
  62. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/concepts/type-safety.md +0 -0
  63. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/contributing.md +0 -0
  64. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/faq.md +0 -0
  65. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/installation.md +0 -0
  66. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/getting-started/next-steps.md +0 -0
  67. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/backend.md +0 -0
  68. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/database.md +0 -0
  69. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/mutations.md +0 -0
  70. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/queries.md +0 -0
  71. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/guide/transactions.md +0 -0
  72. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/multiple-databases.md +0 -0
  73. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/pagination.md +0 -0
  74. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/soft-deletes.md +0 -0
  75. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/howto/timestamps.md +0 -0
  76. {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
  77. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/stylesheets/extra.css +0 -0
  78. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/docs/why-ferro.md +0 -0
  79. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/justfile +0 -0
  80. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/mkdocs.yml +0 -0
  81. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/backend.rs +0 -0
  82. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/connection.rs +0 -0
  83. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_annotation_utils.py +0 -0
  84. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_core.pyi +0 -0
  85. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/_shadow_fk_types.py +0 -0
  86. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/composite_uniques.py +0 -0
  87. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/migrations/__init__.py +0 -0
  88. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/migrations/alembic.py +0 -0
  89. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/models.py +0 -0
  90. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/py.typed +0 -0
  91. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/query/nodes.py +0 -0
  92. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/schema_metadata.py +0 -0
  93. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/ferro/state.py +0 -0
  94. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/lib.rs +0 -0
  95. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/operations.rs +0 -0
  96. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/query.rs +0 -0
  97. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/schema.rs +0 -0
  98. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/src/state.rs +0 -0
  99. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/__init__.py +0 -0
  100. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/conftest.py +0 -0
  101. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/db_backends.py +0 -0
  102. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_aggregation.py +0 -0
  103. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_autogenerate.py +0 -0
  104. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_alembic_type_mapping.py +0 -0
  105. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_bulk_update.py +0 -0
  106. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_connection.py +0 -0
  107. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_constraints.py +0 -0
  108. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_crud.py +0 -0
  109. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_db_backends.py +0 -0
  110. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_deletion.py +0 -0
  111. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_docs_examples.py +0 -0
  112. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_field_wrapper.py +0 -0
  113. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_helpers.py +0 -0
  114. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_hydration.py +0 -0
  115. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_metadata.py +0 -0
  116. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_models.py +0 -0
  117. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_query_builder.py +0 -0
  118. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_refresh.py +0 -0
  119. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema.py +0 -0
  120. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_schema_enum_annotations.py +0 -0
  121. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_static_contracts.py +0 -0
  122. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_string_search.py +0 -0
  123. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_structural_types.py +0 -0
  124. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_temporal_types.py +0 -0
  125. {ferro_orm-0.3.4 → ferro_orm-0.4.0}/tests/test_transactions.py +0 -0
  126. {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
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.3.4"
297
+ version = "0.4.0"
298
298
  dependencies = [
299
299
  "dashmap",
300
300
  "once_cell",
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "ferro"
3
- version = "0.3.4"
3
+ version = "0.4.0"
4
4
  edition = "2024"
5
5
  readme = "README.md"
6
6
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ferro-orm
3
- Version: 0.3.4
3
+ Version: 0.4.0
4
4
  Requires-Dist: pydantic>=2.0
5
5
  Requires-Dist: alembic>=1.18.1 ; extra == 'alembic'
6
6
  Requires-Dist: sqlalchemy>=2.0.46 ; extra == 'alembic'
@@ -9,16 +9,23 @@ Complete reference for relationship types.
9
9
  show_source: false
10
10
  heading_level: 3
11
11
 
12
- ## ManyToManyField
12
+ ## Relation
13
13
 
14
- ::: ferro.base.ManyToManyField
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.query.builder.BackRef
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 `ManyToManyField`, but the join tables are not automatically created during `auto_migrate=True`.
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, ManyToManyField, Model
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: Annotated[list["Tag"], ManyToManyField(related_name="posts")] = None
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: BackRef[list["Post"]] | None = None
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: BackRef["Profile"] | None = None
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: BackRef[list["Post"]] | None = None
36
- comments: BackRef[list["Comment"]] | None = None
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: BackRef[list["Comment"]] | None = None
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: BackRef[list["Post"]] | None = None
315
- comments: BackRef[list["Comment"]] | None = None
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: BackRef[list["Comment"]] | None = None
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: Annotated[list["Course"], ManyToManyField(related_name="students")]
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 `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.
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)), relationships can be declared in two equivalent styles:
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
- - **Annotated-style** (`BackRef`): Type-first approach using `typing.Annotated`
14
- - **Pydantic-style** (`Field(back_ref=True)`): Familiar `Field()` syntax
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
- Choose one style and use it consistently. Do not mix `BackRef` and `back_ref=True` on the same field.
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
- ### Annotated-style (with `BackRef`)
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: BackRef[list["Post"]] | None = None
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
- ### Pydantic-style (with `Field(back_ref=True)`)
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"] | None = Field(default=None, back_ref=True)
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"] | None, Field(back_ref=True)] = None`
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: BackRef["Profile"] | None = None # Note: singular, not list
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 `ManyToManyField`. Ferro automatically manages the hidden join table required for this relationship.
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
- ### Annotated-style (with `BackRef`)
201
+ ### Helper-style (with `ManyToMany()` / `BackRef()`)
202
202
 
203
203
  ```python
204
- from typing import Annotated
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: Annotated[list["Course"], ManyToManyField(related_name="students")] = None
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: BackRef[list["Student"]] | None = None
214
+ students: Relation[list["Student"]] = BackRef()
216
215
  ```
217
216
 
218
- ### Pydantic-style (with `Field(back_ref=True)`)
217
+ ### Field-style (with `Field(...)`)
219
218
 
220
219
  ```python
221
- from ferro import Model, ManyToManyField, Field
220
+ from ferro import Model, Field, Relation
222
221
 
223
222
  class Student(Model):
224
223
  id: int
225
224
  name: str
226
- courses: Annotated[list["Course"], ManyToManyField(related_name="students")] = None
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"] | None = Field(default=None, back_ref=True)
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: BackRef[list["Employee"]] | None = None
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 (`ManyToManyField.add()`, `.remove()`, `.clear()`).
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: BackRef[list["Post"]] | None = None
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: BackRef[list["Post"]] | None = None
96
+ posts: Relation[list["Post"]] = BackRef()
97
97
 
98
98
  class Post(Model):
99
99
  id: int | None = Field(default=None, primary_key=True)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ferro-orm"
3
- version = "0.3.4"
3
+ version = "0.4.0"
4
4
  description = "A high-performance, Rust-backed ORM for Python."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -19,8 +19,9 @@ from ferro import (
19
19
  BackRef,
20
20
  FerroField,
21
21
  ForeignKey,
22
- ManyToManyField,
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: BackRef[list["Product"]] = None
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: Annotated[list["Movie"], ManyToManyField(related_name="actors")] = None
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: BackRef[list[Actor]] = None
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, ManyToManyField
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 BackRef
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("ForeignKey(on_delete='SET NULL') requires nullable=True or 'infer'")
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 ManyToManyField:
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: Annotated[list[int], ManyToManyField("posts")]
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: Annotated[list[int], ManyToManyField("members", through="team_members")]
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 (same as BackRef in the type).
373
- Do not use together with a BackRef annotation on the same field.
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
- __all__ = ["Field", "FERRO_FIELD_EXTRA_KEY"]
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"]