From e064c53134ecd376d5881b8cbe5c4c94bbd65821 Mon Sep 17 00:00:00 2001 From: Taylor Date: Fri, 19 Jun 2026 17:50:28 -0400 Subject: [PATCH 1/3] feat: complete Phase 3 QueryIR cutover and deprecation track Cut query execution over to QueryIR envelopes end-to-end so the runtime no longer depends on legacy QueryDef JSON on core paths. Add operator-style predicate deprecation warnings with migration docs and synchronize Phase 3 roadmap/migration evidence to keep issue and roadmap state aligned. Co-authored-by: Cursor --- crates/ferro-schema-ir/src/lib.rs | 9 +- docs/examples/predicates.py | 1 + docs/pages/api/queries.md | 2 +- docs/pages/concepts/query-typing.md | 8 +- docs/pages/guide/queries.md | 4 +- docs/plans/2026-06-19-001-ir-first-roadmap.md | 21 ++- docs/plans/ir-first-migration-guide.md | 6 +- src/ferro/_core.pyi | 12 +- src/ferro/models.py | 7 +- src/ferro/query/builder.py | 74 +++++++--- src/ferro/query/nodes.py | 99 +++++++++++-- src/migrate.rs | 17 ++- src/operations.rs | 134 +++++++++++++----- src/query.rs | 6 +- tests/test_query_builder.py | 44 ++++-- tests/test_query_typing.py | 26 ++-- tests/test_shadow_reports.py | 20 ++- tests/test_static_contracts.py | 4 +- 18 files changed, 373 insertions(+), 121 deletions(-) diff --git a/crates/ferro-schema-ir/src/lib.rs b/crates/ferro-schema-ir/src/lib.rs index b0c13a6..0d81128 100644 --- a/crates/ferro-schema-ir/src/lib.rs +++ b/crates/ferro-schema-ir/src/lib.rs @@ -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 @@ -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 @@ -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 diff --git a/docs/examples/predicates.py b/docs/examples/predicates.py index e474490..0982df1 100644 --- a/docs/examples/predicates.py +++ b/docs/examples/predicates.py @@ -33,6 +33,7 @@ async def main() -> None: assert len(adults) == 3 # --8<-- [start:operator-style] + # Deprecated path (planned removal: Phase 7 / next major release). adults = await User.where(User.age >= 18).all() # --8<-- [end:operator-style] assert len(adults) == 3 diff --git a/docs/pages/api/queries.md b/docs/pages/api/queries.md index e4941d8..833fb35 100644 --- a/docs/pages/api/queries.md +++ b/docs/pages/api/queries.md @@ -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 Phase 7 removal. ::: ferro.query.builder.Query diff --git a/docs/pages/concepts/query-typing.md b/docs/pages/concepts/query-typing.md index e5d2bd9..273d0f9 100644 --- a/docs/pages/concepts/query-typing.md +++ b/docs/pages/concepts/query-typing.md @@ -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) @@ -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 Phase 7 removal track (next major 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. ## When to Use Which @@ -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 diff --git a/docs/pages/guide/queries.md b/docs/pages/guide/queries.md index 934a45f..41a5d05 100644 --- a/docs/pages/guide/queries.md +++ b/docs/pages/guide/queries.md @@ -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 Phase 7 removal track (next major 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. 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. diff --git a/docs/plans/2026-06-19-001-ir-first-roadmap.md b/docs/plans/2026-06-19-001-ir-first-roadmap.md index 75d04bd..59932d1 100644 --- a/docs/plans/2026-06-19-001-ir-first-roadmap.md +++ b/docs/plans/2026-06-19-001-ir-first-roadmap.md @@ -253,7 +253,7 @@ Issue references: ### Phase 3 - QueryIR cutover -Status: `Not started` +Status: `In progress` Issue references: @@ -264,13 +264,21 @@ 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` +- 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` --- @@ -497,6 +505,7 @@ 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. ## Immediate next actions diff --git a/docs/plans/ir-first-migration-guide.md b/docs/plans/ir-first-migration-guide.md index 76ed0a5..e3f3f3f 100644 --- a/docs/plans/ir-first-migration-guide.md +++ b/docs/plans/ir-first-migration-guide.md @@ -53,7 +53,11 @@ 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 (Phase 7 / next major release) | +| [#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 4 diff --git a/src/ferro/_core.pyi b/src/ferro/_core.pyi index 45ce6b0..64c1856 100644 --- a/src/ferro/_core.pyi +++ b/src/ferro/_core.pyi @@ -56,9 +56,9 @@ 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( @@ -66,13 +66,13 @@ async def fetch_all( ) -> 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: ... @@ -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, diff --git a/src/ferro/models.py b/src/ferro/models.py index 4affc9a..bff362c 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -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 Phase 7 removal track (next major release). 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 @@ -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 """ diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index 3243e47..7e20421 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -1,6 +1,6 @@ -"""Build fluent query objects that serialize filter definitions for the Rust core""" +"""Build fluent query objects that serialize QueryIR payloads for the Rust core.""" -import json +import warnings from typing import TYPE_CHECKING, Any, Generic, Type, TypeVar, overload from .._core import ( @@ -21,9 +21,41 @@ E = TypeVar("E") -def _query_def_to_json(query_def: dict[str, Any]) -> str: - """Serialize query definitions while preserving typed values in live Query state.""" - return json.dumps(_serialize_query_value(query_def)) +try: + from warnings import deprecated as _warnings_deprecated +except ImportError: + + def _warnings_deprecated(message: str, **_: Any): + def _decorate(func): + def _wrapped(*args, **kwargs): + warnings.warn(message, DeprecationWarning, stacklevel=3) + return func(*args, **kwargs) + + return _wrapped + + return _decorate + + +def _query_ir_payload_to_json(query_payload: dict[str, Any]) -> str: + """Serialize a QueryIR payload into a versioned IR envelope JSON string.""" + import json + + return json.dumps( + { + "ir_kind": "query", + "ir_version": 1, + "payload": _serialize_query_value(query_payload), + } + ) + + +@_warnings_deprecated( + "Operator predicate style (Model.field OP value) is deprecated; use lambda " + "predicates (`where(lambda t: ...)`) or col(Model.field) instead. Planned " + "removal: Phase 7 / next major release." +) +def _deprecated_operator_query_node(node: QueryNode) -> QueryNode: + return node def _resolve_where_node(node: Any) -> QueryNode: @@ -34,6 +66,8 @@ def _resolve_where_node(node: Any) -> QueryNode: a ``QueryNode`` (the lambda path). """ if isinstance(node, QueryNode): + if node.uses_operator_style(): + return _deprecated_operator_query_node(node) return node if callable(node): result = node(QueryProxy()) @@ -118,8 +152,9 @@ def where(self, node: "QueryNode | Predicate[T]") -> "Query[T]": :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 Phase 7 removal track (next major release). It does not + type-check statically: the class attribute types as the field type, so the comparison resolves to ``bool``, not ``QueryNode``. @@ -135,7 +170,7 @@ def where(self, node: "QueryNode | Predicate[T]") -> "Query[T]": 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 """ @@ -216,7 +251,7 @@ async def all(self) -> list[T]: """ query_def = { "model_name": self.model_cls.__name__, - "where_clause": [node.to_dict() for node in self.where_clause], + "where": [node.to_ir_dict() for node in self.where_clause], "order_by": self.order_by_clause, "limit": self._limit, "offset": self._offset, @@ -224,7 +259,7 @@ async def all(self) -> list[T]: } tx_id, using = self._transaction_or_using() results = await fetch_filtered( - self.model_cls, _query_def_to_json(query_def), tx_id, using + self.model_cls, _query_ir_payload_to_json(query_def), tx_id, using ) for instance in results: if hasattr(self.model_cls, "_fix_types"): @@ -244,12 +279,15 @@ async def count(self) -> int: """ query_def = { "model_name": self.model_cls.__name__, - "where_clause": [node.to_dict() for node in self.where_clause], + "where": [node.to_ir_dict() for node in self.where_clause], + "order_by": [], + "limit": None, + "offset": None, "m2m": self._m2m_context, } tx_id, using = self._transaction_or_using() return await count_filtered( - self.model_cls.__name__, _query_def_to_json(query_def), tx_id, using + self.model_cls.__name__, _query_ir_payload_to_json(query_def), tx_id, using ) async def update(self, **fields) -> int: @@ -268,9 +306,11 @@ async def update(self, **fields) -> int: """ query_def = { "model_name": self.model_cls.__name__, - "where_clause": [node.to_dict() for node in self.where_clause], + "where": [node.to_ir_dict() for node in self.where_clause], + "order_by": [], "limit": self._limit, "offset": self._offset, + "m2m": None, } from pydantic_core import to_json @@ -278,7 +318,7 @@ async def update(self, **fields) -> int: # Use pydantic_core.to_json to handle Decimals, UUIDs, etc. in kwargs return await update_filtered( self.model_cls.__name__, - _query_def_to_json(query_def), + _query_ir_payload_to_json(query_def), to_json(fields).decode(), tx_id, using, @@ -316,13 +356,15 @@ async def delete(self) -> int: """ query_def = { "model_name": self.model_cls.__name__, - "where_clause": [node.to_dict() for node in self.where_clause], + "where": [node.to_ir_dict() for node in self.where_clause], + "order_by": [], "limit": self._limit, "offset": self._offset, + "m2m": None, } tx_id, using = self._transaction_or_using() return await delete_filtered( - self.model_cls.__name__, _query_def_to_json(query_def), tx_id, using + self.model_cls.__name__, _query_ir_payload_to_json(query_def), tx_id, using ) async def exists(self) -> bool: diff --git a/src/ferro/query/nodes.py b/src/ferro/query/nodes.py index bb57a78..2823270 100644 --- a/src/ferro/query/nodes.py +++ b/src/ferro/query/nodes.py @@ -36,6 +36,7 @@ def __init__( left: "QueryNode | None" = None, right: "QueryNode | None" = None, is_compound: bool = False, + predicate_style: str | None = None, ): """Initialize a query expression node @@ -53,6 +54,7 @@ def __init__( self.left = left self.right = right self.is_compound = is_compound + self.predicate_style = predicate_style def __or__(self, other: "QueryNode") -> "QueryNode": """Combine two nodes with logical OR @@ -70,7 +72,15 @@ def __or__(self, other: "QueryNode") -> "QueryNode": """ if not isinstance(other, QueryNode): return NotImplemented - return QueryNode(left=self, operator="OR", right=other, is_compound=True) + return QueryNode( + left=self, + operator="OR", + right=other, + is_compound=True, + predicate_style="operator" + if self.uses_operator_style() or other.uses_operator_style() + else self.predicate_style or other.predicate_style, + ) def __and__(self, other: "QueryNode") -> "QueryNode": """Combine two nodes with logical AND @@ -88,7 +98,15 @@ def __and__(self, other: "QueryNode") -> "QueryNode": """ if not isinstance(other, QueryNode): return NotImplemented - return QueryNode(left=self, operator="AND", right=other, is_compound=True) + return QueryNode( + left=self, + operator="AND", + right=other, + is_compound=True, + predicate_style="operator" + if self.uses_operator_style() or other.uses_operator_style() + else self.predicate_style or other.predicate_style, + ) def to_dict(self) -> dict[str, Any]: """Serialize the query node tree into a JSON-friendly dictionary @@ -116,6 +134,30 @@ def to_dict(self) -> dict[str, Any]: "is_compound": True, } + def to_ir_dict(self) -> dict[str, Any]: + """Serialize the query node tree into a QueryIR payload shape.""" + if not self.is_compound: + serialized = _serialize_query_value(self.value) + return { + "node_kind": "leaf", + "column": self.column, + "operator": self.operator, + "value": {"kind": _query_value_kind(serialized), "value": serialized}, + } + return { + "node_kind": "compound", + "operator": self.operator, + "left": self.left.to_ir_dict() if self.left else None, + "right": self.right.to_ir_dict() if self.right else None, + } + + def uses_operator_style(self) -> bool: + if self.is_compound: + return (self.left.uses_operator_style() if self.left else False) or ( + self.right.uses_operator_style() if self.right else False + ) + return self.predicate_style == "operator" + def __repr__(self): """Return a developer-friendly representation of the node""" if not self.is_compound: @@ -138,6 +180,24 @@ def _serialize_query_value(value: Any) -> Any: return value +def _query_value_kind(value: Any) -> str: + if value is None: + return "null" + if isinstance(value, bool): + return "bool" + if isinstance(value, int): + return "int" + if isinstance(value, float): + return "float" + if isinstance(value, str): + return "string" + if isinstance(value, list): + return "list" + if isinstance(value, dict): + return "object" + return "unknown" + + class FieldProxy(Generic[TField]): """Capture field comparisons and build query nodes @@ -154,41 +214,50 @@ class FieldProxy(Generic[TField]): True """ - def __init__(self, column: str): + def __init__(self, column: str, predicate_style: str = "operator"): """Initialize a field proxy for a specific column Args: column: Database column name to target in expressions. """ self.column = column + self.predicate_style = predicate_style def __eq__( # type: ignore[override] # ty: ignore[invalid-method-override] self, other: "TField | FieldProxy[TField]" ) -> QueryNode: """Build an equality comparison node""" - return QueryNode(self.column, "==", other) + return QueryNode( + self.column, "==", other, predicate_style=self.predicate_style + ) def __ne__( # type: ignore[override] # ty: ignore[invalid-method-override] self, other: "TField | FieldProxy[TField]" ) -> QueryNode: """Build an inequality comparison node""" - return QueryNode(self.column, "!=", other) + return QueryNode( + self.column, "!=", other, predicate_style=self.predicate_style + ) def __lt__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a less-than comparison node""" - return QueryNode(self.column, "<", other) + return QueryNode(self.column, "<", other, predicate_style=self.predicate_style) def __le__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a less-than-or-equal comparison node""" - return QueryNode(self.column, "<=", other) + return QueryNode( + self.column, "<=", other, predicate_style=self.predicate_style + ) def __gt__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a greater-than comparison node""" - return QueryNode(self.column, ">", other) + return QueryNode(self.column, ">", other, predicate_style=self.predicate_style) def __ge__(self, other: "TField | FieldProxy[TField]") -> QueryNode: """Build a greater-than-or-equal comparison node""" - return QueryNode(self.column, ">=", other) + return QueryNode( + self.column, ">=", other, predicate_style=self.predicate_style + ) def in_( self, other: "list[TField] | tuple[TField, ...] | set[TField]" @@ -213,7 +282,9 @@ def in_( raise TypeError( f"The 'in_' operator expects a list, tuple, or set, got {type(other).__name__}" ) - return QueryNode(self.column, "IN", list(other)) + return QueryNode( + self.column, "IN", list(other), predicate_style=self.predicate_style + ) def like(self: "FieldProxy[str]", pattern: str) -> QueryNode: """Build a ``LIKE`` comparison node @@ -233,7 +304,9 @@ def like(self: "FieldProxy[str]", pattern: str) -> QueryNode: >>> email_filter.operator 'LIKE' """ - return QueryNode(self.column, "LIKE", pattern) + return QueryNode( + self.column, "LIKE", pattern, predicate_style=self.predicate_style + ) def __lshift__( self, other: "list[TField] | tuple[TField, ...] | set[TField]" @@ -292,7 +365,7 @@ def col(value: TField) -> "FieldProxy[TField]": raise TypeError( f"col() expects a model column reference (FieldProxy), got {type(value).__name__}" ) - return value # type: ignore[return-value] + return FieldProxy(value.column, predicate_style="col") # type: ignore[return-value] class QueryProxy(Generic[TModel]): @@ -319,7 +392,7 @@ class QueryProxy(Generic[TModel]): def __getattr__(self, name: str) -> "FieldProxy[Any]": """Return a fresh ``FieldProxy`` for any attribute name.""" - return FieldProxy(name) + return FieldProxy(name, predicate_style="lambda") Predicate: TypeAlias = Callable[[QueryProxy[TModel]], QueryNode] diff --git a/src/migrate.rs b/src/migrate.rs index dc5c80b..72cf899 100644 --- a/src/migrate.rs +++ b/src/migrate.rs @@ -312,14 +312,20 @@ fn shadow_compare_migration_plan( backend: SqlDialect, opts: MigrateOptions, ) -> Result<(), String> { - let legacy = - plan_table_migration(table_lower, schema, live, backend, opts).map_err(|e| e.to_string())?; + let legacy = plan_table_migration(table_lower, schema, live, backend, opts) + .map_err(|e| e.to_string())?; let schema_roundtrip: serde_json::Value = serde_json::from_str(&serde_json::to_string(schema).map_err(|e| e.to_string())?) .map_err(|e| e.to_string())?; let live_roundtrip = live.to_vec(); - let shadow = plan_table_migration(table_lower, &schema_roundtrip, &live_roundtrip, backend, opts) - .map_err(|e| e.to_string())?; + let shadow = plan_table_migration( + table_lower, + &schema_roundtrip, + &live_roundtrip, + backend, + opts, + ) + .map_err(|e| e.to_string())?; if legacy.statements == shadow.statements && legacy.drop_columns == shadow.drop_columns && legacy.warnings == shadow.warnings @@ -622,7 +628,8 @@ pub async fn internal_migrate(engine: Arc, opts: MigrateOptions) - let mut plan = plan_table_migration(&table_lower, &schema, &live, backend, opts)?; if engine.is_shadow_runtime_enabled() - && let Err(diff) = shadow_compare_migration_plan(&table_lower, &schema, &live, backend, opts) + && let Err(diff) = + shadow_compare_migration_plan(&table_lower, &schema, &live, backend, opts) { crate::log_debug(format!("⚠️ Ferro shadow runtime mismatch: {diff}")); if std::env::var("FERRO_SHADOW_RUNTIME_STRICT") diff --git a/src/operations.rs b/src/operations.rs index 70a3c9e..2c7641e 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -11,12 +11,13 @@ use crate::state::{ IDENTITY_MAP, MODEL_REGISTRY, RustValue, SqlDialect, TRANSACTION_REGISTRY, TransactionConnection, TransactionHandle, connection_for_route, engine_for_connection, }; +use ferro_schema_ir::{IrEnvelope, QueryIrPayload}; use pyo3::prelude::*; use sea_query::{ Alias, Expr, Iden, InsertStatement, OnConflict, Order, PostgresQueryBuilder, Query, SimpleExpr, SqliteQueryBuilder, UpdateStatement, Value as SeaValue, }; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; @@ -64,6 +65,33 @@ fn active_connection_for_route(using: Option) -> PyResult<(String, Arc PyResult { + let envelope: QueryIrEnvelope = serde_json::from_str(query_ir_json).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Invalid QueryIR JSON: {}", e)) + })?; + if envelope.ir_kind != "query" { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid QueryIR envelope kind {:?}; expected \"query\"", + envelope.ir_kind + ))); + } + if envelope.ir_version != 1 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unsupported QueryIR version {}; expected 1", + envelope.ir_version + ))); + } + query_def_from_ir_payload(envelope.payload) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Invalid QueryIR: {e}"))) +} + /// Initialize Pydantic v2 slots that `BaseModel.__init__` normally sets, after zero-copy /// hydration (`__new__` + `__dict__` population). /// @@ -1608,29 +1636,27 @@ pub fn save_bulk_records( }) } -/// Fetches records for a given model class based on a JSON-defined query. +/// Fetches records for a given model class based on a QueryIR-defined query. /// /// Args: /// cls (PyAny): The Python model class. -/// query_json (str): The serialized QueryDef JSON. +/// query_ir_json (str): The serialized QueryIR envelope JSON. /// /// Returns: /// list[PyAny]: A list of hydrated model instances. #[pyfunction] -#[pyo3(signature = (cls, query_json, tx_id=None, using=None))] +#[pyo3(signature = (cls, query_ir_json, tx_id=None, using=None))] pub fn fetch_filtered<'py>( py: Python<'py>, cls: Bound<'py, PyAny>, - query_json: String, + query_ir_json: String, tx_id: Option, using: Option, ) -> PyResult> { let name = cls.getattr("__name__")?.extract::()?; let cls_py = cls.unbind(); - let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) - })?; + let mut query_def = query_def_from_ir_json(&query_ir_json)?; pyo3_async_runtimes::tokio::future_into_py(py, async move { let (connection_name, engine, tx_conn, backend) = active_route_for_operation(tx_id, using)?; @@ -1717,7 +1743,12 @@ pub fn fetch_filtered<'py>( let (s, values) = sea_query_build_for_backend!(select, backend); (s, values, pk, schema.clone()) }; - maybe_compare_shadow_query_artifacts(&engine, "fetch_filtered", &query_def, &bind_values.0)?; + maybe_compare_shadow_query_artifacts( + &engine, + "fetch_filtered", + &query_def, + &bind_values.0, + )?; let parsed_data = match tx_conn { Some(conn_arc) => { @@ -1804,17 +1835,15 @@ pub fn fetch_filtered<'py>( /// Returns the number of records matching a filtered query. #[pyfunction] -#[pyo3(signature = (name, query_json, tx_id=None, using=None))] +#[pyo3(signature = (name, query_ir_json, tx_id=None, using=None))] pub fn count_filtered( py: Python<'_>, name: String, - query_json: String, + query_ir_json: String, tx_id: Option, using: Option, ) -> PyResult> { - let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) - })?; + let mut query_def = query_def_from_ir_json(&query_ir_json)?; pyo3_async_runtimes::tokio::future_into_py(py, async move { let (_, engine, tx_conn, backend) = active_route_for_operation(tx_id, using)?; @@ -1876,7 +1905,12 @@ pub fn count_filtered( select.cond_where(query_def.to_condition_for_backend(backend)); sea_query_build_for_backend!(select, backend) }; - maybe_compare_shadow_query_artifacts(&engine, "count_filtered", &query_def, &bind_values.0)?; + maybe_compare_shadow_query_artifacts( + &engine, + "count_filtered", + &query_def, + &bind_values.0, + )?; let engine_bind_values = engine_bind_values_from_sea(&bind_values.0); let count = match tx_conn { @@ -2009,17 +2043,15 @@ pub fn delete_record( /// Deletes records matching a filtered query. #[pyfunction] -#[pyo3(signature = (name, query_json, tx_id=None, using=None))] +#[pyo3(signature = (name, query_ir_json, tx_id=None, using=None))] pub fn delete_filtered( py: Python<'_>, name: String, - query_json: String, + query_ir_json: String, tx_id: Option, using: Option, ) -> PyResult> { - let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) - })?; + let mut query_def = query_def_from_ir_json(&query_ir_json)?; pyo3_async_runtimes::tokio::future_into_py(py, async move { let (_, engine, tx_conn, backend) = active_route_for_operation(tx_id, using)?; @@ -2035,7 +2067,12 @@ pub fn delete_filtered( .cond_where(query_def.to_condition_for_backend(backend)); sea_query_build_for_backend!(delete, backend) }; - maybe_compare_shadow_query_artifacts(&engine, "delete_filtered", &query_def, &bind_values.0)?; + maybe_compare_shadow_query_artifacts( + &engine, + "delete_filtered", + &query_def, + &bind_values.0, + )?; let rows_affected = execute_statement_with_optional_tx(&engine, tx_conn, &sql, &bind_values.0) @@ -2055,18 +2092,16 @@ pub fn delete_filtered( /// Updates records matching a filtered query with provided values. #[pyfunction] -#[pyo3(signature = (name, query_json, update_json, tx_id=None, using=None))] +#[pyo3(signature = (name, query_ir_json, update_json, tx_id=None, using=None))] pub fn update_filtered( py: Python<'_>, name: String, - query_json: String, + query_ir_json: String, update_json: String, tx_id: Option, using: Option, ) -> PyResult> { - let mut query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) - })?; + let mut query_def = query_def_from_ir_json(&query_ir_json)?; let update_values: serde_json::Value = serde_json::from_str(&update_json).map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("Invalid update JSON: {}", e)) @@ -2115,7 +2150,12 @@ pub fn update_filtered( } sea_query_build_for_backend!(update, backend) }; - maybe_compare_shadow_query_artifacts(&engine, "update_filtered", &query_def, &bind_values.0)?; + maybe_compare_shadow_query_artifacts( + &engine, + "update_filtered", + &query_def, + &bind_values.0, + )?; let rows_affected = execute_statement_with_optional_tx(&engine, tx_conn, &sql, &bind_values.0) @@ -2522,9 +2562,9 @@ pub fn raw_fetch_one<'py>( #[pyfunction] #[pyo3(name = "_shadow_compare_query_plan_for_test")] -#[pyo3(signature = (query_json, dialect, operation="select".to_string()))] +#[pyo3(signature = (query_payload_json, dialect, operation="select".to_string()))] pub fn _shadow_compare_query_plan_for_test( - query_json: String, + query_payload_json: String, dialect: String, operation: String, ) -> PyResult { @@ -2538,12 +2578,35 @@ pub fn _shadow_compare_query_plan_for_test( ))); } }; - let query_def: QueryDef = serde_json::from_str(&query_json).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Invalid query JSON: {}", e)) - })?; + let query_def: QueryDef = if let Ok(ir_envelope) = + serde_json::from_str::>(&query_payload_json) + { + if ir_envelope.ir_kind != "query" { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid QueryIR envelope kind {:?}; expected \"query\"", + ir_envelope.ir_kind + ))); + } + if ir_envelope.ir_version != 1 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unsupported QueryIR version {}; expected 1", + ir_envelope.ir_version + ))); + } + query_def_from_ir_payload(ir_envelope.payload).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Invalid QueryIR payload: {e}")) + })? + } else { + serde_json::from_str(&query_payload_json).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("Invalid query payload JSON: {}", e)) + })? + }; let mut select_legacy = Query::select(); select_legacy.from(Alias::new(query_def.model_name.to_lowercase())); - select_legacy.column((Alias::new(query_def.model_name.to_lowercase()), sea_query::Asterisk)); + select_legacy.column(( + Alias::new(query_def.model_name.to_lowercase()), + sea_query::Asterisk, + )); select_legacy.cond_where(query_def.to_condition_for_backend(backend)); if let Some(ref orders) = query_def.order_by { for order in orders { @@ -2576,8 +2639,9 @@ pub fn _shadow_compare_query_plan_for_test( "artifact": shadow, }, }); - serde_json::to_string(&payload) - .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to encode JSON: {e}"))) + serde_json::to_string(&payload).map_err(|e| { + pyo3::exceptions::PyRuntimeError::new_err(format!("Failed to encode JSON: {e}")) + }) } #[cfg(test)] diff --git a/src/query.rs b/src/query.rs index 8c6b12d..6bd979f 100644 --- a/src/query.rs +++ b/src/query.rs @@ -340,7 +340,11 @@ pub fn query_def_from_ir_payload(payload: QueryIrPayload) -> Result=" + assert payload["value"] == {"kind": "int", "value": 18} + + def test_field_proxy_operator_overloading(): """ Test that accessing a field on the Model class returns a FieldProxy @@ -108,7 +122,8 @@ class QueryUser(Model): id: int = Field(json_schema_extra={"primary_key": True}) age: int - query = QueryUser.where(QueryUser.age >= 21) + with pytest.deprecated_call(match="Operator predicate style"): + query = QueryUser.where(QueryUser.age >= 21) assert isinstance(query, Query) assert len(query.where_clause) == 1 @@ -126,7 +141,8 @@ class QueryUser(Model): id: int = Field(json_schema_extra={"primary_key": True}) age: int - query = QueryUser.where(QueryUser.age >= 18).limit(10).offset(5) + with pytest.deprecated_call(match="Operator predicate style"): + query = QueryUser.where(QueryUser.age >= 18).limit(10).offset(5) assert query._limit == 10 assert query._offset == 5 @@ -157,6 +173,18 @@ class QueryUser(Model): _ = QueryUser.username << "not a list" +def test_col_style_where_does_not_emit_deprecation_warning(): + class ColUser(Model): + id: int = Field(json_schema_extra={"primary_key": True}) + age: int + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always", DeprecationWarning) + query = ColUser.where(col(ColUser.age) >= 21) + assert isinstance(query, Query) + assert not [w for w in captured if issubclass(w.category, DeprecationWarning)] + + @pytest.mark.asyncio async def test_query_execution(db_url): """ diff --git a/tests/test_query_typing.py b/tests/test_query_typing.py index 15dbf78..097f34e 100644 --- a/tests/test_query_typing.py +++ b/tests/test_query_typing.py @@ -43,14 +43,16 @@ def _clear_state(): class TestColWrapper: - def test_col_returns_same_field_proxy(self): - """col(FieldProxy) is identity at runtime.""" + def test_col_returns_field_proxy_with_same_column(self): + """col(FieldProxy) returns a typed query proxy for the same column.""" class ColUser(Model): id: Annotated[int | None, FerroField(primary_key=True)] = None archived: bool = False - assert col(ColUser.archived) is ColUser.archived # type: ignore[arg-type] + wrapped = col(ColUser.archived) # type: ignore[arg-type] + assert isinstance(wrapped, FieldProxy) + assert wrapped.column == "archived" def test_col_eq_builds_query_node(self): """col(field) == value builds a QueryNode with the right shape.""" @@ -193,7 +195,10 @@ class OpUser(Model): await OpUser(id=1, email="a@b.com").save() await OpUser(id=2, email="c@d.com").save() - rows = await OpUser.where(OpUser.email == "a@b.com").all() # ty: ignore[no-matching-overload] + with pytest.deprecated_call(match="Operator predicate style"): + rows = await OpUser.where( + OpUser.email == "a@b.com" + ).all() # ty: ignore[no-matching-overload] assert len(rows) == 1 assert rows[0].email == "a@b.com" @@ -218,12 +223,13 @@ class MixUser(Model): await MixUser(id=1_001, role="admin", archived=True).save() await MixUser(id=2, role="user", archived=False).save() - rows = await ( - MixUser.where(MixUser.id == 1) # ty: ignore[no-matching-overload] - .where(col(MixUser.archived) == False) # noqa: E712 - .where(lambda t: t.role == "admin") - .all() - ) + with pytest.deprecated_call(match="Operator predicate style"): + rows = await ( + MixUser.where(MixUser.id == 1) # ty: ignore[no-matching-overload] + .where(col(MixUser.archived) == False) # noqa: E712 + .where(lambda t: t.role == "admin") + .all() + ) assert len(rows) == 1 assert rows[0].id == 1 diff --git a/tests/test_shadow_reports.py b/tests/test_shadow_reports.py index 18cdc80..fc6bf27 100644 --- a/tests/test_shadow_reports.py +++ b/tests/test_shadow_reports.py @@ -10,7 +10,7 @@ _render_migration_sql_for_test, _shadow_compare_query_plan_for_test, ) -from ferro.query.builder import _query_def_to_json +from ferro.query.builder import _query_ir_payload_to_json pytestmark = pytest.mark.backend_matrix @@ -25,12 +25,22 @@ def _report_for_backend(dialect: str) -> dict: "age": {"type": "integer"}, } } - query_json = _query_def_to_json( + query_json = _query_ir_payload_to_json( { "model_name": "ShadowUser", - "where_clause": [ - {"is_compound": False, "column": "age", "operator": ">=", "value": 18}, - {"is_compound": False, "column": "name", "operator": "LIKE", "value": "a%"}, + "where": [ + { + "node_kind": "leaf", + "column": "age", + "operator": ">=", + "value": {"kind": "int", "value": 18}, + }, + { + "node_kind": "leaf", + "column": "name", + "operator": "LIKE", + "value": {"kind": "string", "value": "a%"}, + }, ], "order_by": [{"column": "age", "direction": "desc"}], "limit": 5, diff --git a/tests/test_static_contracts.py b/tests/test_static_contracts.py index c598400..ed97ae6 100644 --- a/tests/test_static_contracts.py +++ b/tests/test_static_contracts.py @@ -1,8 +1,8 @@ from pathlib import Path -def test_query_methods_use_query_def_serializer_instead_of_raw_json_dumps(): +def test_query_methods_use_query_ir_serializer_instead_of_raw_json_dumps(): source = Path("src/ferro/query/builder.py").read_text(encoding="utf-8") - assert source.count("json.dumps(_serialize_query_value(query_def))") == 1 + assert source.count("def _query_ir_payload_to_json(") == 1 assert "json.dumps(query_def)" not in source From 577a820ed180d0107e7779cf14355fbcdef5d6fa Mon Sep 17 00:00:00 2001 From: Taylor Date: Fri, 19 Jun 2026 18:00:34 -0400 Subject: [PATCH 2/3] docs: move deprecation cutoff target to v0.13.0 Clarify that Phase 7 is the public upgrade release with compatibility support and move hard deprecated-path removal to v0.13.0 (Phase 8). Update warning text, query docs, and migration/roadmap guidance to keep policy and user messaging synchronized. Co-authored-by: Cursor --- docs/examples/predicates.py | 2 +- docs/pages/api/queries.md | 2 +- docs/pages/concepts/query-typing.md | 2 +- docs/pages/guide/queries.md | 2 +- docs/plans/2026-06-19-001-ir-first-roadmap.md | 36 ++++++++++++++++--- docs/plans/ir-first-migration-guide.md | 6 +++- src/ferro/models.py | 2 +- src/ferro/query/builder.py | 4 +-- 8 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/examples/predicates.py b/docs/examples/predicates.py index 0982df1..d0cd1fa 100644 --- a/docs/examples/predicates.py +++ b/docs/examples/predicates.py @@ -33,7 +33,7 @@ async def main() -> None: assert len(adults) == 3 # --8<-- [start:operator-style] - # Deprecated path (planned removal: Phase 7 / next major release). + # Deprecated path (planned removal: v0.13.0). adults = await User.where(User.age >= 18).all() # --8<-- [end:operator-style] assert len(adults) == 3 diff --git a/docs/pages/api/queries.md b/docs/pages/api/queries.md index 833fb35..742f436 100644 --- a/docs/pages/api/queries.md +++ b/docs/pages/api/queries.md @@ -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 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 Phase 7 removal. +`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 diff --git a/docs/pages/concepts/query-typing.md b/docs/pages/concepts/query-typing.md index 273d0f9..e4800c8 100644 --- a/docs/pages/concepts/query-typing.md +++ b/docs/pages/concepts/query-typing.md @@ -41,7 +41,7 @@ rows = await User.where(User.email.like("%@example.com")).all() ``` !!! warning "Operator style is deprecated" - The operator style is compatible today but on the Phase 7 removal track (next major 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. + 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 diff --git a/docs/pages/guide/queries.md b/docs/pages/guide/queries.md index 41a5d05..958af2c 100644 --- a/docs/pages/guide/queries.md +++ b/docs/pages/guide/queries.md @@ -74,7 +74,7 @@ Both methods also exist on `Model.using("name")` for [named connections](connect ``` !!! warning "Operator style is deprecated" - The operator style is compatible today but on the Phase 7 removal track (next major 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. + 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. diff --git a/docs/plans/2026-06-19-001-ir-first-roadmap.md b/docs/plans/2026-06-19-001-ir-first-roadmap.md index 59932d1..12d9a42 100644 --- a/docs/plans/2026-06-19-001-ir-first-roadmap.md +++ b/docs/plans/2026-06-19-001-ir-first-roadmap.md @@ -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 @@ -351,7 +351,7 @@ Issue references: --- -### Phase 7 - Major version release and shim removal +### Phase 7 - Major-version public release with compatibility window Status: `Not started` @@ -361,16 +361,41 @@ 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. - [ ] 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. +- [ ] 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 @@ -460,7 +485,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 @@ -506,6 +531,7 @@ Append updates as concise entries. - `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 diff --git a/docs/plans/ir-first-migration-guide.md b/docs/plans/ir-first-migration-guide.md index e3f3f3f..e66ae82 100644 --- a/docs/plans/ir-first-migration-guide.md +++ b/docs/plans/ir-first-migration-guide.md @@ -56,7 +56,7 @@ No user-facing runtime behavior changes expected. Shadow planning is internal-on | 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 (Phase 7 / next major release) | +| [#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 4 @@ -74,3 +74,7 @@ _TBD_ ### Phase 7 _TBD_ + +### Phase 8 + +_TBD_ diff --git a/src/ferro/models.py b/src/ferro/models.py index bff362c..f7c0ab4 100644 --- a/src/ferro/models.py +++ b/src/ferro/models.py @@ -441,7 +441,7 @@ def where(cls, node: "QueryNode | Predicate[Self]") -> Query[Self]: :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 deprecated and - on the Phase 7 removal track (next major release). It does not + 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 diff --git a/src/ferro/query/builder.py b/src/ferro/query/builder.py index 7e20421..11ed11a 100644 --- a/src/ferro/query/builder.py +++ b/src/ferro/query/builder.py @@ -52,7 +52,7 @@ def _query_ir_payload_to_json(query_payload: dict[str, Any]) -> str: @_warnings_deprecated( "Operator predicate style (Model.field OP value) is deprecated; use lambda " "predicates (`where(lambda t: ...)`) or col(Model.field) instead. Planned " - "removal: Phase 7 / next major release." + "removal: v0.13.0." ) def _deprecated_operator_query_node(node: QueryNode) -> QueryNode: return node @@ -153,7 +153,7 @@ def where(self, node: "QueryNode | Predicate[T]") -> "Query[T]": :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 deprecated and - on the Phase 7 removal track (next major release). It does not + 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``. From 65d25c81bfde063d3d19cd8debc99a5e5b26153c Mon Sep 17 00:00:00 2001 From: Taylor Date: Fri, 19 Jun 2026 18:05:00 -0400 Subject: [PATCH 3/3] test: tag deprecated operator-compat tests for v0.13.0 sunset Introduce a dedicated pytest marker for temporary operator-path compatibility tests, migrate low-risk incidental operator-style tests to lambda predicates, and update roadmap/migration docs to explicitly track inventory in Phase 7 and removal in Phase 8 (v0.13.0). Co-authored-by: Cursor --- docs/plans/2026-06-19-001-ir-first-roadmap.md | 3 ++ docs/plans/ir-first-migration-guide.md | 9 ++++ pyproject.toml | 3 ++ tests/test_documentation_features.py | 49 ++++++++++--------- tests/test_query_builder.py | 31 ++++++------ tests/test_query_typing.py | 4 ++ 6 files changed, 61 insertions(+), 38 deletions(-) diff --git a/docs/plans/2026-06-19-001-ir-first-roadmap.md b/docs/plans/2026-06-19-001-ir-first-roadmap.md index 12d9a42..3512104 100644 --- a/docs/plans/2026-06-19-001-ir-first-roadmap.md +++ b/docs/plans/2026-06-19-001-ir-first-roadmap.md @@ -276,6 +276,7 @@ Issue references: - 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` @@ -366,6 +367,7 @@ Issue references: **Deliverables** - [ ] 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. @@ -390,6 +392,7 @@ Issue references: **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. diff --git a/docs/plans/ir-first-migration-guide.md b/docs/plans/ir-first-migration-guide.md index e66ae82..8f822f9 100644 --- a/docs/plans/ir-first-migration-guide.md +++ b/docs/plans/ir-first-migration-guide.md @@ -59,6 +59,10 @@ No user-facing runtime behavior changes expected. Shadow planning is internal-on | [#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 _TBD_ @@ -78,3 +82,8 @@ _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`. diff --git a/pyproject.toml b/pyproject.toml index 03c0bbf..a179c3b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_documentation_features.py b/tests/test_documentation_features.py index 7bc4a51..954d806 100644 --- a/tests/test_documentation_features.py +++ b/tests/test_documentation_features.py @@ -264,7 +264,7 @@ async def test_refresh_method(db_url): user = await User.create(username="alice", email="alice@example.com") # Simulate external update - await User.where(User.id == user.id).update(email="updated@example.com") + await User.where(lambda t: t.id == user.id).update(email="updated@example.com") # Refresh instance await user.refresh() @@ -337,7 +337,7 @@ async def test_where_equality(db_url): await User.create(username="alice", email="alice@example.com", is_active=True) await User.create(username="bob", email="bob@example.com", is_active=False) - active_users = await User.where(User.is_active == True).all() + active_users = await User.where(lambda t: t.is_active == True).all() # noqa: E712 assert len(active_users) == 1 assert active_users[0].username == "alice" @@ -352,11 +352,11 @@ async def test_where_comparison_operators(db_url): ) # Greater than - expensive = await Product.where(Product.price > Decimal("20")).all() + expensive = await Product.where(lambda t: t.price > Decimal("20")).all() assert len(expensive) == 2 # 30 and 40 # Less than or equal - cheap = await Product.where(Product.price <= Decimal("20")).all() + cheap = await Product.where(lambda t: t.price <= Decimal("20")).all() assert len(cheap) == 3 # 0, 10, 20 @@ -368,7 +368,7 @@ async def test_where_like_operator(db_url): await User.create(username="bob", email="bob@yahoo.com") await User.create(username="charlie", email="charlie@gmail.com") - gmail_users = await User.where(User.email.like("%gmail.com")).all() + gmail_users = await User.where(lambda t: t.email.like("%gmail.com")).all() assert len(gmail_users) == 2 @@ -384,7 +384,7 @@ async def test_where_in_operator(db_url): # Use enum values instead of enum instances staff = await User.where( - User.role.in_([UserRole.ADMIN.value, UserRole.MODERATOR.value]) + lambda t: t.role.in_([UserRole.ADMIN.value, UserRole.MODERATOR.value]) ).all() assert len(staff) == 2 @@ -407,7 +407,7 @@ async def test_logical_and_operator(db_url): ) active_admins = await User.where( - (User.is_active == True) & (User.role == UserRole.ADMIN.value) + lambda t: (t.is_active == True) & (t.role == UserRole.ADMIN.value) # noqa: E712 ).all() assert len(active_admins) == 1 assert active_admins[0].username == "alice" @@ -424,7 +424,8 @@ async def test_logical_or_operator(db_url): ) staff = await User.where( - (User.role == UserRole.ADMIN.value) | (User.role == UserRole.MODERATOR.value) + lambda t: (t.role == UserRole.ADMIN.value) + | (t.role == UserRole.MODERATOR.value) ).all() assert len(staff) == 2 @@ -471,11 +472,11 @@ async def test_query_first(db_url): await connect(db_url, auto_migrate=True) await User.create(username="alice", email="alice@example.com") - user = await User.where(User.username == "alice").first() + user = await User.where(lambda t: t.username == "alice").first() assert user is not None assert user.username == "alice" - none_user = await User.where(User.username == "nonexistent").first() + none_user = await User.where(lambda t: t.username == "nonexistent").first() assert none_user is None @@ -487,7 +488,7 @@ async def test_query_count(db_url): await User.create(username="bob", email="bob@example.com", is_active=True) await User.create(username="charlie", email="charlie@example.com", is_active=False) - active_count = await User.where(User.is_active == True).count() + active_count = await User.where(lambda t: t.is_active == True).count() # noqa: E712 assert active_count == 2 @@ -497,10 +498,12 @@ async def test_query_exists(db_url): await connect(db_url, auto_migrate=True) await User.create(username="alice", email="alice@example.com", role=UserRole.ADMIN) - has_admin = await User.where(User.role == UserRole.ADMIN.value).exists() + has_admin = await User.where(lambda t: t.role == UserRole.ADMIN.value).exists() assert has_admin is True - has_moderator = await User.where(User.role == UserRole.MODERATOR.value).exists() + has_moderator = await User.where( + lambda t: t.role == UserRole.MODERATOR.value + ).exists() assert has_moderator is False @@ -511,10 +514,12 @@ async def test_query_update(db_url): await User.create(username="alice", email="alice@example.com", is_active=True) await User.create(username="bob", email="bob@example.com", is_active=True) - count = await User.where(User.is_active == True).update(is_active=False) + count = await User.where(lambda t: t.is_active == True).update( # noqa: E712 + is_active=False + ) assert count == 2 - active_users = await User.where(User.is_active == True).all() + active_users = await User.where(lambda t: t.is_active == True).all() # noqa: E712 assert len(active_users) == 0 @@ -525,7 +530,7 @@ async def test_query_delete(db_url): await User.create(username="alice", email="alice@example.com", is_active=True) await User.create(username="bob", email="bob@example.com", is_active=False) - count = await User.where(User.is_active == False).delete() + count = await User.where(lambda t: t.is_active == False).delete() # noqa: E712 assert count == 1 remaining = await User.all() @@ -588,7 +593,7 @@ async def test_reverse_relation_filtering(db_url): ) await Post.create(title="Draft", content="Content", author=author, published=False) - published = await author.posts.where(Post.published == True).all() + published = await author.posts.where(lambda t: t.published == True).all() # noqa: E712 assert len(published) == 1 assert published[0].title == "Published" @@ -604,7 +609,7 @@ async def test_shadow_field_access(db_url): assert post.author_id == author.id # Query by shadow field - posts = await Post.where(Post.author_id == author.id).all() + posts = await Post.where(lambda t: t.author_id == author.id).all() assert len(posts) == 1 @@ -796,15 +801,15 @@ async def test_tutorial_blog_example(db_url): comment2 = await Comment.create(text="Thanks for sharing", author=alice, post=post1) # Query: Find all published posts - published = await Post.where(Post.published == True).all() + published = await Post.where(lambda t: t.published == True).all() # noqa: E712 assert len(published) == 2 # Query: Find posts by author - alice_posts = await Post.where(Post.author_id == alice.id).all() + alice_posts = await Post.where(lambda t: t.author_id == alice.id).all() assert len(alice_posts) == 2 # Query: Get post with pattern matching - post = await Post.where(Post.title.like("%Fast%")).first() + post = await Post.where(lambda t: t.title.like("%Fast%")).first() assert post is not None assert post.title == "Why Ferro is Fast" @@ -820,7 +825,7 @@ async def test_tutorial_blog_example(db_url): draft.published = True await draft.save() - published_after = await Post.where(Post.published == True).all() + published_after = await Post.where(lambda t: t.published == True).all() # noqa: E712 assert len(published_after) == 3 diff --git a/tests/test_query_builder.py b/tests/test_query_builder.py index 2b685ba..ba55a20 100644 --- a/tests/test_query_builder.py +++ b/tests/test_query_builder.py @@ -113,6 +113,7 @@ class QueryUser(Model): assert expr.value == 18 +@pytest.mark.deprecated_operator_path def test_model_where_clause(): """ Test that Model.where() returns a Query object with the correct condition. @@ -132,6 +133,7 @@ class QueryUser(Model): assert query.where_clause[0].value == 21 +@pytest.mark.deprecated_operator_path def test_query_chaining_placeholders(): """ Test that Query object supports chaining (even if not yet executed). @@ -205,19 +207,19 @@ class FilterUser(Model): await FilterUser(id=3, username="alice", age=35).save() # 1. Test basic filter - results = await FilterUser.where(FilterUser.age >= 30).all() + results = await FilterUser.where(lambda t: t.age >= 30).all() assert len(results) == 2 assert {r.username for r in results} == {"taylor", "alice"} # 2. Test IN filter - results_in = await FilterUser.where(FilterUser.username << ["jeff", "alice"]).all() + results_in = await FilterUser.where(lambda t: t.username << ["jeff", "alice"]).all() assert len(results_in) == 2 assert {r.username for r in results_in} == {"jeff", "alice"} # 3. Test combined filters (Chaining) - results_chained = ( - await FilterUser.where(FilterUser.age < 35).where(FilterUser.age > 20).all() - ) + results_chained = await FilterUser.where(lambda t: t.age < 35).where( + lambda t: t.age > 20 + ).all() assert len(results_chained) == 2 assert {r.username for r in results_chained} == {"taylor", "jeff"} @@ -236,12 +238,12 @@ class FirstUser(Model): await FirstUser(id=1, username="taylor").save() # 1. Match found - user = await FirstUser.where(FirstUser.username == "taylor").first() + user = await FirstUser.where(lambda t: t.username == "taylor").first() assert user is not None assert user.username == "taylor" # 2. No match found - no_user = await FirstUser.where(FirstUser.username == "nonexistent").first() + no_user = await FirstUser.where(lambda t: t.username == "nonexistent").first() assert no_user is None @@ -263,7 +265,7 @@ class SafeUser(Model): # If not parameterized, this might return the user. # If parameterized, it should look for the literal string and return None. - result = await SafeUser.where(SafeUser.username == injection_string).first() + result = await SafeUser.where(lambda t: t.username == injection_string).first() assert result is None @@ -287,7 +289,7 @@ class LogicUser(Model): # 1. Test OR (|) # SQL: SELECT * FROM logicuser WHERE age < 30 OR username == 'alice' results_or = await LogicUser.where( - (LogicUser.age < 30) | (LogicUser.username == "alice") + lambda t: (t.age < 30) | (t.username == "alice") ).all() assert len(results_or) == 2 assert {r.username for r in results_or} == {"jeff", "alice"} @@ -295,7 +297,7 @@ class LogicUser(Model): # 2. Test nested AND (&) within WHERE # SQL: SELECT * FROM logicuser WHERE (age > 20) AND (username != 'taylor') results_and = await LogicUser.where( - (LogicUser.age > 20) & (LogicUser.username != "taylor") + lambda t: (t.age > 20) & (t.username != "taylor") ).all() assert len(results_and) == 2 assert {r.username for r in results_and} == {"jeff", "alice"} @@ -304,8 +306,7 @@ class LogicUser(Model): # SQL: SELECT * FROM logicuser WHERE (username == 'taylor' OR username == 'jeff') AND age > 28 # Only taylor (30) matches both. jeff (25) is under 28. results_complex = await LogicUser.where( - ((LogicUser.username == "taylor") | (LogicUser.username == "jeff")) - & (LogicUser.age > 28) + lambda t: ((t.username == "taylor") | (t.username == "jeff")) & (t.age > 28) ).all() assert len(results_complex) == 1 assert results_complex[0].username == "taylor" @@ -328,10 +329,8 @@ class LogicUser(Model): await LogicUser(id=3, username="alice", age=35).save() # (A OR B) AND (C) - query = LogicUser.where( - (LogicUser.username == "jeff") | (LogicUser.username == "alice") - ) - query = query.where(LogicUser.age > 30) + query = LogicUser.where(lambda t: (t.username == "jeff") | (t.username == "alice")) + query = query.where(lambda t: t.age > 30) results = await query.all() assert len(results) == 1 diff --git a/tests/test_query_typing.py b/tests/test_query_typing.py index 097f34e..744bbeb 100644 --- a/tests/test_query_typing.py +++ b/tests/test_query_typing.py @@ -176,6 +176,8 @@ class LamUser(Model): class TestOperatorPathUnchanged: + pytestmark = pytest.mark.deprecated_operator_path + @pytest.mark.asyncio async def test_operator_eq_still_works(self, db_url): """The original ``Model.field == value`` form is unchanged at runtime. @@ -209,6 +211,8 @@ class OpUser(Model): class TestCombinedStyles: + pytestmark = pytest.mark.deprecated_operator_path + @pytest.mark.asyncio async def test_mixed_chain_executes(self, db_url): """Operator + col() + lambda chained together filter correctly."""