ferro-orm 0.3.3__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 (129) hide show
  1. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.gitignore +3 -0
  2. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/CHANGELOG.md +47 -0
  3. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/Cargo.lock +1 -1
  4. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/Cargo.toml +1 -1
  5. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/PKG-INFO +1 -1
  6. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/relationships.md +10 -3
  7. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/coming-soon.md +5 -5
  8. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/getting-started/tutorial.md +8 -8
  9. ferro_orm-0.4.0/docs/guide/backend.md +401 -0
  10. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/migrations.md +1 -1
  11. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/models-and-fields.md +1 -1
  12. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/relationships.md +22 -23
  13. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/howto/testing.md +32 -7
  14. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/index.md +2 -2
  15. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/migration-sqlalchemy.md +2 -2
  16. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/mkdocs.yml +2 -0
  17. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/pyproject.toml +3 -1
  18. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/scripts/demo_queries.py +5 -4
  19. ferro_orm-0.4.0/src/backend.rs +650 -0
  20. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/connection.rs +67 -19
  21. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/__init__.py +5 -4
  22. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/base.py +7 -5
  23. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/fields.py +66 -3
  24. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/metaclass.py +146 -64
  25. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/migrations/alembic.py +10 -30
  26. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/query/__init__.py +2 -2
  27. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/query/builder.py +68 -8
  28. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/relations/__init__.py +15 -6
  29. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/relations/descriptors.py +10 -8
  30. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/schema_metadata.py +16 -0
  31. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/operations.rs +755 -649
  32. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/query.rs +103 -42
  33. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/schema.rs +175 -160
  34. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/state.rs +10 -23
  35. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/conftest.py +38 -10
  36. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/db_backends.py +50 -2
  37. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_alembic_bridge.py +11 -9
  38. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_alembic_nullability.py +25 -8
  39. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_alembic_type_mapping.py +2 -1
  40. ferro_orm-0.4.0/tests/test_auto_migrate.py +200 -0
  41. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_composite_unique.py +11 -11
  42. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_db_backends.py +49 -0
  43. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_documentation_features.py +7 -6
  44. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_metaclass_internals.py +69 -80
  45. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_one_to_one.py +6 -4
  46. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_query_builder.py +74 -1
  47. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_relationship_engine.py +137 -9
  48. ferro_orm-0.4.0/tests/test_schema.py +121 -0
  49. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_schema_constraints.py +5 -2
  50. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_shadow_fk_types.py +14 -12
  51. ferro_orm-0.4.0/tests/test_static_contracts.py +8 -0
  52. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_structural_types.py +70 -0
  53. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/uv.lock +42 -1
  54. ferro_orm-0.3.3/src/backend.rs +0 -108
  55. ferro_orm-0.3.3/tests/test_auto_migrate.py +0 -80
  56. ferro_orm-0.3.3/tests/test_schema.py +0 -36
  57. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  58. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  59. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/PERMISSIONS.md +0 -0
  60. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/PYPI_CHECKLIST.md +0 -0
  61. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/PYPI_SETUP.md +0 -0
  62. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/generated/wheels.generated.yml +0 -0
  63. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/pull_request_template.md +0 -0
  64. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/workflows/ci.yml +0 -0
  65. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/workflows/packaging-smoke.yml +0 -0
  66. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/workflows/publish-docs.yml +0 -0
  67. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/workflows/publish.yml +0 -0
  68. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.github/workflows/release.yml +0 -0
  69. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.pre-commit-config.yaml +0 -0
  70. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/.python-version +0 -0
  71. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/CONTRIBUTING.md +0 -0
  72. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/LICENSE +0 -0
  73. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/README.md +0 -0
  74. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/TEST_RESULTS.md +0 -0
  75. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/fields.md +0 -0
  76. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/model.md +0 -0
  77. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/query.md +0 -0
  78. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/transactions.md +0 -0
  79. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/api/utilities.md +0 -0
  80. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/changelog.md +0 -0
  81. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/concepts/architecture.md +0 -0
  82. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/concepts/identity-map.md +0 -0
  83. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/concepts/performance.md +0 -0
  84. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/concepts/type-safety.md +0 -0
  85. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/contributing.md +0 -0
  86. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/faq.md +0 -0
  87. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/getting-started/installation.md +0 -0
  88. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/getting-started/next-steps.md +0 -0
  89. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/database.md +0 -0
  90. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/mutations.md +0 -0
  91. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/queries.md +0 -0
  92. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/guide/transactions.md +0 -0
  93. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/howto/multiple-databases.md +0 -0
  94. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/howto/pagination.md +0 -0
  95. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/howto/soft-deletes.md +0 -0
  96. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/howto/timestamps.md +0 -0
  97. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  98. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/stylesheets/extra.css +0 -0
  99. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/docs/why-ferro.md +0 -0
  100. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/justfile +0 -0
  101. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/_annotation_utils.py +0 -0
  102. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/_core.pyi +0 -0
  103. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/_shadow_fk_types.py +0 -0
  104. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/composite_uniques.py +0 -0
  105. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/migrations/__init__.py +0 -0
  106. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/models.py +0 -0
  107. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/py.typed +0 -0
  108. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/query/nodes.py +0 -0
  109. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/ferro/state.py +0 -0
  110. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/src/lib.rs +0 -0
  111. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/__init__.py +0 -0
  112. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_aggregation.py +0 -0
  113. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_alembic_autogenerate.py +0 -0
  114. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_bulk_update.py +0 -0
  115. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_connection.py +0 -0
  116. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_constraints.py +0 -0
  117. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_crud.py +0 -0
  118. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_deletion.py +0 -0
  119. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_docs_examples.py +0 -0
  120. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_field_wrapper.py +0 -0
  121. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_helpers.py +0 -0
  122. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_hydration.py +0 -0
  123. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_metadata.py +0 -0
  124. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_models.py +0 -0
  125. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_refresh.py +0 -0
  126. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_schema_enum_annotations.py +0 -0
  127. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_string_search.py +0 -0
  128. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_temporal_types.py +0 -0
  129. {ferro_orm-0.3.3 → ferro_orm-0.4.0}/tests/test_transactions.py +0 -0
@@ -1,3 +1,6 @@
1
+ # Local git worktrees (see: git worktree add)
2
+ .worktrees/
3
+
1
4
  # Generated by Cargo
2
5
  # will have compiled files and executables
3
6
  debug
@@ -1,6 +1,53 @@
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
+
17
+ ## v0.3.4 (2026-04-25)
18
+
19
+ ### Bug Fixes
20
+
21
+ - Serialize UUID M2M query contexts
22
+ ([`f53b3ca`](https://github.com/syn54x/ferro-orm/commit/f53b3ca4219d3cd21174d1cb2215bda717c0ac3d))
23
+
24
+ ### Chores
25
+
26
+ - Gitignore .worktrees/ for local worktrees
27
+ ([`142cd3f`](https://github.com/syn54x/ferro-orm/commit/142cd3fc1240e2e0ce5597b170455e4355ac98b9))
28
+
29
+ - Update lock file
30
+ ([`fa1c003`](https://github.com/syn54x/ferro-orm/commit/fa1c003efd3960c4c7a647ddf0f8ba166c731e01))
31
+
32
+ ### Documentation
33
+
34
+ - Add backend guide
35
+ ([`78f1e29`](https://github.com/syn54x/ferro-orm/commit/78f1e295052663416e37ce2bef81be06ec602ba0))
36
+
37
+ ### Refactoring
38
+
39
+ - Replace Any backend with typed engine
40
+ ([`71628a7`](https://github.com/syn54x/ferro-orm/commit/71628a7281e7f6d8ec6a4640eb2512a7589a634d))
41
+
42
+ ### Testing
43
+
44
+ - Add local Postgres test provider
45
+ ([`f8601a5`](https://github.com/syn54x/ferro-orm/commit/f8601a54b414baefd5f1078470c60b3ee85782db))
46
+
47
+ - Harden bridge-boundary coverage
48
+ ([`f1a6064`](https://github.com/syn54x/ferro-orm/commit/f1a60647a799a17ad8adf75c86e9635dd192cc55))
49
+
50
+
4
51
  ## v0.3.3 (2026-04-24)
5
52
 
6
53
  ### Bug Fixes
@@ -294,7 +294,7 @@ dependencies = [
294
294
 
295
295
  [[package]]
296
296
  name = "ferro"
297
- version = "0.3.3"
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.3"
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.3
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)
@@ -0,0 +1,401 @@
1
+ # Backend Guide
2
+
3
+ Ferro supports SQLite and PostgreSQL through one Python API and an explicit Rust backend layer. Application code still calls `connect()`, defines Pydantic-style models, and uses the query builder. The Rust core decides which typed SQLx driver, SeaQuery dialect, transaction connection, and value conversion rules apply for the active database.
4
+
5
+ This guide starts with the user-facing behavior, then explains the implementation details that maintainers need when changing the backend.
6
+
7
+ ## What The Backend Is
8
+
9
+ The backend is the runtime database engine behind Ferro's Python API. It owns:
10
+
11
+ - the active database kind, currently SQLite or PostgreSQL
12
+ - the typed SQLx connection pool
13
+ - SQL execution and row materialization
14
+ - transaction-bound typed connections
15
+ - backend-specific SQL generation choices
16
+ - value binding and hydration rules
17
+
18
+ The backend does not introduce a new public routing API. Ferro still uses one active engine per process. Named databases, replicas, and `using("name")`-style routing are intentionally deferred.
19
+
20
+ ## Supported Backends
21
+
22
+ Ferro currently treats these URL schemes as first-class runtime targets:
23
+
24
+ ```python
25
+ await connect("sqlite:app.db?mode=rwc")
26
+ await connect("sqlite::memory:")
27
+ await connect("postgresql://user:password@localhost:5432/app")
28
+ await connect("postgres://user:password@localhost:5432/app")
29
+ ```
30
+
31
+ Unsupported schemes fail during connection setup:
32
+
33
+ ```python
34
+ await connect("mysql://user:password@localhost/app")
35
+ # raises a connection error: supported schemes are sqlite, postgres, postgresql
36
+ ```
37
+
38
+ The important implementation detail is that URL detection happens once during `connect()`. After that, the active `EngineHandle` carries the backend kind and typed pool, so operations do not need to rediscover the database from global state or URL strings.
39
+
40
+ ## Connection Lifecycle
41
+
42
+ `ferro.connect()` is the public entry point. Internally, the Rust connection layer does four things:
43
+
44
+ 1. Splits Ferro-only query parameters from the database URL.
45
+ 2. Classifies the backend from the URL scheme.
46
+ 3. Creates a typed SQLx pool for that backend.
47
+ 4. Stores an `Arc<EngineHandle>` in global engine state.
48
+
49
+ SQLite uses `SqlitePoolOptions` and PostgreSQL uses `PgPoolOptions`. Both currently use a fixed pool size of 5 connections.
50
+
51
+ ```text
52
+ connect(url, auto_migrate)
53
+ -> split ferro_search_path
54
+ -> BackendKind::from_url(url)
55
+ -> connect typed pool
56
+ -> optionally create tables
57
+ -> store EngineHandle globally
58
+ ```
59
+
60
+ ### PostgreSQL Search Paths
61
+
62
+ Ferro supports a private `ferro_search_path` URL parameter for test isolation:
63
+
64
+ ```python
65
+ await connect(
66
+ "postgresql://localhost/ferro?ferro_search_path=ferro_test_schema",
67
+ auto_migrate=True,
68
+ )
69
+ ```
70
+
71
+ The parameter is removed before SQLx connects. If present, Ferro installs an `after_connect` hook that runs:
72
+
73
+ ```sql
74
+ SET search_path TO ferro_test_schema
75
+ ```
76
+
77
+ Search path names must be ASCII alphanumeric or `_`. This keeps the test helper ergonomic without allowing arbitrary SQL in the connection URL.
78
+
79
+ Use this when several test runs share one PostgreSQL database, but each test should see its own tables. Instead of creating and dropping a whole database for every test, create a temporary schema, connect with that schema as the search path, and let `auto_migrate=True` create the model tables there:
80
+
81
+ ```python
82
+ import uuid
83
+
84
+ import psycopg
85
+ from ferro import connect, reset_engine
86
+
87
+
88
+ async def run_isolated_postgres_test(base_url: str):
89
+ schema_name = f"ferro_{uuid.uuid4().hex[:16]}"
90
+
91
+ with psycopg.connect(base_url, autocommit=True) as conn:
92
+ conn.execute(f'CREATE SCHEMA "{schema_name}"')
93
+
94
+ try:
95
+ await connect(
96
+ f"{base_url}?ferro_search_path={schema_name}",
97
+ auto_migrate=True,
98
+ )
99
+
100
+ # Test code now reads and writes tables in only this schema.
101
+ # A second test can use the same database with a different schema.
102
+ finally:
103
+ reset_engine()
104
+ with psycopg.connect(base_url, autocommit=True) as conn:
105
+ conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE')
106
+ ```
107
+
108
+ This is how Ferro's PostgreSQL matrix keeps tests isolated while still supporting both local `pytest-postgresql` databases and externally managed databases such as Supabase.
109
+
110
+ ## Typed Engine Internals
111
+
112
+ The core backend types live in `src/backend.rs`.
113
+
114
+ ```text
115
+ BackendKind
116
+ Sqlite
117
+ Postgres
118
+
119
+ EngineHandle
120
+ backend: BackendKind
121
+ pool: BackendPool
122
+
123
+ BackendPool
124
+ Sqlite(Arc<SqlitePool>)
125
+ Postgres(Arc<PgPool>)
126
+
127
+ EngineConnection
128
+ Sqlite(PoolConnection<Sqlite>)
129
+ Postgres(PoolConnection<Postgres>)
130
+ ```
131
+
132
+ This replaced the old `sqlx::Any`-centered execution path. Instead of one generic pool that tries to behave like every database, Ferro stores exactly the pool it connected:
133
+
134
+ - SQLite connections are executed through SQLx's SQLite driver.
135
+ - PostgreSQL connections are executed through SQLx's PostgreSQL driver.
136
+ - Transaction connections keep the same typed distinction.
137
+ - Backend dispatch is a small enum match at the boundary where SQL actually runs.
138
+
139
+ This gives Ferro access to backend-specific SQLx behavior without making the Python API backend-specific.
140
+
141
+ ## Query And Mutation Execution
142
+
143
+ Most ORM operations follow the same high-level pipeline:
144
+
145
+ ```text
146
+ Python Query / Model API
147
+ -> JSON query or mutation payload
148
+ -> Rust operation function
149
+ -> SeaQuery statement
150
+ -> backend-specific SQL builder
151
+ -> EngineBindValue list
152
+ -> EngineHandle or EngineConnection execution
153
+ -> EngineRow values
154
+ -> RustValue values
155
+ -> Python model instances
156
+ ```
157
+
158
+ SeaQuery remains the SQL construction layer. The backend controls which SeaQuery builder lowers the statement:
159
+
160
+ - SQLite uses `SqliteQueryBuilder`
161
+ - PostgreSQL uses `PostgresQueryBuilder`
162
+
163
+ Bind values are converted into a backend-neutral Ferro enum before execution:
164
+
165
+ ```text
166
+ EngineBindValue
167
+ Bool
168
+ I64
169
+ F64
170
+ String
171
+ Bytes
172
+ Null
173
+ ```
174
+
175
+ The backend then binds those values to the typed SQLx query. This keeps most operation code independent of SQLx's generic types, while still executing through real SQLite or PostgreSQL drivers.
176
+
177
+ ### Reads
178
+
179
+ Read operations fetch typed rows through the engine, materialize each SQLx row into `EngineRow`, then convert the values into Ferro's internal `RustValue` representation. `RustValue` is the final GIL-free representation before Python objects are created.
180
+
181
+ This split matters because database values are not the same as Python field values. For example:
182
+
183
+ - a PostgreSQL `integer` may decode as `i32`, but Ferro model IDs use Python `int`
184
+ - PostgreSQL UUIDs are selected as text before becoming Python `uuid.UUID`
185
+ - Decimal values are selected as text before becoming Python `Decimal`
186
+ - JSON values are selected as text before becoming Python dicts or lists
187
+
188
+ ### Writes
189
+
190
+ Create, update, relationship, and delete operations build SeaQuery statements and execute them through either:
191
+
192
+ - the active `EngineHandle`, if no transaction is active
193
+ - the transaction's `EngineConnection`, if a transaction ID is present
194
+
195
+ SQLite insert results can report `last_insert_rowid()`. PostgreSQL insert paths rely on explicit `RETURNING` where Ferro needs generated values.
196
+
197
+ ## Schema Metadata And DDL
198
+
199
+ The backend depends on normalized schema metadata from Python. `src/ferro/schema_metadata.py` enriches Pydantic's JSON schema with Ferro-specific keys before Rust consumes it.
200
+
201
+ Important metadata includes:
202
+
203
+ - `primary_key`
204
+ - `autoincrement`
205
+ - `unique`
206
+ - `index`
207
+ - `foreign_key`
208
+ - `ferro_nullable`
209
+ - `format: "decimal"`
210
+ - `enum_type_name`
211
+
212
+ That metadata is shared by:
213
+
214
+ - Rust runtime DDL in `src/schema.rs`
215
+ - Alembic metadata generation in `src/ferro/migrations/alembic.py`
216
+ - query and mutation casting decisions in `src/operations.rs`
217
+ - relationship join-table generation in `src/ferro/relations/__init__.py`
218
+
219
+ The goal is to make the Python schema the contract. Runtime DDL and Alembic may lower it differently, but they should not infer conflicting meanings from the same model.
220
+
221
+ ### Auto-Migration
222
+
223
+ When `auto_migrate=True`, `connect()` creates the typed engine first, then asks Rust to create tables for all registered models.
224
+
225
+ ```python
226
+ await connect("sqlite:dev.db?mode=rwc", auto_migrate=True)
227
+ await connect("postgresql://localhost/ferro", auto_migrate=True)
228
+ ```
229
+
230
+ Runtime DDL uses the active backend:
231
+
232
+ - SQLite gets SQLite-compatible column definitions and index SQL.
233
+ - PostgreSQL gets PostgreSQL-compatible column definitions, native casts, and SQL syntax.
234
+
235
+ ## Type Handling Across SQLite And Postgres
236
+
237
+ SQLite and PostgreSQL do not store or decode every logical type the same way. Ferro's backend layer aims to preserve the Python model contract while allowing backend-specific SQL where needed.
238
+
239
+ ### Integer Primary Keys
240
+
241
+ SQLite autoincrement IDs come from `last_insert_rowid()`. PostgreSQL `SERIAL` / integer values may decode as `i32`; Ferro materializes them as `i64` and then Python `int`.
242
+
243
+ ### UUID
244
+
245
+ UUIDs are a bridge-boundary type. They can appear as:
246
+
247
+ - Python `uuid.UUID`
248
+ - JSON query payload strings
249
+ - SQL bind values
250
+ - PostgreSQL `uuid` columns
251
+ - SQLite text-like columns
252
+
253
+ Ferro serializes UUIDs before JSON query payloads cross the Python/Rust boundary. For PostgreSQL SQL expressions, Ferro adds explicit `uuid` casts where SQLx or PostgreSQL would otherwise see text. Many-to-many add, remove, and clear operations use the same backend-aware cast path for UUID join-table columns.
254
+
255
+ ### Decimal
256
+
257
+ Python `Decimal` fields are marked with `format: "decimal"` in schema metadata. PostgreSQL can use numeric storage, while SQLite remains more flexible. On reads, Ferro selects Decimal values as text when needed so Python can reconstruct an exact `Decimal`.
258
+
259
+ ### JSON Objects And Arrays
260
+
261
+ Python `dict` and `list` fields are represented as JSON object or array schema types. PostgreSQL writes cast JSON strings to `json` so inserts and updates target native JSON columns correctly. Reads select JSON values as text when required, then parse them back into Python values.
262
+
263
+ ### Dates And Datetimes
264
+
265
+ Temporal values cross the bridge as ISO strings and are reconstructed into Python `date` or `datetime` objects. PostgreSQL SQL generation applies explicit casts for temporal comparisons and nulls where needed.
266
+
267
+ ### Enums
268
+
269
+ Enums are represented through schema metadata, including the enum type name. PostgreSQL-specific enum casts are applied where the column uses a native enum type. Portable text-like enum behavior remains available through the same Python model shape.
270
+
271
+ ## Transactions
272
+
273
+ Transactions use the same typed backend model as normal operations.
274
+
275
+ When a root transaction begins:
276
+
277
+ ```text
278
+ active EngineHandle
279
+ -> acquire typed pool connection
280
+ -> BEGIN
281
+ -> TransactionHandle::root(EngineConnection)
282
+ ```
283
+
284
+ Nested transactions reuse the same typed connection and create savepoints:
285
+
286
+ ```text
287
+ parent TransactionConnection
288
+ -> SAVEPOINT sp_<tx_id>
289
+ -> TransactionHandle::nested(parent_conn, savepoint_name)
290
+ ```
291
+
292
+ The transaction registry stores a transaction ID mapped to:
293
+
294
+ - a shared `Arc<Mutex<EngineConnection>>`
295
+ - an optional savepoint name
296
+
297
+ This means all operations inside a transaction execute on the same typed database connection. Commit and rollback dispatch through the `EngineConnection` enum, not through a generic SQLx connection.
298
+
299
+ ## Testing The Backend Matrix
300
+
301
+ Backend correctness is tested with the same public API users call. Tests that should run on both databases use the backend matrix fixtures:
302
+
303
+ ```python
304
+ @pytest.mark.backend_matrix
305
+ async def test_create_and_fetch(db_url):
306
+ await connect(db_url, auto_migrate=True)
307
+ ...
308
+ ```
309
+
310
+ Run the SQLite default suite:
311
+
312
+ ```bash
313
+ uv run pytest -q
314
+ ```
315
+
316
+ Run the SQLite/PostgreSQL matrix:
317
+
318
+ ```bash
319
+ uv run pytest -m "backend_matrix or postgres_only" --db-backends=sqlite,postgres -q
320
+ ```
321
+
322
+ Run only the PostgreSQL side:
323
+
324
+ ```bash
325
+ uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q
326
+ ```
327
+
328
+ ### Local PostgreSQL Provider
329
+
330
+ The test harness supports local ephemeral PostgreSQL through `pytest-postgresql`.
331
+
332
+ Install PostgreSQL server binaries, then force the local provider:
333
+
334
+ ```bash
335
+ brew install postgresql@16
336
+ FERRO_POSTGRES_PROVIDER=local uv run pytest -m "backend_matrix or postgres_only" --db-backends=postgres -q
337
+ ```
338
+
339
+ If `FERRO_POSTGRES_PROVIDER=local` is not set, tests prefer an external URL:
340
+
341
+ 1. `FERRO_POSTGRES_URL`
342
+ 2. legacy `FERRO_SUPABASE_URL`
343
+ 3. local `pytest-postgresql` fallback
344
+
345
+ Each PostgreSQL test gets an isolated schema through `ferro_search_path`, so externally managed databases can still run isolated test cases.
346
+
347
+ ## How To Extend This Later
348
+
349
+ The current backend design makes a future backend, such as MySQL, more approachable but not automatic. A new backend would need:
350
+
351
+ 1. A new `BackendKind` variant.
352
+ 2. A typed SQLx pool and connection variant.
353
+ 3. URL classification.
354
+ 4. SeaQuery builder dispatch.
355
+ 5. DDL type mapping in `src/schema.rs`.
356
+ 6. bind and row materialization support in `src/backend.rs`.
357
+ 7. schema-value casting rules in `src/operations.rs`.
358
+ 8. backend-matrix test coverage.
359
+ 9. docs that clearly state support level and known differences.
360
+
361
+ Avoid adding a backend by sprinkling one-off branches through query, schema, and operation code. The maintainable path is to make the backend identity explicit first, then lower shared ORM semantics through that backend.
362
+
363
+ ## Troubleshooting And Gotchas
364
+
365
+ ### `Engine not initialized`
366
+
367
+ You called a model or query method before `await connect(...)`. Importing models registers schema, but it does not connect to the database.
368
+
369
+ ### Unsupported URL scheme
370
+
371
+ Only `sqlite:`, `postgres://`, and `postgresql://` are supported. MySQL is planned for later, not accepted by this backend.
372
+
373
+ ### PostgreSQL tests use the wrong database
374
+
375
+ If `.env` contains `FERRO_POSTGRES_URL` or `FERRO_SUPABASE_URL`, the test harness will use it by default. Set `FERRO_POSTGRES_PROVIDER=local` to force `pytest-postgresql`.
376
+
377
+ ### Local PostgreSQL tests skip or fail to start
378
+
379
+ `pytest-postgresql` needs server binaries such as `pg_ctl`, `postgres`, and `initdb` on `PATH`. On macOS with Homebrew, installing `postgresql@16` usually provides them.
380
+
381
+ ### UUID or Decimal values fail only on PostgreSQL
382
+
383
+ Check whether the value crosses the Python/Rust boundary as JSON or as a direct PyO3 argument. Query payloads must serialize non-JSON-native Python values before `json.dumps`; direct relationship operations must preserve typed values long enough for backend-aware SQL casts.
384
+
385
+ ### Runtime DDL and Alembic disagree
386
+
387
+ Start with schema metadata. If `ferro_nullable`, `format`, `primary_key`, or relationship metadata is missing from the normalized Python schema, Rust DDL and Alembic may lower the same model differently. Fix the metadata source before adding more backend-specific lowering rules.
388
+
389
+ ## Mental Model
390
+
391
+ The shortest way to understand the backend is:
392
+
393
+ ```text
394
+ Python owns the model contract.
395
+ Rust owns execution.
396
+ SeaQuery owns SQL shape.
397
+ SQLx owns typed database I/O.
398
+ BackendKind decides which database-specific path is legal.
399
+ ```
400
+
401
+ When changing backend behavior, preserve that separation. Put shared ORM meaning in schema/query metadata, then make the backend choose the correct SQLite or PostgreSQL lowering at the execution boundary.
@@ -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