ferro-orm 0.3.4__tar.gz → 0.5.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 (149) hide show
  1. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/agent-native.json +87 -0
  2. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/api-contract.json +40 -0
  3. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/correctness.json +15 -0
  4. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/data-migrations.json +72 -0
  5. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/kieran-python.json +44 -0
  6. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/maintainability.json +79 -0
  7. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/project-standards.json +45 -0
  8. ferro_orm-0.5.0/.context/compound-engineering/ce-code-review/20260428-081705-bdeab5a2/testing.json +108 -0
  9. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/workflows/ci.yml +13 -2
  10. ferro_orm-0.5.0/AGENTS.md +134 -0
  11. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/CHANGELOG.md +159 -0
  12. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/Cargo.lock +15 -13
  13. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/Cargo.toml +13 -3
  14. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/PKG-INFO +1 -1
  15. ferro_orm-0.5.0/docs/api/raw-sql.md +117 -0
  16. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/relationships.md +10 -3
  17. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/coming-soon.md +5 -5
  18. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/concepts/architecture.md +1 -1
  19. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/getting-started/tutorial.md +8 -8
  20. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/migrations.md +3 -1
  21. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/models-and-fields.md +42 -1
  22. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/relationships.md +75 -23
  23. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/transactions.md +23 -0
  24. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/howto/testing.md +1 -1
  25. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/index.md +2 -2
  26. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/migration-sqlalchemy.md +2 -2
  27. ferro_orm-0.5.0/docs/solutions/README.md +60 -0
  28. ferro_orm-0.5.0/docs/solutions/issues/python-3.14-deferred-annotation-typeerror-swallow.md +69 -0
  29. ferro_orm-0.5.0/docs/solutions/issues/sa-pk-column-nullable-divergence.md +68 -0
  30. ferro_orm-0.5.0/docs/solutions/issues/sa-vs-rust-unique-constraint-shape.md +69 -0
  31. ferro_orm-0.5.0/docs/solutions/patterns/cross-emitter-ddl-parity.md +90 -0
  32. ferro_orm-0.5.0/docs/solutions/patterns/foreign-key-index.md +59 -0
  33. ferro_orm-0.5.0/docs/solutions/patterns/index-unique-redundancy.md +55 -0
  34. ferro_orm-0.5.0/docs/solutions/patterns/shadow-fk-columns.md +63 -0
  35. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/mkdocs.yml +4 -0
  36. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/pyproject.toml +1 -1
  37. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/scripts/demo_queries.py +5 -4
  38. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/__init__.py +10 -4
  39. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/_core.pyi +9 -0
  40. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/base.py +44 -9
  41. ferro_orm-0.5.0/src/ferro/composite_indexes.py +133 -0
  42. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/fields.py +85 -3
  43. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/metaclass.py +147 -64
  44. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/migrations/alembic.py +31 -2
  45. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/models.py +32 -6
  46. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/query/__init__.py +2 -2
  47. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/query/builder.py +59 -5
  48. ferro_orm-0.5.0/src/ferro/raw.py +137 -0
  49. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/relations/__init__.py +14 -6
  50. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/relations/descriptors.py +10 -8
  51. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/schema_metadata.py +9 -2
  52. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/lib.rs +3 -0
  53. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/operations.rs +303 -0
  54. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/schema.rs +191 -0
  55. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_alembic_autogenerate.py +146 -1
  56. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_alembic_bridge.py +10 -9
  57. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_alembic_nullability.py +22 -10
  58. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_auto_migrate.py +22 -11
  59. ferro_orm-0.5.0/tests/test_composite_index.py +795 -0
  60. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_composite_unique.py +225 -11
  61. ferro_orm-0.5.0/tests/test_cross_emitter_parity.py +209 -0
  62. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_documentation_features.py +7 -6
  63. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_metaclass_internals.py +69 -80
  64. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_one_to_one.py +6 -4
  65. ferro_orm-0.5.0/tests/test_raw_sql.py +493 -0
  66. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_relationship_engine.py +137 -9
  67. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_schema_constraints.py +95 -2
  68. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_shadow_fk_types.py +14 -12
  69. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/uv.lock +1 -1
  70. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  71. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  72. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/PERMISSIONS.md +0 -0
  73. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/PYPI_CHECKLIST.md +0 -0
  74. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/PYPI_SETUP.md +0 -0
  75. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/generated/wheels.generated.yml +0 -0
  76. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/pull_request_template.md +0 -0
  77. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/workflows/packaging-smoke.yml +0 -0
  78. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/workflows/publish-docs.yml +0 -0
  79. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/workflows/publish.yml +0 -0
  80. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.github/workflows/release.yml +0 -0
  81. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.gitignore +0 -0
  82. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.pre-commit-config.yaml +0 -0
  83. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/.python-version +0 -0
  84. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/CONTRIBUTING.md +0 -0
  85. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/LICENSE +0 -0
  86. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/README.md +0 -0
  87. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/TEST_RESULTS.md +0 -0
  88. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/fields.md +0 -0
  89. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/model.md +0 -0
  90. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/query.md +0 -0
  91. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/transactions.md +0 -0
  92. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/api/utilities.md +0 -0
  93. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/changelog.md +0 -0
  94. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/concepts/identity-map.md +0 -0
  95. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/concepts/performance.md +0 -0
  96. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/concepts/type-safety.md +0 -0
  97. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/contributing.md +0 -0
  98. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/faq.md +0 -0
  99. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/getting-started/installation.md +0 -0
  100. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/getting-started/next-steps.md +0 -0
  101. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/backend.md +0 -0
  102. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/database.md +0 -0
  103. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/mutations.md +0 -0
  104. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/guide/queries.md +0 -0
  105. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/howto/multiple-databases.md +0 -0
  106. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/howto/pagination.md +0 -0
  107. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/howto/soft-deletes.md +0 -0
  108. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/howto/timestamps.md +0 -0
  109. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/plans/2026-04-24-001-refactor-multi-db-backend-architecture-plan.md +0 -0
  110. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/stylesheets/extra.css +0 -0
  111. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/docs/why-ferro.md +0 -0
  112. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/justfile +0 -0
  113. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/backend.rs +0 -0
  114. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/connection.rs +0 -0
  115. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/_annotation_utils.py +0 -0
  116. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/_shadow_fk_types.py +0 -0
  117. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/composite_uniques.py +0 -0
  118. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/migrations/__init__.py +0 -0
  119. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/py.typed +0 -0
  120. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/query/nodes.py +0 -0
  121. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/ferro/state.py +0 -0
  122. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/query.rs +0 -0
  123. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/src/state.rs +0 -0
  124. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/__init__.py +0 -0
  125. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/conftest.py +0 -0
  126. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/db_backends.py +0 -0
  127. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_aggregation.py +0 -0
  128. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_alembic_type_mapping.py +0 -0
  129. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_bulk_update.py +0 -0
  130. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_connection.py +0 -0
  131. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_constraints.py +0 -0
  132. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_crud.py +0 -0
  133. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_db_backends.py +0 -0
  134. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_deletion.py +0 -0
  135. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_docs_examples.py +0 -0
  136. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_field_wrapper.py +0 -0
  137. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_helpers.py +0 -0
  138. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_hydration.py +0 -0
  139. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_metadata.py +0 -0
  140. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_models.py +0 -0
  141. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_query_builder.py +0 -0
  142. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_refresh.py +0 -0
  143. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_schema.py +0 -0
  144. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_schema_enum_annotations.py +0 -0
  145. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_static_contracts.py +0 -0
  146. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_string_search.py +0 -0
  147. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_structural_types.py +0 -0
  148. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_temporal_types.py +0 -0
  149. {ferro_orm-0.3.4 → ferro_orm-0.5.0}/tests/test_transactions.py +0 -0
@@ -0,0 +1,87 @@
1
+ {
2
+ "reviewer": "agent-native",
3
+ "summary": "ferro is a programmatic Python ORM; the library itself IS the agent-native surface (no UI, no MCP/agent-tool layer in this PR). The ForeignKey(index=True) addition is well-documented for AI agents generating ferro models: the new `index` parameter is exposed via __init__ kwarg, listed in the class Attributes block, covered by Args with a runnable doctest example, propagated into the JSON schema for downstream introspection (Alembic + Rust runtime both consume it), and noted in CHANGELOG + docs/guide/relationships.md. Action parity, context parity, and shared-workspace checks are not applicable to a library API of this shape. Two concrete agent-discoverability nits in the JSON schema serialization layer, plus a residual operational risk (Alembic vs Rust runtime index name divergence) worth surfacing.",
4
+ "capability_map": [
5
+ {
6
+ "capability": "Declare non-unique index on FK shadow column",
7
+ "human_path": "ForeignKey(index=True) kwarg, documented in docstring + relationships.md",
8
+ "agent_path": "Same kwarg; signature, defaults, and warning behavior are introspectable via inspect.signature() and the class docstring",
9
+ "in_prompt_or_docstring": true,
10
+ "priority": "must_have",
11
+ "status": "parity"
12
+ },
13
+ {
14
+ "capability": "Detect that a FK column is indexed via schema metadata",
15
+ "human_path": "model_json_schema() returns prop['index']=True for indexed FK columns",
16
+ "agent_path": "Asymmetric: non-FK FerroField columns always carry an explicit `index` key (True or False); FK columns only carry `index` when True. Also, the `foreign_key` sub-dict embeds `unique` but not `index`",
17
+ "in_prompt_or_docstring": false,
18
+ "priority": "should_have",
19
+ "status": "minor_gap"
20
+ },
21
+ {
22
+ "capability": "Discover effective index name across migration backends",
23
+ "human_path": "docs/guide/relationships.md explicitly states Alembic emits ix_<t>_<c> while Rust runtime emits idx_<t>_<c>",
24
+ "agent_path": "Docstring on ForeignKey.index does not surface this divergence; an agent that only reads the docstring would not learn the dual-naming convention",
25
+ "in_prompt_or_docstring": false,
26
+ "priority": "low",
27
+ "status": "observation"
28
+ }
29
+ ],
30
+ "findings": [
31
+ {
32
+ "title": "FK index flag asymmetric in JSON schema vs FerroField",
33
+ "severity": "P3",
34
+ "file": "src/ferro/schema_metadata.py",
35
+ "line": 132,
36
+ "why_it_matters": "Downstream agents and tools that inspect ferro's JSON schema to reason about FK indexes have to special-case absence-as-false for FK columns while non-FK columns always carry an explicit `index` boolean. An agent computing 'which FK columns lack indexes?' from the schema cannot rely on key presence -- it must default-coerce -- and the inconsistency invites bugs in autogen tooling that round-trips schemas. Minor agent-introspection contract drift.",
37
+ "autofix_class": "safe_auto",
38
+ "owner": "review-fixer",
39
+ "requires_verification": true,
40
+ "suggested_fix": "Replace `if metadata.index: prop['index'] = True` with `prop['index'] = metadata.index` so FK columns always carry an explicit index boolean, matching the FerroField branch on line 107.",
41
+ "confidence": 75,
42
+ "evidence": [
43
+ "src/ferro/schema_metadata.py:107 — `prop['index'] = metadata.index` (unconditional for FerroField)",
44
+ "src/ferro/schema_metadata.py:132-133 — `if metadata.index: prop['index'] = True` (conditional for ForeignKey, only set when truthy)"
45
+ ],
46
+ "pre_existing": false
47
+ },
48
+ {
49
+ "title": "foreign_key descriptor dict omits index flag",
50
+ "severity": "P3",
51
+ "file": "src/ferro/schema_metadata.py",
52
+ "line": 125,
53
+ "why_it_matters": "The `prop['foreign_key']` sub-dict is the natural place for an agent to look up everything FK-related on a column (to_table, on_delete, unique are all there). The new `index` flag is set on the peer property instead, so any agent or external tool that reads `prop['foreign_key']` to enumerate FK-specific config will silently miss the index flag and have to know to also peek at the sibling. This makes the FK descriptor a lossy view of FK metadata.",
54
+ "autofix_class": "safe_auto",
55
+ "owner": "review-fixer",
56
+ "requires_verification": true,
57
+ "suggested_fix": "Add `\"index\": metadata.index` to the `prop['foreign_key']` dict alongside `unique`, in addition to (or instead of) the peer `prop['index']` write. Keeps FK metadata co-located for agent introspection.",
58
+ "confidence": 75,
59
+ "evidence": [
60
+ "src/ferro/schema_metadata.py:125-129 — foreign_key dict includes to_table/on_delete/unique but not index",
61
+ "src/ferro/schema_metadata.py:132-133 — index is set as a peer property only"
62
+ ],
63
+ "pre_existing": false
64
+ }
65
+ ],
66
+ "residual_risks": [
67
+ "Alembic vs Rust runtime DDL emit different index names for the same FK column (ix_<table>_<col> vs idx_<table>_<col>). An agent that runs `connect(..., auto_migrate=True)` once and later switches to Alembic-driven migrations could end up with two real indexes on the same column (Alembic autogen sees no ix_* and creates one). Documented in docs/guide/relationships.md but not in the ForeignKey.index docstring; agents generating ferro models from the docstring alone would not learn this.",
68
+ "ForeignKey(unique=True, index=True) silently mutates self.index to False after issuing a UserWarning. An agent that constructs the ForeignKey programmatically and later introspects `fk.index` sees the effective post-init state, not the originally-passed value. The warning makes the override visible in interactive runs but not in non-interactive agent flows that may filter or suppress warnings.",
69
+ "FerroField.unique does not state that it implies an index, while ForeignKey.unique now does. Pre-existing inconsistency in agent-facing documentation; out of scope for this PR but worth surfacing for a future docstring sweep."
70
+ ],
71
+ "testing_gaps": [
72
+ "No test asserts the JSON schema shape for FK index — i.e., that prop['index'] (or prop['foreign_key']['index']) is present on the schema dict produced by build_model_schema. Current tests only assert the SQLAlchemy metadata side (project_table.c.org_id.index) and the Rust DDL output. If the asymmetry above is fixed, a schema-shape regression test would prevent it recurring.",
73
+ "No test exercises ForeignKey(unique=True, index=True) at the schema-metadata layer -- only at the Python-class layer. A test asserting that the resulting prop carries unique=True and index=False (effective config) would lock in the documented warning behavior end-to-end."
74
+ ],
75
+ "score": {
76
+ "high_priority_capabilities_with_agent_parity": "1/1",
77
+ "verdict": "PASS"
78
+ },
79
+ "whats_working_well": [
80
+ "ForeignKey.index is fully discoverable via standard Python introspection: kwarg in __init__ signature, listed in class-level Attributes, covered in Args with a runnable doctest, and reflected on the instance as self.index.",
81
+ "The docstring explicitly documents the unique+index redundancy and the resulting UserWarning, so an agent generating models will not be surprised by silent normalization.",
82
+ "stacklevel=2 on the warnings.warn call points at the user's call site, not ferro internals -- correct for agent log triage.",
83
+ "Schema metadata propagates the flag to both Alembic and the Rust DDL path, keeping the two code-generation backends in sync from a single declarative source.",
84
+ "CHANGELOG entry references the issue (#32) and explicitly calls out the warning behavior, giving agents a single canonical pointer to the new flag's contract.",
85
+ "src/ferro/_core.pyi correctly does not need updating -- ForeignKey is a pure-Python class; type checkers pick up the signature directly from base.py."
86
+ ]
87
+ }
@@ -0,0 +1,40 @@
1
+ {
2
+ "reviewer": "api-contract",
3
+ "findings": [
4
+ {
5
+ "id": "api-contract-1",
6
+ "title": "New `index` parameter inserted positionally before `nullable` silently rebinds 4th positional arg",
7
+ "severity": "high",
8
+ "confidence": 75,
9
+ "file": "src/ferro/base.py",
10
+ "lines": "114-120",
11
+ "category": "breaking-change",
12
+ "description": "`ForeignKey.__init__` is a public, exported API (re-exported from `ferro/__init__.py`). The new `index: bool = False` is inserted between `unique` and `nullable` as a regular positional-or-keyword parameter; none of the existing parameters are keyword-only (no `*` separator). Any external caller passing arguments positionally past `unique` silently rebinds. Concrete breakage examples for callers upgrading without source changes:\n\n- `ForeignKey(\"posts\", \"CASCADE\", False, True)` previously meant `nullable=True`; now means `index=True` and `nullable` falls back to `'infer'`. The shadow `*_id` column's NOT NULL behavior can flip from forced-NULL to inferred-from-annotation, and an unintended index is emitted.\n- `ForeignKey(\"posts\", \"CASCADE\", False, \"infer\")` previously a no-op nullable hint; now sets `self.index = 'infer'` (a non-bool truthy string). `metadata.index` is then truthy in `schema_metadata.py`, producing `prop['index'] = True` and an unintended `CREATE INDEX`. Type contract for `index: bool` is also violated without validation.\n- `ForeignKey(\"posts\", \"CASCADE\", True, True)` previously meant `unique=True, nullable=True`; now means `unique=True, index=True` -> the new redundancy warning fires (`UserWarning`) and `nullable` silently changes to `'infer'`.\n\nAll in-tree call sites use keyword arguments (verified across `src/`, `tests/`, `docs/`, `scripts/`), so the project itself is unaffected — but `ForeignKey` is part of the documented public API and the contract on positional ordering changed.",
13
+ "evidence": "diff src/ferro/base.py:\n def __init__(\n self,\n related_name: str,\n on_delete: str = \"CASCADE\",\n unique: bool = False,\n index: bool = False, # <-- inserted between unique and nullable\n nullable: FerroNullable = \"infer\",\n ):\nNo `*,` separator, so positional binding order changes. `self.index` is also assigned the raw value with no type validation (contrast `_validate_nullable_option`).",
14
+ "suggested_fix": "Make `index` (and ideally `unique` and `nullable`, since they are clearly intended as kwargs) keyword-only by adding `*,` before `unique` (least disruptive) or at minimum before `index`:\n\n```python\ndef __init__(\n self,\n related_name: str,\n on_delete: str = \"CASCADE\",\n *,\n unique: bool = False,\n index: bool = False,\n nullable: FerroNullable = \"infer\",\n):\n```\n\nThis preserves the only realistic positional call shape (`ForeignKey(\"posts\", \"CASCADE\")`) and makes the contract robust to future insertions. It also matches how every example in the codebase already calls it. If a softer landing is preferred for v0.x, at minimum insert `*,` immediately before `index` so additions never reorder existing positional semantics again, and add an `isinstance(index, bool)` guard to reject the non-bool truthy case."
15
+ },
16
+ {
17
+ "id": "api-contract-2",
18
+ "title": "Serialized model schema gains a new optional `index` key on FK columns — additive but worth documenting",
19
+ "severity": "low",
20
+ "confidence": 50,
21
+ "file": "src/ferro/schema_metadata.py",
22
+ "lines": "131-134",
23
+ "category": "schema-contract",
24
+ "description": "`build_model_schema` now writes `prop['index'] = True` on FK column entries when `metadata.index` is truthy, conditionally (matching the existing pattern for `prop['unique']`). This JSON schema is consumed by the Rust core (`register_model_schema`) and is part of the cross-language contract. The change is additive: the key is absent when `index=False` and consumers using `dict.get('index', False)` are safe. Two consumer expectations to confirm before declaring this fully safe:\n\n1. The Rust `schema.rs` parsing path tolerates an unknown-to-old-binaries `index` key on FK column dicts — the in-PR Rust test confirms it does, so OK.\n2. Any out-of-tree tooling that snapshots/diffs the serialized schema (autogen baselines, golden files) will see a new key on FK columns whenever a user opts into `index=True`. Not breaking, but PRs touching schema serialization should call this out in CHANGELOG so external integrators know to refresh fixtures. CHANGELOG already mentions the feature; could note the schema-key addition explicitly.\n\nThe `foreign_key` sub-dict shape is unchanged (`to_table`, `on_delete`, `unique`), which is the right call — keeping `index` as a column-level flag rather than nesting it under `foreign_key` matches how `FerroField(index=True)` already serializes.",
25
+ "evidence": "src/ferro/schema_metadata.py:\n if metadata.unique:\n prop[\"unique\"] = True\n if metadata.index:\n prop[\"index\"] = True\nRust test in src/schema.rs asserts `idx_<table>_<col>` is emitted when `index: true` is set on an FK column — Rust side handles it.",
26
+ "suggested_fix": "No code change required. Optionally add a one-line note in the Unreleased CHANGELOG entry that the JSON schema now carries an optional `index` boolean on FK column properties, so downstream tooling that pins or snapshots the schema is aware."
27
+ }
28
+ ],
29
+ "residual_risks": [
30
+ "`_core.pyi` does not declare `ForeignKey` or `build_model_schema` (only Rust-extension async functions), so no .pyi update is required. Confirmed via Grep — no false-positive 'missing stub' risk.",
31
+ "Public `ForeignKey` instances now carry a new `.index` attribute. This is purely additive for introspection consumers (`hasattr(fk, 'index')` flips from False to True). Anyone iterating `vars(fk)` for serialization will see the new key. Not a contract break, but worth noting if any external Alembic env.py or migration helper enumerates FK attributes.",
32
+ "The `unique=True, index=True` UserWarning + silent drop is a defensible behavior choice, but it is a *new* runtime warning surface. Strict warning filters (`warnings.simplefilter('error')`) in user test suites would now raise on this combination. Since `index` is a new parameter, no pre-existing code can hit this path on upgrade — risk is forward-only and only for users who explicitly opt into both flags.",
33
+ "Type contract on the `index` parameter is unenforced — `self.index` accepts any truthy value (including the `'infer'` literal that's valid for `nullable`). Combined with the positional-rebinding risk in finding api-contract-1, a misplaced positional argument can poison the schema with a non-bool. An `isinstance(index, bool)` validator (or relying on the keyword-only fix) would close this."
34
+ ],
35
+ "testing_gaps": [
36
+ "No test exercises the positional-argument call shape (e.g., `ForeignKey('posts', 'CASCADE', False, True)`) to lock in the *intended* binding for `index` vs `nullable`. Adding either (a) a test that asserts the keyword-only nature of `index` (e.g., `with pytest.raises(TypeError): ForeignKey('x', 'CASCADE', False, True, 'infer')` after the fix) or (b) a regression test pinning the current positional contract would prevent silent reordering in future PRs.",
37
+ "No test asserts that passing a non-bool to `index` is rejected or coerced. With the current code, `ForeignKey('x', index='infer')` silently assigns the string and produces `prop['index'] = True` downstream. A small input-validation test (alongside the existing `_validate_nullable_option` pattern) would harden the contract.",
38
+ "No test covers the JSON schema shape for the FK-with-index case at the Python boundary (the existing tests assert SQLAlchemy `Column.index` is True and the Rust DDL contains `idx_*`, but not that `build_model_schema(...)['properties']['org_id']` contains exactly `{'foreign_key': {...}, 'index': True, ...}` and crucially does NOT contain `'index': False` when off). This locks the wire-format contract to Rust."
39
+ ]
40
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "reviewer": "correctness",
3
+ "summary": "Traced every input/state path for the ForeignKey(index=True) feature: constructor → schema_metadata.build_model_schema → Alembic _build_sa_table and Rust schema.rs emission. No correctness defects identified. The default (index=False) path is bit-for-bit identical to the prior behavior because `if metadata.index:` guards the new prop assignment, and both downstream emitters already read `col_info.get('index', False)` / `column_bool_metadata(..., 'index').unwrap_or(false)`. The unique+index overlap is collapsed in __init__ before the schema layer ever sees it, so neither Alembic nor Rust can double-emit a unique constraint and a redundant index. Naming convention parity (Alembic `ix_project_org_id` from SA's default; Rust `idx_project_org_id` from format!(\"idx_{}_{}\")) is asserted by both Python and Rust tests. IF NOT EXISTS in the Rust path keeps re-runs idempotent.",
4
+ "findings": [],
5
+ "residual_risks": [
6
+ "If a user passes ForeignKey(unique=True, index=True, nullable='bogus'), the redundancy UserWarning is emitted *before* _validate_nullable_option raises, leaving a leaked warning for an object that never finished construction. Pure UX nit — not a correctness bug — and below the flagging threshold. Constructor-order swap (validate first, then warn-and-drop) would eliminate it.",
7
+ "Rust DDL parity test runs only on SQLite (sqlite_only marker). Postgres parity is covered by the new schema.rs unit test, not an end-to-end migration test, so a future regression in the auto_migrate Postgres path could land without a failing pytest. Existing residual risk pattern, not introduced by this PR.",
8
+ "Adding index=True to an existing FK on a deployed model will cause Alembic autogen to produce an op.create_index migration. That is the intended feature, but operators applying it to a large hot table should be aware (CREATE INDEX is non-concurrent by default in Postgres). Documented behavior, not a code defect."
9
+ ],
10
+ "testing_gaps": [
11
+ "No test covers an explicit ForeignKey(unique=False, index=False) round-trip asserting that the JSON schema for the shadow column does *not* contain an 'index' key (only that SA Column.index is False). The current `if metadata.index:` guard preserves this, but a schema-level assertion would lock it in against future refactors that set prop['index'] = metadata.index unconditionally (which would change the JSON shape consumed by the Rust side and any downstream consumers).",
12
+ "No test exercises the Rust DDL path on Postgres for ForeignKey(index=True) end-to-end (only the SQLite runtime parity test and the in-process Rust unit test). A connected Postgres integration test asserting pg_indexes contains idx_project_org_id would close the loop.",
13
+ "No test for the case where ForeignKey(index=True) is combined with on_delete='SET NULL' AND the relation annotation is non-Optional (e.g., `Annotated[Org, ForeignKey(on_delete='SET NULL', index=True)]`). The existing nullable-validation path raises ValueError in __init__, but the index kwarg is set on self before validation runs; if construction fails the instance is discarded so this is benign, but a focused negative test would document the precedence."
14
+ ]
15
+ }
@@ -0,0 +1,72 @@
1
+ {
2
+ "reviewer": "data-migrations",
3
+ "findings": [
4
+ {
5
+ "id": "fk-index-docs-missing-concurrently-warning",
6
+ "severity": "medium",
7
+ "confidence": 70,
8
+ "title": "Docs steer users toward index=True on hot tenant tables but never warn that Alembic autogen emits a blocking CREATE INDEX",
9
+ "where": "docs/guide/relationships.md \"Indexing FK columns\" section (added in this PR)",
10
+ "summary": "The new docs explicitly position ForeignKey(index=True) for the exact workload where naive autogen output is most dangerous: \"tenant-scoped tables that filter by a FK on every read\". With index=True set on a model, Alembic autogen against a populated production DB will emit op.create_index(...) which compiles to plain CREATE INDEX on Postgres. CREATE INDEX (without CONCURRENTLY) takes ACCESS EXCLUSIVE on the table for the duration of the build, which on a hot tenant table means writes are blocked for seconds-to-minutes. There is no mention of postgresql_concurrently=True, no mention of editing the autogen output before applying, and no mention that the wrapping transaction must be disabled when using CONCURRENTLY (Alembic op.execute('COMMIT') / transaction_per_migration / autocommit_block).",
11
+ "evidence": [
12
+ "docs/guide/relationships.md adds: \"Postgres does not auto-index foreign-key columns, so tenant-scoped tables that filter by a FK on every read should declare the index on the model\" and shows a Project(org=ForeignKey(..., index=True)) example.",
13
+ "src/ferro/migrations/alembic.py:182-187 passes index=col_info.get('index', False) into sa.Column kwargs. Autogen will diff against the live DB and emit op.create_index(...). There is no override of the rendered op and no naming-convention-driven hint about CONCURRENTLY.",
14
+ "CHANGELOG.md entry markets the feature without operational caveats."
15
+ ],
16
+ "impact": "First user to add index=True to a populated production tenant table (the documented use case) and run alembic upgrade head will block writes on that table for the duration of the index build. Recoverable but classic Postgres outage pattern.",
17
+ "remediation": "Add an admonition to docs/guide/relationships.md (Indexing FK columns) that says, on Postgres, populated tables should hand-edit the generated migration to use op.create_index(..., postgresql_concurrently=True) inside an autocommit_block (and remove the index= kwarg from op.create_table flows / use op.create_index separately), and that this cannot run inside a wrapping transaction. Optionally add the same caveat to the CHANGELOG entry."
18
+ }
19
+ ],
20
+ "residual_risks": [
21
+ {
22
+ "id": "fk-index-name-divergence-double-create",
23
+ "severity": "medium",
24
+ "confidence": 65,
25
+ "title": "Alembic 'ix_<table>_<col>_id' vs Rust runtime 'idx_<table>_<col>_id' will create two indexes on the same column for users who run both auto_migrate and Alembic",
26
+ "where": "src/ferro/migrations/alembic.py:44 (sa.MetaData() created with no naming_convention) and the Rust idx_* prefix exercised in the new tests",
27
+ "summary": "ferro.migrations builds sa.MetaData() with no naming_convention, so sa.Column('org_id', ..., index=True) inherits SQLAlchemy's default 'ix_<table>_<col>' index name (i.e. ix_project_org_id). The Rust runtime DDL emits idx_project_org_id (verified by the new tests/test_schema_constraints.py::test_foreign_key_index_runtime_ddl_parity and the new src/schema.rs unit test). For a user who first calls connect(auto_migrate=True) (creating idx_project_org_id) and later runs alembic revision --autogenerate, Alembic's reflection sees an index named idx_project_org_id but the model-side metadata wants ix_project_org_id; autogen will emit a CreateIndex(ix_project_org_id) op, leaving the database with two physically-identical indexes on the same column under different names. The reverse order (Alembic first, auto_migrate second) is also problematic: the Rust path will attempt to create idx_* on top of the existing ix_*. As the PR description acknowledges, this is the same divergence already present for FerroField(index=True); this PR widens the surface from 'user-declared singleton indexes' to 'every FK column the user opts into', which is a much more common case in real apps.",
28
+ "evidence": [
29
+ "src/ferro/migrations/alembic.py:44 — metadata = sa.MetaData() (no naming_convention dict).",
30
+ "src/ferro/migrations/alembic.py:186 — kwargs['index'] = col_info.get('index', False) → SA emits ix_<table>_<col>.",
31
+ "tests/test_schema_constraints.py::test_foreign_key_index_runtime_ddl_parity asserts 'idx_project_org_id' in sqlite_master.",
32
+ "src/schema.rs new unit test asserts joined.contains(\"IDX_PROJECT_ORG_ID\")."
33
+ ],
34
+ "impact": "Silent duplicate indexes on tenant FK columns: 2x write amplification on every INSERT/UPDATE of the table, 2x storage. Does not cause data loss or query incorrectness; degrades the exact workload (hot tenant tables) the feature is sold for.",
35
+ "remediation": "Out of scope for this PR if the team has already accepted the existing FerroField(index=True) divergence, but worth tracking as a follow-up: align both paths on a single naming scheme by setting a naming_convention={'ix': 'idx_%(table_name)s_%(column_0_label)s'} on sa.MetaData() in src/ferro/migrations/alembic.py so SA emits idx_* names matching the Rust runtime."
36
+ },
37
+ {
38
+ "id": "fk-index-redundant-with-composite-leftmost-prefix",
39
+ "severity": "low",
40
+ "confidence": 55,
41
+ "title": "ForeignKey(index=True) does not detect / dedupe against a composite index whose leftmost column is the same FK shadow column",
42
+ "where": "src/ferro/schema_metadata.py build_model_schema and src/ferro/composite_indexes.py warn_and_drop_overlap_with_uniques",
43
+ "summary": "warn_and_drop_overlap_with_uniques only deduplicates ferro_composite_indexes against ferro_composite_uniques. There is no equivalent check for the new per-column FK index against ferro_composite_indexes that already cover the same column as their leftmost prefix. A user who declares __ferro_composite_indexes__ = ((\"org_id\", \"created_at\"),) and also adds ForeignKey(\"orgs\", index=True) will get two indexes — idx_<t>_org_id_created_at AND ix_<t>_org_id (Alembic) / idx_<t>_org_id (Rust) — where the first already serves all WHERE org_id = ? queries via Postgres's leftmost-prefix optimization.",
44
+ "evidence": [
45
+ "src/ferro/schema_metadata.py:130-133 unconditionally sets prop['index'] = True when metadata.index is True; no cross-check against schema['ferro_composite_indexes'].",
46
+ "src/ferro/composite_indexes.py warn_and_drop_overlap_with_uniques only handles composite-vs-composite-unique, not single-column-vs-composite-leftmost."
47
+ ],
48
+ "impact": "Wasted storage and write amplification on tables that are precisely the high-write tenant tables this feature targets. Not a correctness or migration-safety issue.",
49
+ "remediation": "Out of scope for this PR. Optional follow-up: extend warn_and_drop_overlap_with_uniques to a generic warn_and_drop_overlap routine that also drops a column-level FK index when an existing composite index has that column as its leftmost prefix, with a UserWarning pointing the user at the composite."
50
+ }
51
+ ],
52
+ "testing_gaps": [
53
+ {
54
+ "id": "no-autogen-roundtrip-test",
55
+ "severity": "low",
56
+ "confidence": 70,
57
+ "title": "No test exercises the actual Alembic autogen path (compare_metadata) for ForeignKey(index=True) — only the static MetaData object is asserted",
58
+ "where": "tests/test_alembic_autogenerate.py (new tests)",
59
+ "summary": "The four new tests in test_alembic_autogenerate.py inspect get_metadata().tables['project'].c.org_id.index but never run alembic.autogenerate.compare_metadata against a live (or in-memory) DB to verify that the diff actually emits a CreateIndexOp with the expected name and that the same MetaData against an already-indexed DB emits no diff (idempotency). This is the test that would catch the ix_ vs idx_ name divergence regressing into a 'phantom CreateIndex on every autogen run' bug.",
60
+ "remediation": "Add one test that sets up an empty SQLite DB, runs compare_metadata(MigrationContext, get_metadata()), and asserts an op with type 'add_index' and column 'org_id' appears. Add a second test that pre-creates the index under SA's expected ix_ name and asserts compare_metadata yields no diff."
61
+ },
62
+ {
63
+ "id": "no-pg-runtime-parity-test",
64
+ "severity": "low",
65
+ "confidence": 60,
66
+ "title": "Runtime DDL parity test for ForeignKey(index=True) is sqlite_only; Postgres path is only covered by a Rust unit test",
67
+ "where": "tests/test_schema_constraints.py::test_foreign_key_index_runtime_ddl_parity",
68
+ "summary": "The new integration test is marked @pytest.mark.sqlite_only. The Rust unit test in src/schema.rs loops over [SqlDialect::Sqlite, SqlDialect::Postgres] and asserts CREATE INDEX text, but no end-to-end Postgres test verifies the index actually exists in pg_indexes after connect(auto_migrate=True). Given that the documented use case is Postgres tenant tables, a Postgres-side runtime parity test is the highest-value missing coverage.",
69
+ "remediation": "Mirror the sqlite test as a @pytest.mark.postgres_only test that queries pg_indexes WHERE tablename = 'project' AND indexname = 'idx_project_org_id'."
70
+ }
71
+ ]
72
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "reviewer": "kieran-python",
3
+ "findings": [
4
+ {
5
+ "id": "kieran-python-1",
6
+ "title": "Docstring example for ForeignKey(index=True) is not runnable / inconsistent with the public guide",
7
+ "file": "src/ferro/base.py",
8
+ "lines": "138-148",
9
+ "severity": "P1",
10
+ "category": "maintainability",
11
+ "confidence": 75,
12
+ "summary": "The new __init__ docstring example uses Annotated[int, ForeignKey(\"projects\", index=True)] and never defines an Org/Project pair, so a reader cannot copy-paste it into a working module. The user-facing relationships guide (and your own internal pattern) uses Annotated[<TargetModel>, ForeignKey(related_name=\"projects\", index=True)] with the model class as the first type argument; that's how the ForeignKey resolves its `to` target. The doctest also reuses the same int-typed shape that the existing profile_id example uses, perpetuating an example pattern that doesn't reflect real usage. Either define a tiny Org model in the example and use `org: Annotated[Org, ForeignKey(related_name=\"projects\", index=True)]`, or drop the new example block entirely and let the class-level docstring carry one canonical example. The new example is the most likely thing a reader hits via IDE hover, so it should be the most correct, not the most stylized.",
13
+ "fix_suggestion": "Replace the appended example with a runnable one: `>>> class Org(Model):\\n... id: Annotated[int, FerroField(primary_key=True)]\\n>>>\\n>>> class Project(Model):\\n... id: Annotated[int, FerroField(primary_key=True)]\\n... org: Annotated[Org, ForeignKey(related_name=\"projects\", index=True)]` — matching docs/guide/relationships.md."
14
+ },
15
+ {
16
+ "id": "kieran-python-2",
17
+ "title": "Four new tests duplicate `from ferro import BackRef, ForeignKey, Relation` inline instead of using the module-level import block",
18
+ "file": "tests/test_alembic_autogenerate.py",
19
+ "lines": "195, 214, 238, 256",
20
+ "severity": "P1",
21
+ "category": "maintainability",
22
+ "confidence": 75,
23
+ "summary": "The file already imports public ferro symbols at module top (`from ferro import FerroField, Model, clear_registry, reset_engine`). The only inline imports prior to this PR are inside the cleanup fixture and pull `ferro.state` *internals* — a deliberate exception. The four new tests instead import public API (`BackRef, ForeignKey, Relation`) inside each function body, four copies of the same line, with no scoping reason. This breaks the existing convention, hides the test file's real surface area when scanning the top of the file, and adds noise to every new test. Hoist the import to the top once.",
24
+ "fix_suggestion": "Change line 10 to `from ferro import BackRef, FerroField, ForeignKey, Model, Relation, clear_registry, reset_engine` and delete the four inline `from ferro import BackRef, ForeignKey, Relation` lines."
25
+ },
26
+ {
27
+ "id": "kieran-python-3",
28
+ "title": "Redundancy UserWarning fires before `nullable` is validated, so an invalid `nullable` argument produces both a warning and a TypeError",
29
+ "file": "src/ferro/base.py",
30
+ "lines": "150-167",
31
+ "severity": "P2",
32
+ "category": "reliability",
33
+ "confidence": 50,
34
+ "summary": "`__init__` currently runs in this order: assign attributes -> warn if `unique and index` -> `_validate_nullable_option(nullable, \"ForeignKey\")` -> SET NULL+nullable=False guard. If a caller passes `ForeignKey(..., unique=True, index=True, nullable=\"bogus\")` they get a UserWarning about a redundant flag *and then* a TypeError about nullable. The warning is also issued for an object that fails to construct, polluting filters and pytest.warns scopes. Validate inputs first (nullable, then SET-NULL/nullable interaction), then surface the redundancy warning once we know the FK config is otherwise legal. This also keeps the fast-fail TypeError path uncluttered by warnings noise.",
35
+ "fix_suggestion": "Reorder to: `_validate_nullable_option` -> SET NULL guard -> redundancy warning + `self.index = False`. Drop the assignment-then-overwrite of `self.index` and assign the final value once."
36
+ }
37
+ ],
38
+ "residual_risks": [
39
+ "Runtime DDL parity for ForeignKey(index=True) is exercised end-to-end only on SQLite (`@pytest.mark.sqlite_only` in test_foreign_key_index_runtime_ddl_parity). The Rust unit test in src/schema.rs does cover both Sqlite and Postgres dialects, so this is mitigated, but there is no Python-level Postgres integration test confirming the Rust runtime emits `idx_project_org_id` against a real Postgres database; rely on the Rust unit test until a Postgres integration fixture exists."
40
+ ],
41
+ "testing_gaps": [
42
+ "No test asserts the warning text content beyond `match=\"redundant\"`. If the wording is part of the user-facing contract (CHANGELOG and docstring both quote it), consider asserting on the full sentence or at minimum on `\"unique=True\"` + `\"index=True\"` + `\"redundant\"` to lock it in."
43
+ ]
44
+ }
@@ -0,0 +1,79 @@
1
+ {
2
+ "reviewer": "maintainability",
3
+ "scope": {
4
+ "branch": "feat/foreign-key-index-32",
5
+ "pr": 36,
6
+ "files": [
7
+ "src/ferro/base.py",
8
+ "src/ferro/schema_metadata.py",
9
+ "src/schema.rs",
10
+ "tests/test_alembic_autogenerate.py",
11
+ "tests/test_schema_constraints.py",
12
+ "docs/guide/relationships.md",
13
+ "CHANGELOG.md"
14
+ ]
15
+ },
16
+ "findings": [
17
+ {
18
+ "id": "fk-index-silent-mutation",
19
+ "title": "ForeignKey.__init__ silently mutates the user-passed `index` attribute on redundancy",
20
+ "file": "src/ferro/base.py",
21
+ "lines": "150-162",
22
+ "category": "naming-clarity / least-surprise",
23
+ "severity": "low",
24
+ "confidence": 55,
25
+ "description": "After `unique=True, index=True`, __init__ emits a UserWarning and resets `self.index = False`. The instance attribute then disagrees with the kwarg the caller passed. Anyone introspecting `fk.index` later (debugging, custom tooling, third-party schema diffing, a future Ferro feature that conditionally branches on `metadata.index`) sees False even though the source code says True. The warning fires once at class-definition time and is then lost; the lie on the attribute persists for the life of the process. This also makes the new test `assert profile_table.c.user_id.index is False` correct only because of the silent mutation -- a future maintainer reading just `ForeignKey(unique=True, index=True)` would reasonably expect `index=True` to round-trip.",
26
+ "suggestion": "Treat `index` as the literal user intent and compute the *effective* index decision at the single consumer (schema_metadata.py): `if metadata.index and not metadata.unique: prop[\"index\"] = True`. Keep the UserWarning where it is. This (a) preserves the user's input on the metadata object, (b) keeps the redundancy logic in one place, and (c) makes the intent explicit at the consumer site rather than hidden in a constructor side effect. If you do keep the mutation, add an inline comment near `self.index = False` explaining that the public attribute is normalized post-warning, so the next maintainer doesn't read it as a bug.",
27
+ "user_flagged": true
28
+ },
29
+ {
30
+ "id": "fk-vs-ferrofield-redundancy-asymmetry",
31
+ "title": "Parallel-but-divergent handling of `unique=True, index=True` between FerroField and ForeignKey",
32
+ "file": "src/ferro/base.py",
33
+ "lines": "54-85, 114-167",
34
+ "category": "api-symmetry / coupling",
35
+ "severity": "low",
36
+ "confidence": 60,
37
+ "description": "ForeignKey now warns + drops `index` when combined with `unique`. FerroField has had `unique` and `index` for longer and silently accepts both; downstream in schema_metadata.py the FerroField path unconditionally writes `prop[\"unique\"] = metadata.unique` AND `prop[\"index\"] = metadata.index` (lines 106-107), so a user who writes `FerroField(unique=True, index=True)` likely gets *both* a unique constraint AND a redundant non-unique index emitted, with no warning. Two near-identical APIs now have opposite UX for the identical mistake. Whichever behavior is correct, the divergence itself is the maintainability cost: the next reader has to remember which constructor warns and which doesn't, and the rationale (`unique implies index`) applies equally to both.",
38
+ "suggestion": "Pick one rule and apply it to both constructors. Either (a) extract a shared `_normalize_unique_index(unique, index, owner) -> tuple[bool, bool]` helper that warns + drops in both classes, or (b) push the redundancy check entirely into the schema layer where both code paths converge, removing the logic from `__init__` for ForeignKey. Option (a) keeps validation close to the user; option (b) eliminates the silent-mutation problem from finding #1 simultaneously. Either way, do not ship the asymmetry.",
39
+ "user_flagged": true
40
+ },
41
+ {
42
+ "id": "fk-schema-property-contract-divergence",
43
+ "title": "Schema property keys for `index`/`unique` are written conditionally on the FK path and unconditionally on the FerroField path",
44
+ "file": "src/ferro/schema_metadata.py",
45
+ "lines": "106-107, 130-133",
46
+ "category": "coupling / contract-clarity",
47
+ "severity": "low",
48
+ "confidence": 45,
49
+ "description": "FerroField path: `prop[\"unique\"] = metadata.unique; prop[\"index\"] = metadata.index` (always present, bool). ForeignKey path: `if metadata.unique: prop[\"unique\"] = True; if metadata.index: prop[\"index\"] = True` (key absent when False). Downstream Rust DDL consumers and any other reader of this schema dict now have to handle two shapes for the same logical property -- 'False' vs 'absent'. This is the kind of subtle inconsistency that bites later when someone adds a third consumer and assumes the keys are always present.",
50
+ "suggestion": "Match the FerroField shape on the ForeignKey path: write `prop[\"index\"] = metadata.index` and `prop[\"unique\"] = metadata.unique` unconditionally. The Rust side already treats absence and `false` as equivalent in the new test, so this is a no-op behavior change today, but it locks in a single contract."
51
+ }
52
+ ],
53
+ "residual_risks": [
54
+ {
55
+ "id": "fk-index-naming-divergence",
56
+ "description": "Docs explicitly call out that Alembic autogen produces `ix_project_org_id` while the Rust runtime DDL produces `idx_project_org_id`. This is honest but it is a real foot-gun: a user comparing migration output against a database created via `auto_migrate=True` will see two indexes with different names for the same logical thing, or a 'phantom' index drop/create on the first autogen run after switching paths. Out of scope for this PR but worth tracking as a follow-up.",
57
+ "file": "docs/guide/relationships.md",
58
+ "confidence": 70
59
+ },
60
+ {
61
+ "id": "test-boilerplate-mild-duplication",
62
+ "description": "Four new tests in test_alembic_autogenerate.py each redeclare an `Org` model with `id` + `projects: Relation[list[\"Project\"]] = BackRef()` and a `Project` model that varies only in the ForeignKey kwargs. A pytest.parametrize over (fk_kwargs, expected_index, expected_unique) would shrink ~80 lines to ~30 and put the test cases in a single grid. Not blocking -- the duplication is local and readable -- but the next FK-feature test will copy this shape again and the cost compounds.",
63
+ "file": "tests/test_alembic_autogenerate.py",
64
+ "confidence": 40
65
+ }
66
+ ],
67
+ "testing_gaps": [
68
+ {
69
+ "id": "unique-implies-index-unverified-premise",
70
+ "description": "The redundancy warning's premise is 'unique=True already creates an implicit unique index'. The new tests verify that `unique=True, index=True` produces `index=False` on the SQLAlchemy column, but no test in this diff asserts that `ForeignKey(unique=True)` alone results in an actual unique index on the FK column at the DDL level (Alembic) or at the Rust runtime level. If that premise ever drifts -- e.g., a backend that enforces uniqueness via a constraint without a usable index -- the warning becomes misleading. Add a parity test analogous to `test_foreign_key_index_runtime_ddl_parity` for `unique=True` to lock in the assumption.",
71
+ "file": "tests/test_schema_constraints.py"
72
+ },
73
+ {
74
+ "id": "fk-index-postgres-runtime-parity-untested",
75
+ "description": "`test_foreign_key_index_runtime_ddl_parity` is `@pytest.mark.sqlite_only`. The Rust schema test in src/schema.rs covers Postgres at the SQL-string level, but there is no end-to-end test that the Postgres runtime path actually creates the index and that its name (`idx_project_org_id`) matches what the Postgres-specific code emits. Given the docs explicitly mention Postgres FKs as the motivating use case, a Postgres parity test is worth adding before relying on this in production."
76
+ }
77
+ ],
78
+ "summary": "Three low-severity maintainability findings, all on the same axis: the new `unique+index` redundancy logic was added asymmetrically. Concretely (1) it silently mutates `self.index` so the public attribute disagrees with the constructor argument, (2) it diverges from FerroField which has the same redundancy but no warning, and (3) it writes the schema property dict in a different shape than the FerroField path. The cleanest fix collapses all three: extract a shared normalize helper used by both constructors *or* push the redundancy decision into schema_metadata.py and stop mutating the metadata object. Tests are correct and well-placed but contain mild boilerplate duplication and leave the 'unique implies index' premise itself unverified."
79
+ }
@@ -0,0 +1,45 @@
1
+ {
2
+ "reviewer": "project-standards",
3
+ "scope": {
4
+ "branch": "feat/foreign-key-index-32",
5
+ "pr": 36,
6
+ "standards_file": ".cursorrules"
7
+ },
8
+ "audit_summary": {
9
+ "tdd_workflow": {
10
+ "compliant": true,
11
+ "evidence": "Commit 02d8184 'test: red test for ForeignKey(index=True) shadow column index' is the first commit on the branch and precedes all feat commits (9c3f9cf, b6a41b4, 8e4423c). Matches .cursorrules \u00a74 Step 1: 'Write the Integration Test First'."
12
+ },
13
+ "rust_unit_tests_present": {
14
+ "compliant": true,
15
+ "evidence": "src/schema.rs adds test_foreign_key_column_with_index_flag_emits_create_index covering both Sqlite and Postgres dialects, asserting CREATE INDEX, the idx_project_org_id name, and absence of CREATE UNIQUE INDEX. Satisfies .cursorrules \u00a75.1."
16
+ },
17
+ "python_integration_tests_present": {
18
+ "compliant": true,
19
+ "evidence": "tests/test_alembic_autogenerate.py adds 4 cases (index emission, unique+index warning, default false, nullable FK) and tests/test_schema_constraints.py adds test_foreign_key_index_runtime_ddl_parity exercising the compiled Rust runtime DDL. Satisfies .cursorrules \u00a75.2."
20
+ },
21
+ "no_unwrap_introduced": {
22
+ "compliant": true,
23
+ "evidence": "Diff contains zero occurrences of unwrap(). The new Rust test uses safe destructuring of build_create_table_sqls and Vec::join. Satisfies .cursorrules \u00a74 Step 3 'Never use unwrap()'."
24
+ },
25
+ "pyo3_bound_apis": {
26
+ "compliant": true,
27
+ "evidence": "PyO3 layer is not touched by this change (only src/schema.rs test module, plus pure-Python files). The Bound<'_, PyModule> requirement is out of scope."
28
+ },
29
+ "changelog_unreleased_entry": {
30
+ "compliant": true,
31
+ "evidence": "CHANGELOG.md adds a bullet under '## Unreleased' \u2192 '### Features' linking issue #32: 'Add `ForeignKey(index=True)` to emit a non-unique index on the shadow `*_id` column...'"
32
+ },
33
+ "bridge_thinness_parse_json_in_rust": {
34
+ "compliant": true,
35
+ "evidence": "src/ferro/schema_metadata.py only sets `prop[\"index\"] = True` on the JSON schema dict (lightweight serialization). All semantic interpretation (index DDL emission, naming) happens in Rust (src/schema.rs). Matches .cursorrules \u00a76 'FFI Efficiency: Pass JSON or primitives; parse in Rust.'"
36
+ },
37
+ "frequent_conventional_commits": {
38
+ "compliant": true,
39
+ "evidence": "8 commits on branch, all conventional with scope: 02d8184 test:, 9c3f9cf feat(fk):, b6a41b4 feat(fk):, 8e4423c feat(fk):, 260976c test(fk):, a0e84d6 test(fk):, b58c126 test(rust):, 3ccef38 docs(fk):. Each commit isolates a single concern (red test \u2192 minimal kwarg \u2192 propagation \u2192 warn \u2192 regression guards \u2192 runtime parity \u2192 rust unit \u2192 docs+changelog)."
40
+ }
41
+ },
42
+ "findings": [],
43
+ "residual_risks": [],
44
+ "testing_gaps": []
45
+ }
@@ -0,0 +1,108 @@
1
+ {
2
+ "reviewer": "testing",
3
+ "findings": [
4
+ {
5
+ "id": "testing-001-autogen-idempotency-gap",
6
+ "title": "No autogen-idempotency test for FK index; ix_* vs idx_* naming asymmetry will silently produce drift",
7
+ "severity": "P1",
8
+ "confidence": 75,
9
+ "autofix_class": "manual",
10
+ "owner": "review-fixer",
11
+ "files": [
12
+ "tests/test_alembic_autogenerate.py",
13
+ "tests/test_schema_constraints.py"
14
+ ],
15
+ "evidence": {
16
+ "diff_signal": "docs/guide/relationships.md (lines 357-360) explicitly documents the naming asymmetry: Alembic emits 'ix_project_org_id' (SQLAlchemy convention) while Rust runtime DDL emits 'idx_project_org_id'. The PR adds zero tests that run alembic.autogenerate.compare_metadata after auto_migrate to confirm this asymmetry does not produce phantom diffs.",
17
+ "precedent": "tests/test_composite_index.py::test_autogen_idempotent_after_first_apply (lines 690-724) establishes the team's pattern for catching exactly this class of bug for composite indexes. The new FK index feature has no equivalent guard.",
18
+ "regression_path": "After auto_migrate creates idx_project_org_id in the DB, compare_metadata(ctx, get_metadata()) sees: (a) an unexpected DB index 'idx_project_org_id' Alembic wants to drop, and (b) a missing index 'ix_project_org_id' Alembic wants to create. Every subsequent autogen run produces a non-empty diff. A naive `alembic revision --autogenerate` followed by `upgrade` will drop the Rust-created index and replace it with the Alembic-named one, silently mutating the deployed schema."
19
+ },
20
+ "recommended_fix": "Add an asyncio + sqlite_only test that calls connect(auto_migrate=True) for a model with ForeignKey(index=True), then runs compare_metadata on a sync engine and asserts the resulting diff contains no entries that match 'project' or 'org_id'. Mirror the structure of test_autogen_idempotent_after_first_apply. If the diff is non-empty (which it likely will be due to ix_/idx_ asymmetry), the implementation needs reconciliation (e.g., emit an Index() with name='idx_*' on the SA Column instead of relying on the bare index=True default convention).",
21
+ "rationale": "This is the question the user asked in the prompt verbatim: 'autogenerate diff-detection between two model versions'. Coverage gap is provable from the diff and from the docs that flag the asymmetry."
22
+ },
23
+ {
24
+ "id": "testing-002-no-postgres-end-to-end-runtime-ddl",
25
+ "title": "FK runtime-DDL parity test is SQLite-only; no Postgres analog queries pg_indexes",
26
+ "severity": "P2",
27
+ "confidence": 75,
28
+ "autofix_class": "manual",
29
+ "owner": "review-fixer",
30
+ "files": ["tests/test_schema_constraints.py"],
31
+ "evidence": {
32
+ "diff_signal": "tests/test_schema_constraints.py adds test_foreign_key_index_runtime_ddl_parity gated by @pytest.mark.sqlite_only. The Rust unit test in src/schema.rs iterates both SqlDialect::Sqlite and SqlDialect::Postgres but only asserts substring presence on the rendered SQL string -- it does not execute that SQL against a live Postgres engine.",
33
+ "precedent": "tests/test_schema_constraints.py::test_foreign_key_constraint_exists_in_postgres (lines 88-140) demonstrates the team's pattern for verifying FK behavior end-to-end on Postgres via information_schema queries. The new feature breaks this pattern.",
34
+ "regression_path": "Identifier quoting differences, search_path interactions, schema-qualified naming, or IF NOT EXISTS semantics on Postgres < 9.5 (sea_query emits the keyword unconditionally per src/schema.rs:342) could fail at runtime. The Rust string-level unit test will not catch a real connection failure or a partial-DDL situation where the table is created but the index step rolls back."
35
+ },
36
+ "recommended_fix": "Add @pytest.mark.postgres_only async test that runs auto_migrate with ForeignKey(index=True), then queries SELECT indexname FROM pg_indexes WHERE schemaname=%s AND tablename='project' and asserts 'idx_project_org_id' is present and is non-unique (verify via pg_index.indisunique = false). Reuse the postgres_base_url / db_schema_name fixtures already used by test_foreign_key_constraint_exists_in_postgres."
37
+ },
38
+ {
39
+ "id": "testing-003-nullable-fk-index-not-verified-at-ddl-level",
40
+ "title": "Nullable FK + index combination only verified at SA-metadata level, not at runtime DDL",
41
+ "severity": "P2",
42
+ "confidence": 50,
43
+ "autofix_class": "manual",
44
+ "owner": "review-fixer",
45
+ "files": ["tests/test_alembic_autogenerate.py"],
46
+ "evidence": {
47
+ "diff_signal": "test_foreign_key_index_with_nullable_fk only checks project_table.c.org_id.index is True / nullable is True (SA metadata flags). The runtime-DDL parity test uses the default non-null CASCADE case. The Rust unit test in schema.rs uses ferro_nullable: false.",
48
+ "regression_path": "A future change that conditionally suppresses the CREATE INDEX emission for nullable FK columns (e.g., to switch to a partial index 'WHERE org_id IS NOT NULL' for storage savings) would pass test_foreign_key_index_with_nullable_fk and the Rust unit test, but would silently regress the documented contract that the index is created for nullable FKs."
49
+ },
50
+ "recommended_fix": "Either (a) add ferro_nullable: true variant to the existing Rust unit test in src/schema.rs, OR (b) add 'org: Org | None' with on_delete='SET NULL' + index=True to test_foreign_key_index_runtime_ddl_parity and assert idx_project_org_id is still emitted in sqlite_master."
51
+ },
52
+ {
53
+ "id": "testing-004-warning-negative-path-uncovered",
54
+ "title": "No assertion that UserWarning is silent for index=True alone or unique=True alone",
55
+ "severity": "P3",
56
+ "confidence": 50,
57
+ "autofix_class": "safe_auto",
58
+ "owner": "review-fixer",
59
+ "files": ["tests/test_alembic_autogenerate.py"],
60
+ "evidence": {
61
+ "diff_signal": "test_foreign_key_unique_implies_index_warns asserts the warning fires for unique=True+index=True. test_foreign_key_index_emits_single_column_index uses index=True alone but does not wrap the class definition in pytest.warns / pytest.warns(None) / a recwarn fixture to confirm no UserWarning is emitted. Same for test_foreign_key_index_default_false.",
62
+ "regression_path": "A future change that broadens the redundancy condition (e.g., warning whenever index=True regardless of unique) would not fail any current test. The warning-on path is tested; the warning-off path is implied but unverified."
63
+ },
64
+ "recommended_fix": "Wrap one of the existing index=True tests in 'with warnings.catch_warnings(record=True) as ws: warnings.simplefilter(\"always\")' and assert no UserWarning matching 'redundant' was captured. Cheap to add."
65
+ },
66
+ {
67
+ "id": "testing-005-default-false-omits-json-key-not-verified",
68
+ "title": "test_foreign_key_index_default_false asserts SA metadata only; doesn't verify schema-JSON behavior is bit-for-bit identical to pre-PR",
69
+ "severity": "P3",
70
+ "confidence": 50,
71
+ "autofix_class": "advisory",
72
+ "owner": "review-fixer",
73
+ "files": ["tests/test_alembic_autogenerate.py", "src/ferro/schema_metadata.py"],
74
+ "evidence": {
75
+ "diff_signal": "src/ferro/schema_metadata.py:132-133 uses 'if metadata.index: prop[\"index\"] = True' for FK columns (conditional set), unlike line 107 for ferro_fields which unconditionally sets prop['index'] = metadata.index. The new test asserts SA Column.index is False but does not assert the underlying JSON schema property for org_id either lacks the 'index' key entirely or has it set to False.",
76
+ "intent_in_pr_description": "The PR explicitly states 'Existing index=False behavior must remain bit-for-bit identical.' The current test verifies the SA-level outcome, which is downstream of both the JSON shape AND the alembic.py renderer's get('index', False) fallback. A regression that flipped the schema_metadata.py line to 'prop[\"index\"] = metadata.index' (unconditional) would change the JSON shape (key now present with value False) without changing observable SA metadata, slipping past tests but potentially affecting CDC consumers or external schema-JSON consumers."
77
+ },
78
+ "recommended_fix": "Add a one-liner assertion in test_foreign_key_index_default_false that calls build_model_schema(Project) and asserts 'index' not in schema['properties']['org_id']. Or relax the requirement and document that 'bit-for-bit identical' applies to SA metadata only."
79
+ }
80
+ ],
81
+ "residual_risks": [
82
+ "Alembic autogen drift between Rust-emitted 'idx_*' indexes and SQLAlchemy-default 'ix_*' indexes is the highest-risk silent failure mode for this feature. Any team running 'alembic revision --autogenerate' after an auto_migrate may unwittingly produce a migration that drops the Rust index and recreates it under a different name -- and no test in this PR will surface that.",
83
+ "Postgres-specific behaviors (search_path, schema qualification under multi-tenant schemas, IF NOT EXISTS on legacy Postgres) are not exercised end-to-end. The project has multi-tenant schema fixtures (db_schema_name) used elsewhere; the new feature does not opt in.",
84
+ "The pytest.warns(UserWarning, match='redundant') assertion is intentionally loose on wording (good for refactor resilience) but will not detect a future change that emits the warning at the wrong stacklevel, causing it to surface as if from the ForeignKey internal frame rather than the user's class definition."
85
+ ],
86
+ "testing_gaps": [
87
+ {
88
+ "area": "composite_indexes + FK shadow column interaction",
89
+ "description": "A model declaring both __ferro_composite_indexes__ = ((\"org_id\", \"name\"),) and ForeignKey(index=True) on the relation would request two distinct indexes on overlapping columns (a single-column ix on org_id and a composite on org_id+name). No test exercises this; the warn_and_drop_overlap_with_uniques() helper handles only composite-vs-unique overlaps, not single-column FK-index vs composite. Likely fine (additive) but unverified.",
90
+ "confidence": 50
91
+ },
92
+ {
93
+ "area": "unique=True alone without index=True",
94
+ "description": "Pre-existing behavior; this PR doesn't regress it. But the new assertions on profile_table.c.user_id.unique is True / index is False rely on the implementation in schema_metadata.py:130-133 being bypassed correctly for the unique-only case. No targeted test for a model with unique=True alone in the new test additions; the assertion piggybacks on the redundancy-warning test only.",
95
+ "confidence": 50
96
+ },
97
+ {
98
+ "area": "Warning stacklevel and emission location",
99
+ "description": "src/ferro/base.py:158 sets stacklevel=2. No test asserts the warning surfaces from the user's class-definition frame. A refactor that wraps __init__ in another helper would silently break the user-facing warning location.",
100
+ "confidence": 25
101
+ },
102
+ {
103
+ "area": "Idempotency of repeated auto_migrate calls",
104
+ "description": "Rust DDL uses .if_not_exists() (src/schema.rs:342) on the index creation, but no test calls connect(auto_migrate=True) twice and asserts the second call succeeds without error. The composite_indexes test suite has equivalent coverage; FK index does not.",
105
+ "confidence": 50
106
+ }
107
+ ]
108
+ }
@@ -70,15 +70,26 @@ jobs:
70
70
  - name: Set up Rust
71
71
  uses: dtolnay/rust-toolchain@stable
72
72
 
73
+ - name: Set up Python
74
+ uses: actions/setup-python@v5
75
+ with:
76
+ python-version: "3.13"
77
+
73
78
  - name: Cache Rust build
74
79
  uses: Swatinem/rust-cache@v2
75
80
  with:
76
- prefix-key: v1
81
+ prefix-key: v2
77
82
  cache-on-failure: true
78
83
 
79
84
  - name: Run Rust tests
85
+ env:
86
+ PYO3_PYTHON: ${{ env.pythonLocation }}/bin/python
80
87
  run: |
81
- cargo test --all-features
88
+ # extension-module skips libpython linking; disable it for tests so the
89
+ # cargo test binary can resolve Python symbols against the installed runtime.
90
+ # The `testing` feature pulls in pyo3/auto-initialize so unit tests that
91
+ # call Python::attach start the interpreter automatically.
92
+ cargo test --no-default-features --features testing
82
93
 
83
94
  test-python-pr:
84
95
  name: Python tests (PR / 3.13)