Skip to content

Type AsyncQueryBuilder.where() and .get() so basedpyright resolves concrete types - #223

Merged
tmgbedu merged 1 commit into
mainfrom
task/orm-querybuilder-typing
Sep 13, 2026
Merged

Type AsyncQueryBuilder.where() and .get() so basedpyright resolves concrete types#223
tmgbedu merged 1 commit into
mainfrom
task/orm-querybuilder-typing

Conversation

@tmgbedu

@tmgbedu tmgbedu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Fixes the reportUnknownMemberType diagnostics reported on Model.where(...).get() (task #1774).

QueryBuilder is already generic over the model, so the generic plumbing was fine — the gap was that where()'s parameters and get()'s columns parameter were unannotated, which made the members themselves partially unknown and degraded everything downstream of the call.

Changes

  • QueryBuilder.where() — one @overload per real call shape ((column), (column, value), (column, operator, value), dict[str, Any], and a callable subgroup), no collapsing to Any for column/operator. Returns Self, so the model parameter survives chaining.
  • QueryBuilder.get()columns: list[str] | str | None; return stays Collection[TModel].
  • Model.where() / Model.get() — the classmethod passthroughs mirror the same overloads and declare Collection[Self].
  • Collection.first() — one-line annotation of the existing typing-only stub's callback (it sits directly on the awaited-get() path and was the last unknown member in the chain).
  • New tests/masoniteorm/sqlite/models/test_model_query_typing.py plus pyrightconfig.typing-tests.json (the repo pyright config excludes tests/).

Values stay Any deliberately — a bound query value is genuinely arbitrary (str, int, bool, None, a datetime, or a subselect QueryBuilder), and explicit Any does not trigger the reported rule.

Evidence

basedpyright (strict) on await Lexeme.where("id", 1).where("name", "=", "x").get()

Before — 10 errors:

Type of "where" is "(column: Unknown, *args: Unknown) -> QueryBuilder[Lexeme]"     (reportUnknownMemberType)
Type of "get" is "(columns: Unknown | None = None) -> CoroutineType[...]"          (reportUnknownMemberType)

After — 0 errors:

Type of "Lexeme.where" is "Overload[(column: str, /) -> QueryBuilder[Lexeme],
  (column: str, value: Any, /) -> QueryBuilder[Lexeme],
  (column: str, operator: str, value: Any, /) -> QueryBuilder[Lexeme],
  (column: dict[str, Any], /) -> QueryBuilder[Lexeme],
  (column: (QueryBuilder[Lexeme]) -> QueryBuilder[Lexeme], /) -> QueryBuilder[Lexeme]]"
Type of "...get" is "(columns: list[str] | str | None = None) -> CoroutineType[Any, Any, Collection[Lexeme]]"
Type of "rows" is "Collection[Lexeme]"   |   Type of "rows.first()" is "Lexeme | None"

Typing tests fail first. uv run pyright -p pyrightconfig.typing-tests.json against the unfixed tree: 18 errors (where, get, first all partially unknown). Against this branch: 0 errors.

No runtime change. 13 representative queries (value/operator/like/chained/dict/lambda-subgroup/single-arg/order+limit/or_where/where_in/where_null/subselect/select-columns) dumped via to_sql(), to_qmark() and get_bindings() — output byte-identical before and after (same md5).

Tests. uv run pytest --ignore=tests/masoniteorm/postgres --cov2196 passed, 7 skipped, coverage 84.44% (threshold 80). Repo-wide uv run pyright on src/: 655 errors / 113 warnings before and after — no new diagnostics.

Follow-up, deliberately NOT in this PR

~35 sibling chainable methods (order_by, select, limit, offset, group_by, where_in, or_where, having, between, join, …) are declared -> "QueryBuilder" rather than Self, so chaining through any of them drops the model parameter:

Lexeme.where("id", 1).order_by("name")        -> QueryBuilder[Unknown]
await Lexeme.where("id", 1).order_by("name").get() -> Collection[Unknown]

The fix is mechanical (-> "QueryBuilder"-> "Self", plus parameter annotations on or_where, where_in, having, between, when, first, find, get_models), but it is a large mechanical diff and belongs in its own reviewable change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Vzhb1PCKci1Ge19GvDyG5M

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vzhb1PCKci1Ge19GvDyG5M
@tmgbedu

tmgbedu commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

✅ Review of 00981213 — approving

(Comment rather than a formal approval: GitHub won't let me review my own account's PR.)

Typing-only changes that are structurally incapable of altering runtime, with tests that genuinely
fail first. I re-ran every claim rather than taking the PR body's numbers.

Numbers I observed

Check Claimed I observed
pytest --ignore=tests/masoniteorm/postgres --cov 2196 passed, 7 skipped, 84.44% 2196 passed, 7 skipped, 84.44%
pyright -p pyrightconfig.typing-tests.json on this branch 0 errors 0 errors, 0 warnings
Same config against unfixed src/ 18 errors 17 errors — see note below

The fail-first claim holds. I checked out a149c09b, restored only the new test file and config onto
the unfixed source, and got 17 errors — where partially unknown at every call site, plus get and
first. The count is one off from the PR body (17 vs 18); most likely a pyright-version difference
(I'm on the pinned 1.1.411). Not material — the test fails hard without the fix, which is the point —
but worth correcting the number in the description.

"No runtime change" — stronger than the md5 evidence you gave

Rather than sample queries, I filtered the src/ diff for changed executable statements. There are
none. Every added line is an @overload stub, a @classmethod/@overload decorator, a parameter
annotation, a docstring line, or blank; the single removed line of substance is the first stub,
which lives inside if TYPE_CHECKING:. where()'s body, get()'s body and both Model
passthroughs are untouched. That's a structural guarantee rather than a 13-query sample, so the
SQL-equivalence check was belt-and-braces.

Correctness of the annotations

  • -> Self on where() is honest. Single return self, no early returns on any of the four
    branches. Checked before trusting it.
  • PEP 695 type WhereGroup[M: "Model"] and typing.Self are safe hererequires-python is
    >=3.12,<4.0, and the declared classifiers are 3.12/3.13 only. Would have been a hard import-time
    SyntaxError on 3.11; it isn't.
  • Collection[Self] on Model.get resolvesCollection is a real runtime import at
    model.py:9, not a TYPE_CHECKING-only name, so the annotation is valid under
    from __future__ import annotations either way.
  • Overload arities don't overlap. (column, value) and (column, operator, value) are
    distinguished by arity, so no ordering hazard between them.
  • @overload above @classmethod is the correct nesting order and pyright resolves it — the 0
    errors on the typing config confirm it end to end.
  • Per-file reportUnknown*=error pragma is what makes this work: the config is
    typeCheckingMode: standard, where those rules are off by default. Enabling them in the test file
    rather than globally is the right call, and pairing every assert_type with a runtime isinstance
    is good — it means the file is a real test under pytest as well as under pyright.

One note, non-blocking

WhereGroup is Callable[[QueryBuilder[M]], QueryBuilder[M]], but the runtime discriminator is
inspect.isfunction(column) (builder.py:523), which is narrower than Callable:

lambda           -> True
plain function   -> True
functools.partial-> False
bound method     -> False
callable object  -> False

So a partial, a bound method, or a __call__-able object now type-checks clean against the new
overload and then silently falls through to the else branch, building
QueryExpression(column=<the callable>, ...) instead of a subgroup — a wrong query with no error.

The runtime narrowness is pre-existing and not yours; what's new is advertising it as Callable.
Still strictly better than the old unannotated signature, so I'm not blocking on it. When you touch
this next, either widen the runtime check (callable(column) and not isinstance(column, (str, dict)))
or narrow the alias to types.FunctionType. Worth a line in #1777 or its own issue — your call which.

Follow-up scope is genuinely excluded ✅

Confirmed as asked, and not raising it here. builder.py still has 37 methods declared
-> "QueryBuilder" (matching the "~35" in #1777), and the diff touches none of them — no
order_by, select, limit, offset, group_by, where_in, or_where, having, between or
join. The only Self returns added anywhere are the five where overloads plus its implementation
signature. The diff is exactly where / get / Collection.first and the two Model passthroughs,
and it stays reviewable as a result — splitting it was the right call.

Approving. Merge is the PM's call.

@codecov

codecov Bot commented Sep 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@tmgbedu
tmgbedu merged commit f7520f6 into main Sep 13, 2026
6 checks passed
@tmgbedu
tmgbedu deleted the task/orm-querybuilder-typing branch September 13, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant