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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions crates/ferro-schema-ir/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,8 @@ mod tests {

#[test]
fn schema_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json");
let fixture =
include_str!("../../../tests/fixtures/ir_vectors/schema_invoice_baseline_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("schema fixture must parse");
let ir = parsed
Expand All @@ -156,7 +157,8 @@ mod tests {

#[test]
fn query_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v1.json");
let fixture =
include_str!("../../../tests/fixtures/ir_vectors/query_user_compound_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("query fixture must parse");
let ir = parsed
Expand All @@ -171,7 +173,8 @@ mod tests {

#[test]
fn codec_fixture_roundtrip() {
let fixture = include_str!("../../../tests/fixtures/ir_vectors/codec_registry_core_v1.json");
let fixture =
include_str!("../../../tests/fixtures/ir_vectors/codec_registry_core_v1.json");
let parsed: serde_json::Value =
serde_json::from_str(fixture).expect("codec fixture must parse");
let ir = parsed
Expand Down
1 change: 1 addition & 0 deletions docs/examples/predicates.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ async def main() -> None:
assert len(adults) == 3

# --8<-- [start:operator-style]
# Deprecated path (planned removal: v0.13.0).
adults = await User.where(User.age >= 18).all()
# --8<-- [end:operator-style]
assert len(adults) == 3
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/api/queries.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Queries

`Model.where(...)` and `Model.select()` return a `Query` — an immutable, chainable builder that executes when awaited via `all()`, `first()`, `count()`, `exists()`, `update()`, or `delete()`. Predicates are written against the typed field proxies on the model class (`User.age >= 18`); `col()` is the untyped escape hatch for dynamic field names.
`Model.where(...)` and `Model.select()` return a `Query` — an immutable, chainable builder that executes when awaited via `all()`, `first()`, `count()`, `exists()`, `update()`, or `delete()`. Predicates are lambda-first (`User.where(lambda t: t.age >= 18)`), `col()` is the compatibility bridge for operator-shaped predicates, and direct operator style is deprecated for `v0.13.0` removal.

::: ferro.query.builder.Query

Expand Down
8 changes: 4 additions & 4 deletions docs/pages/concepts/query-typing.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ from ferro.query import col
rows = await User.where(col(User.archived) == False).all()
```

`col()` is a runtime-identity helper that statically narrows its argument back to `FieldProxy[T]`. It does no work at runtime beyond an `isinstance` guard (and raises `TypeError` if you accidentally hand it a literal). Reach for it when you want to keep the operator shape on an existing call site while staying type-safe.
`col()` is a runtime helper that returns a typed `FieldProxy[T]` for the same column while preserving the operator shape. It validates input with an `isinstance` guard (and raises `TypeError` if you accidentally hand it a literal). Reach for it when you want to keep the operator shape on an existing call site while staying type-safe.

### 3. Operator (legacy)

Expand All @@ -40,8 +40,8 @@ rows = await User.where(User.id == 1).all()
rows = await User.where(User.email.like("%@example.com")).all()
```

!!! warning "Operator style will be deprecated"
The operator style is compatible today but slated for deprecation in a future release. It also fails static type checking: checkers read `User.id == 1` through your Pydantic annotations as a `bool`, while `where()` expects a `QueryNode | Predicate`. Use lambda predicates for new code, or `col()` when migrating existing operator-style call sites with minimal diff.
!!! warning "Operator style is deprecated"
The operator style is compatible today but on the `v0.13.0` removal track. It also fails static type checking: checkers read `User.id == 1` through your Pydantic annotations as a `bool`, while `where()` expects a `QueryNode | Predicate`. Use lambda predicates for new code, or `col()` when migrating existing operator-style call sites with minimal diff.

## When to Use Which

Expand Down Expand Up @@ -79,7 +79,7 @@ published = await author.posts.where(lambda t: t.published == True).all()
- Your model annotations. `archived: bool = False` stays exactly as it is.
- The metaclass's `FieldProxy` injection. Class attribute access is unchanged.
- Pydantic schema generation, JSON schema output, or model validation.
- The Rust FFI bridge or how `QueryNode`s are serialized for the engine.
- The Rust FFI bridge architecture (predicates now serialize through QueryIR envelopes).
- The operator-path runtime. Existing `Model.field == value` calls take the same code path they always have.

## Scope Boundaries
Expand Down
4 changes: 2 additions & 2 deletions docs/pages/guide/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ Both methods also exist on `Model.using("name")` for [named connections](connect
--8<-- "docs/examples/predicates.py:operator-style"
```

!!! warning "Operator style will be deprecated"
The operator style is compatible today but slated for deprecation in a future release. It is also incompatible with static type checkers (ty, mypy, Pyright): they see `User.age >= 18` as a `bool` from your Pydantic annotations, while `where()` expects a `QueryNode | Predicate`. Prefer the lambda style.
!!! warning "Operator style is deprecated"
The operator style is compatible today but on the `v0.13.0` removal track. It is also incompatible with static type checkers (ty, mypy, Pyright): they see `User.age >= 18` as a `bool` from your Pydantic annotations, while `where()` expects a `QueryNode | Predicate`. Prefer the lambda style.

Lambda predicates keep the call site fully type-checked because the proxy's attributes are real `FieldProxy` objects in the type checker's eyes, not your Pydantic annotations. Reach for `col()` only when you want to preserve the operator shape on a single attribute. See [Typed Query Predicates](../concepts/query-typing.md) for the full reasoning.

Expand Down
60 changes: 49 additions & 11 deletions docs/plans/2026-06-19-001-ir-first-roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Move Ferro to an IR-first architecture where schema, query, migration, and codec
- Query execution consumes typed QueryIR (not ad-hoc JSON payloads).
- Hydration path is single and ABI-defined for Pydantic slot initialization.
- Global registries are removed from hot-path runtime operations in favor of explicit engine/session state.
- Legacy compatibility shims are removed in the final major-version cut.
- Legacy compatibility shims are removed in the explicit `v0.13.0` cutover.
- User-facing migration guidance is continuously updated and release-ready at each phase boundary.

## Living migration guide requirement
Expand Down Expand Up @@ -253,7 +253,7 @@ Issue references:

### Phase 3 - QueryIR cutover

Status: `Not started`
Status: `In progress`

Issue references:

Expand All @@ -264,13 +264,22 @@ Issue references:
- Move query execution to typed QueryIR and retire internal JSON query contracts.

**Deliverables**
- [ ] Runtime query compilation consumes QueryIR.
- [ ] Lambda predicate style is first-class; legacy operator style on deprecation track.
- [ ] JSON query payload bridge removed from core execution path.
- [x] Runtime query compilation consumes QueryIR.
- [x] Lambda predicate style is first-class; legacy operator style on deprecation track.
- [x] JSON query payload bridge removed from core execution path.

**Exit gate**
- [ ] Query builder integration tests pass fully on QueryIR path.
- [ ] Compatibility behavior explicitly documented for remaining public API differences.
- [x] Query builder integration tests pass fully on QueryIR path.
- [x] Compatibility behavior explicitly documented for remaining public API differences.

**Evidence (working branch; pending merge to `feat/ir-first`)**
- QueryIR envelope emission from Python query builder: `src/ferro/query/builder.py`, `src/ferro/query/nodes.py`
- QueryIR envelope consumption on runtime query operations: `src/operations.rs`, `src/ferro/_core.pyi`
- Query/typing/deprecation test coverage: `tests/test_query_builder.py`, `tests/test_query_typing.py`, `tests/test_static_contracts.py`, `tests/test_shadow_reports.py`
- Deprecated-compat test inventory marker: `pytest.mark.deprecated_operator_path` (see `pyproject.toml`)
- Docs + migration updates for deprecation/compatibility: `docs/pages/guide/queries.md`, `docs/pages/concepts/query-typing.md`, `docs/pages/api/queries.md`, `docs/examples/predicates.py`, `docs/plans/ir-first-migration-guide.md`
- Verification command:
- `uv run pytest tests/test_static_contracts.py tests/test_query_builder.py tests/test_query_typing.py tests/test_shadow_reports.py -q`

---

Expand Down Expand Up @@ -343,7 +352,7 @@ Issue references:

---

### Phase 7 - Major version release and shim removal
### Phase 7 - Major-version public release with compatibility window

Status: `Not started`

Expand All @@ -353,16 +362,43 @@ Issue references:
- `Sub-issues:` [#101](https://github.com/syn54x/ferro-orm/issues/101), [#102](https://github.com/syn54x/ferro-orm/issues/102), [#103](https://github.com/syn54x/ferro-orm/issues/103)

**Objective**
- Complete migration by removing compatibility shims and releasing IR-first major version.
- Release the IR-first upgrade publicly while keeping deprecated compatibility paths available through a defined migration window.

**Deliverables**
- [ ] Legacy code paths removed.
- [ ] Public upgrade release shipped with migration guide and deprecation messaging.
- [ ] Deprecated compatibility paths remain available during the migration window.
- [ ] Deprecated-compat test inventory is tagged and tracked for removal (`pytest.mark.deprecated_operator_path`).
- [ ] Migration guide and upgrade checklist.
- [ ] Final release checklist and changelog entries.

**Exit gate**
- [ ] Release branch green across full backend/test matrix.
- [ ] Migration guide validated against at least one real example project.
- [ ] Deprecation warnings explicitly point to `v0.13.0` as the removal release.

---

### Phase 8 - Compatibility cutover and shim removal (`v0.13.0`)

Status: `Not started`

Issue references:

- `Epic:` _TBD_
- `Sub-issues:` _TBD_

**Objective**
- Complete migration by removing deprecated compatibility shims in `v0.13.0`.

**Deliverables**
- [ ] Legacy compatibility code paths removed.
- [ ] Deprecated-compat test inventory removed (all `deprecated_operator_path` tests deleted or rewritten).
- [ ] Final migration-guide cutover notes for `v0.13.0`.
- [ ] Release checklist and changelog entries for shim removal.

**Exit gate**
- [ ] Full backend/test matrix green with deprecated paths removed.
- [ ] Migration guide validated against at least one real example project on the `v0.13.0` code path.

## Workstreams and ownership

Expand Down Expand Up @@ -452,7 +488,7 @@ async with engines.session("app"):
Use this roadmap as the source for issues and project fields.

**Recommended project fields**
- `Phase`: 0, 1, 2, 3, 4, 5, 6, 7
- `Phase`: 0, 1, 2, 3, 4, 5, 6, 7, 8
- `Workstream`: WS1..WS6
- `Type`: RFC, Infra, Runtime, Migration, Test, Docs, Release
- `Status`: Backlog, Ready, In Progress, Blocked, In Review, Done
Expand Down Expand Up @@ -497,6 +533,8 @@ Append updates as concise entries.
- `2026-06-19` - Phase 1 implementation landed on working branch: added `ferro-schema-ir`, Python->SchemaIR compiler, model-set fingerprinting, and stable representative snapshot checks.
- `2026-06-19` - Phase 2 scaffolding landed on working branch: internal shadow runtime flag/hook wiring, semantic comparison harness, stable SQLite/Postgres shadow report fixtures, and touched-path CI gate for shadow reports.
- `2026-06-19` - Phase 2 merged via [#105](https://github.com/syn54x/ferro-orm/pull/105); issues [#80](https://github.com/syn54x/ferro-orm/issues/80), [#81](https://github.com/syn54x/ferro-orm/issues/81), [#82](https://github.com/syn54x/ferro-orm/issues/82), [#83](https://github.com/syn54x/ferro-orm/issues/83) synchronized and closed.
- `2026-06-19` - Phase 3 working-branch implementation landed: QueryIR envelope hot-path cutover for query operations, operator-style deprecation warnings, and synchronized query docs/migration guidance updates.
- `2026-06-19` - Sequencing update: Phase 7 is now public release with deprecated compatibility support; hard removal moved to Phase 8 (`v0.13.0`).

## Immediate next actions

Expand Down
19 changes: 18 additions & 1 deletion docs/plans/ir-first-migration-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,15 @@ No user-facing runtime behavior changes expected. Shadow planning is internal-on

### Phase 3

_TBD_
| Issue | Change | Impact | User action | Notes |
| --- | --- | --- | --- | --- |
| [#85](https://github.com/syn54x/ferro-orm/issues/85) | Runtime query compilation now consumes QueryIR envelopes on core execution paths | minor | No API change for lambda/`col()` query callers; if you rely on internal `_core` query payload shape, migrate to QueryIR envelope (`ir_kind`, `ir_version`, `payload`) | Internal JSON `QueryDef` payload contract is no longer the core hot-path boundary |
| [#86](https://github.com/syn54x/ferro-orm/issues/86) | Operator-style predicates (`Model.field OP value`) are deprecated with runtime warnings | minor | Migrate call sites to `where(lambda t: ...)` (recommended) or `col(Model.field)` | Deprecation message includes replacement + removal target (`v0.13.0`) |
| [#87](https://github.com/syn54x/ferro-orm/issues/87) | Python query builder now emits QueryIR envelope payloads to Rust runtime | minor | No action for public `Model.where`/`Query.where` usage; update internal tests/tools that serialized legacy `where_clause` JSON | Compatibility behavior remains documented in query typing docs during deprecation window |

Phase 3 test-migration note:

- Tests that exist only to verify temporary operator-style compatibility are tagged `deprecated_operator_path` and scheduled for removal/rewrite at `v0.13.0`.

### Phase 4

Expand All @@ -70,3 +78,12 @@ _TBD_
### Phase 7

_TBD_

### Phase 8

_TBD_

Planned cutover checklist (target: `v0.13.0`):

- Remove deprecated operator-style predicate support.
- Remove or rewrite all tests tagged `deprecated_operator_path`.
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ dev = [

[tool.pytest.ini_options]
addopts = "--cov=src"
markers = [
"deprecated_operator_path: tests that assert temporary operator-style predicate compatibility and are scheduled for removal in v0.13.0",
]

[tool.commitizen]
name = "cz_conventional_commits"
Expand Down
12 changes: 6 additions & 6 deletions src/ferro/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -56,23 +56,23 @@ def _render_migration_sql_for_test(
...

def _shadow_compare_query_plan_for_test(
query_json: str, dialect: str, operation: str = "select"
query_payload_json: str, dialect: str, operation: str = "select"
) -> str:
"""Test-only: compare legacy vs QueryIR-roundtrip query planning semantics."""
"""Test-only: compare query payload planning semantics."""
...

async def fetch_all(
cls: object, tx_id: Optional[str] = None, using: Optional[str] = None
) -> list[Any]: ...
async def fetch_filtered(
cls: object,
query_json: str,
query_ir_json: str,
tx_id: Optional[str] = None,
using: Optional[str] = None,
) -> list[Any]: ...
async def count_filtered(
name: str,
query_json: str,
query_ir_json: str,
tx_id: Optional[str] = None,
using: Optional[str] = None,
) -> int: ...
Expand Down Expand Up @@ -102,13 +102,13 @@ async def delete_record(
) -> bool: ...
async def delete_filtered(
name: str,
query_json: str,
query_ir_json: str,
tx_id: Optional[str] = None,
using: Optional[str] = None,
) -> int: ...
async def update_filtered(
name: str,
query_json: str,
query_ir_json: str,
update_json: str,
tx_id: Optional[str] = None,
using: Optional[str] = None,
Expand Down
7 changes: 4 additions & 3 deletions src/ferro/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -440,8 +440,9 @@ def where(cls, node: "QueryNode | Predicate[Self]") -> Query[Self]:
A prebuilt :class:`QueryNode` is also accepted, built either with
:func:`ferro.query.col` (the type-safe escape hatch that preserves
operator shape) or with operator syntax on class attributes. The
bare operator form (``User.where(User.age >= 18)``) is planned for
deprecation in a future release and does not type-check statically:
bare operator form (``User.where(User.age >= 18)``) is deprecated and
on the v0.13.0 removal track. It does not
type-check statically:
the class attribute types as the field type, so the comparison
resolves to ``bool``, not ``QueryNode``. See
``docs/concepts/query-typing.md`` for the trade-offs between the
Expand All @@ -455,7 +456,7 @@ def where(cls, node: "QueryNode | Predicate[Self]") -> Query[Self]:

Examples:
>>> q1 = User.where(lambda t: t.archived == False) # noqa: E712
>>> q2 = User.where(User.id == 1)
>>> q2 = User.where(lambda t: t.id == 1)
>>> isinstance(q1, Query) and isinstance(q2, Query)
True
"""
Expand Down
Loading
Loading