Type AsyncQueryBuilder.where() and .get() so basedpyright resolves concrete types - #223
Conversation
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
✅ Review of
|
| 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
-> Selfonwhere()is honest. Singlereturn self, no early returns on any of the four
branches. Checked before trusting it.- PEP 695
type WhereGroup[M: "Model"]andtyping.Selfare safe here —requires-pythonis
>=3.12,<4.0, and the declared classifiers are 3.12/3.13 only. Would have been a hard import-time
SyntaxErroron 3.11; it isn't. Collection[Self]onModel.getresolves —Collectionis a real runtime import at
model.py:9, not aTYPE_CHECKING-only name, so the annotation is valid under
from __future__ import annotationseither way.- Overload arities don't overlap.
(column, value)and(column, operator, value)are
distinguished by arity, so no ordering hazard between them. @overloadabove@classmethodis the correct nesting order and pyright resolves it — the 0
errors on the typing config confirm it end to end.- Per-file
reportUnknown*=errorpragma 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 everyassert_typewith a runtimeisinstance
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 Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Fixes the
reportUnknownMemberTypediagnostics reported onModel.where(...).get()(task #1774).QueryBuilderis already generic over the model, so the generic plumbing was fine — the gap was thatwhere()'s parameters andget()'scolumnsparameter were unannotated, which made the members themselves partially unknown and degraded everything downstream of the call.Changes
QueryBuilder.where()— one@overloadper real call shape ((column),(column, value),(column, operator, value),dict[str, Any], and a callable subgroup), no collapsing toAnyforcolumn/operator. ReturnsSelf, so the model parameter survives chaining.QueryBuilder.get()—columns: list[str] | str | None; return staysCollection[TModel].Model.where()/Model.get()— the classmethod passthroughs mirror the same overloads and declareCollection[Self].Collection.first()— one-line annotation of the existing typing-only stub'scallback(it sits directly on the awaited-get()path and was the last unknown member in the chain).tests/masoniteorm/sqlite/models/test_model_query_typing.pypluspyrightconfig.typing-tests.json(the repo pyright config excludestests/).Values stay
Anydeliberately — a bound query value is genuinely arbitrary (str,int,bool,None, a datetime, or a subselectQueryBuilder), and explicitAnydoes not trigger the reported rule.Evidence
basedpyright (strict) on
await Lexeme.where("id", 1).where("name", "=", "x").get()Before — 10 errors:
After — 0 errors:
Typing tests fail first.
uv run pyright -p pyrightconfig.typing-tests.jsonagainst the unfixed tree: 18 errors (where,get,firstall 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()andget_bindings()— output byte-identical before and after (same md5).Tests.
uv run pytest --ignore=tests/masoniteorm/postgres --cov→ 2196 passed, 7 skipped, coverage 84.44% (threshold 80). Repo-wideuv run pyrightonsrc/: 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 thanSelf, so chaining through any of them drops the model parameter:The fix is mechanical (
-> "QueryBuilder"→-> "Self", plus parameter annotations onor_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