From 009812132cbf3b714d489091ee99ae147441ffc6 Mon Sep 17 00:00:00 2001 From: Bedram Tamang Date: Sat, 12 Sep 2026 16:45:23 -0700 Subject: [PATCH] Type where() and get() on the ORM query builder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit where()'s parameters were unannotated, so basedpyright reported the method itself as partially unknown ((column: Unknown, *args: Unknown)) even though QueryBuilder is already generic over the model. get()'s columns parameter had the same problem, which left the whole awaited result partially unknown at the call site. Both now carry annotations. where() ships an overload per real call shape — (column), (column, value), (column, operator, value), a dict of column/value pairs, and a callable subgroup — rather than collapsing to Any, and returns Self so the model parameter survives chaining. get() declares its columns parameter and keeps returning Collection[TModel]. The Model classmethod passthrough mirrors the same overloads. Typing only: no change to query construction or execution. Compiled SQL, qmark SQL and bindings for a representative set of queries are byte-identical before and after. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Vzhb1PCKci1Ge19GvDyG5M --- .../pyrightconfig.typing-tests.json | 7 ++ .../masoniteorm/collection/Collection.py | 5 +- .../masoniteorm/models/builder.py | 29 ++++++- .../masoniteorm/models/model.py | 28 +++++- .../sqlite/models/test_model_query_typing.py | 86 +++++++++++++++++++ 5 files changed, 145 insertions(+), 10 deletions(-) create mode 100644 fastapi_startkit/pyrightconfig.typing-tests.json create mode 100644 fastapi_startkit/tests/masoniteorm/sqlite/models/test_model_query_typing.py diff --git a/fastapi_startkit/pyrightconfig.typing-tests.json b/fastapi_startkit/pyrightconfig.typing-tests.json new file mode 100644 index 00000000..e8e7053c --- /dev/null +++ b/fastapi_startkit/pyrightconfig.typing-tests.json @@ -0,0 +1,7 @@ +{ + "include": [ + "tests/masoniteorm/sqlite/models/test_model_query_typing.py" + ], + "typeCheckingMode": "standard", + "pythonVersion": "3.12" +} diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/collection/Collection.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/collection/Collection.py index 2882b6b0..6ca2e79c 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/collection/Collection.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/collection/Collection.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, Generator, Generic, TypeVar +from collections.abc import Callable, Generator +from typing import TYPE_CHECKING, Any, Generic, TypeVar from fastapi_startkit.support.collection import Collection as BaseCollection @@ -10,7 +11,7 @@ class Collection(BaseCollection, Generic[T]): # Typing-only element-access overrides so a Collection[User] yields # User (not Any) on iteration, indexing, and first(). Runtime behaviour # is supplied unchanged by the base class. - def first(self, callback=None) -> "T | None": ... + def first(self, callback: "Callable[[T], bool] | None" = None) -> "T | None": ... def __iter__(self) -> "Generator[T, Any, None]": ... def __getitem__(self, item) -> "T": ... diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py index e822f0af..e6b31277 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/builder.py @@ -1,5 +1,6 @@ import inspect -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from collections.abc import Callable +from typing import TYPE_CHECKING, Any, Generic, Self, TypeVar, overload from fastapi_startkit.masoniteorm.expressions.expressions import ( JoinClause, @@ -24,6 +25,10 @@ TModel = TypeVar("TModel", bound="Model") +# where(lambda q: q.where(...)) — the callable receives a nested builder and +# returns it, which the parent renders as a parenthesised subgroup. +type WhereGroup[M: "Model"] = Callable[["QueryBuilder[M]"], "QueryBuilder[M]"] + class QueryBuilder(EagerLoadMixin, SupportMixin, Generic[TModel]): operators = [ @@ -159,7 +164,7 @@ async def first(self, columns=None) -> "TModel | None": results = await self.select(columns).limit(1).get() return results.first() - async def get(self, columns=None) -> "Collection[TModel]": + async def get(self, columns: "list[str] | str | None" = None) -> "Collection[TModel]": # TODO: apply scopes if not columns: columns = [] @@ -486,11 +491,27 @@ def invalid_operator(self, operator): """Determine whether an operator is not supported by the builder.""" return not isinstance(operator, str) or operator.lower() not in self.operators - def where(self, column, *args): + @overload + def where(self, column: str, /) -> "Self": ... + + @overload + def where(self, column: str, value: Any, /) -> "Self": ... + + @overload + def where(self, column: str, operator: str, value: Any, /) -> "Self": ... + + @overload + def where(self, column: dict[str, Any], /) -> "Self": ... + + @overload + def where(self, column: "WhereGroup[TModel]", /) -> "Self": ... + + def where(self, column: "str | dict[str, Any] | WhereGroup[TModel]", *args: Any) -> "Self": """Specifies a where expression. Arguments: - column {string} -- The name of the column to search + column {string | dict | callable} -- The column to search, a dict of + column/value pairs, or a callable receiving a nested builder. Keyword Arguments: args {List} -- The operator and the value of the column to search. (default: {None}) diff --git a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py index 39cfbbce..a7705e95 100644 --- a/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py +++ b/fastapi_startkit/src/fastapi_startkit/masoniteorm/models/model.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Self +from typing import TYPE_CHECKING, Any, Self, overload import inflection import pendulum @@ -15,7 +15,7 @@ from fastapi_startkit.masoniteorm.observers import ObservesEvents if TYPE_CHECKING: - from fastapi_startkit.masoniteorm.models.builder import QueryBuilder + from fastapi_startkit.masoniteorm.models.builder import QueryBuilder, WhereGroup class Model(Attribute, Relationship, ObservesEvents): @@ -93,8 +93,28 @@ def get_related(self, key: str): def with_(cls, *eagers) -> "QueryBuilder": return cls.query().with_(*eagers) + @overload @classmethod - def where(cls, column, *args) -> "QueryBuilder[Self]": + def where(cls, column: str, /) -> QueryBuilder[Self]: ... + + @overload + @classmethod + def where(cls, column: str, value: Any, /) -> QueryBuilder[Self]: ... + + @overload + @classmethod + def where(cls, column: str, operator: str, value: Any, /) -> QueryBuilder[Self]: ... + + @overload + @classmethod + def where(cls, column: dict[str, Any], /) -> QueryBuilder[Self]: ... + + @overload + @classmethod + def where(cls, column: WhereGroup[Self], /) -> QueryBuilder[Self]: ... + + @classmethod + def where(cls, column: str | dict[str, Any] | WhereGroup[Self], *args: Any) -> QueryBuilder[Self]: return cls.query().where(column, *args) @classmethod @@ -243,7 +263,7 @@ async def first(cls, columns=None): return await cls.query().first(columns) @classmethod - async def get(cls): + async def get(cls) -> Collection[Self]: return await cls.query().get() @classmethod diff --git a/fastapi_startkit/tests/masoniteorm/sqlite/models/test_model_query_typing.py b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_model_query_typing.py new file mode 100644 index 00000000..5a057952 --- /dev/null +++ b/fastapi_startkit/tests/masoniteorm/sqlite/models/test_model_query_typing.py @@ -0,0 +1,86 @@ +# pyright: reportUnknownMemberType=error, reportUnknownVariableType=error, reportUnknownArgumentType=error +"""Static typing guarantees of where() and get() on models and the query builder. + +The type checker is the real assertion here: + + cd fastapi_startkit && uv run pyright -p pyrightconfig.typing-tests.json + +(the repo pyright config excludes tests/, so this file ships its own config). + +`assert_type` is a no-op at runtime, so every static assertion is paired with a +runtime check that the value really is what the annotation promises. The +`reportUnknown*` rules above are enabled per-file: an unannotated parameter on +where()/get() makes this module fail to type-check even though the assert_type +calls themselves would still pass. +""" + +from typing import assert_type + +from fastapi_startkit.masoniteorm.collection import Collection +from fastapi_startkit.masoniteorm.models.builder import QueryBuilder + +from ...fixtures.model import User +from ..test_case import TestCase + + +class TestWhereTyping(TestCase): + async def test_value_form_returns_builder_of_the_model(self): + builder = User.where("name", "Joe") + + assert_type(builder, QueryBuilder[User]) + assert isinstance(builder, QueryBuilder) + + async def test_operator_form_returns_builder_of_the_model(self): + builder = User.where("name", "!=", "Joe") + + assert_type(builder, QueryBuilder[User]) + assert isinstance(builder, QueryBuilder) + + async def test_dict_form_returns_builder_of_the_model(self): + builder = User.where({"name": "Joe", "is_admin": True}) + + assert_type(builder, QueryBuilder[User]) + assert isinstance(builder, QueryBuilder) + + async def test_callable_form_returns_builder_of_the_model(self): + builder = User.query().where(lambda q: q.where("name", "Joe").where("is_admin", True)) + + assert_type(builder, QueryBuilder[User]) + assert isinstance(builder, QueryBuilder) + + async def test_model_type_survives_a_multi_step_chain(self): + builder = User.where("is_admin", True).where("name", "!=", "Jane").where("name", "like", "%o%") + + assert_type(builder, QueryBuilder[User]) + assert isinstance(builder, QueryBuilder) + + +class TestGetTyping(TestCase): + async def test_awaited_get_is_a_collection_of_the_model(self): + users = await User.where("name", "Joe").get() + + assert_type(users, Collection[User]) + assert isinstance(users, Collection) + assert len(users) == 1 + + async def test_collection_elements_are_the_model(self): + users = await User.where("name", "Joe").get() + user = users.first() + + assert_type(user, User | None) + assert isinstance(user, User) + assert user.name == "Joe" + + async def test_get_with_explicit_columns(self): + users = await User.where("name", "Joe").get(["name"]) + + assert_type(users, Collection[User]) + user = users.first() + assert user is not None + assert user.name == "Joe" + + async def test_model_get_is_a_collection_of_the_model(self): + users = await User.get() + + assert_type(users, Collection[User]) + assert len(users) == 2