feat(orm): with_count() correlated relationship counts#171
Conversation
… table()
- select_sub(subquery, alias): the subquery-as-column primitive. Renders
(subquery) AS alias with bindings in SELECT position; accepts a builder
or a callable that builds one. Matches Laravel selectSub(query, as).
- add_select(*columns): Laravel's variadic column-adder. Appends plain
string columns with a dedup guard; an associative {alias: subquery} entry
(string key + queryable value) delegates to select_sub, seeding
select({table}.*) first when nothing is selected yet. A string value
under a string key stays a plain column.
- table(name): sets the target table on a bare builder; backs the callable
form of select_sub.
- Migrate the *Through/BelongsToMany with-count callers from the old
add_select(alias, callable) shape to select_sub(callable, alias).
- process_columns: route the subquery alias through subquery_alias_string()
instead of a hardcoded AS (single source of alias emission; SQL unchanged).
Existing string-based select() is unchanged. Covered by grammar-level
to_sql()/to_qmark() with binding-order assertions across all four dialects,
plus a real sqlite suite incl. the Laravel addSelect({alias: subquery})
acceptance case. End-to-end relationship with-count remains gated on the
async count() fix (#886).
Replace raw conn.execute CREATE TABLE / INSERT with the framework's Schema builder and Model.insert() for the categories/posts and accounts/logins fixtures. Same data and same assertions (incl. the Laravel addSelect last_login_at acceptance case).
Wire up relationship count subqueries end-to-end:
- QueryBuilder.select_count(column='*', alias=None): synchronous COUNT
aggregate builder (the query-building counterpart to the async count()),
used to compose correlated COUNT subqueries.
- QueryBuilder.with_count(relation, callback=None) + Model.with_count():
adds a correlated '(SELECT COUNT(*) ...) AS {relation}_count' column.
- BaseRelationship.get_with_count_query: real default for direct relations
(HasMany/HasOne/BelongsTo) — was NotImplementedError.
- BelongsToMany/HasOneThrough/HasManyThrough with-count queries now build
the COUNT subquery synchronously via select_count() instead of chaining
on the async count() coroutine (the #886 blocker).
- Fix BelongsToMany.__init__ resolver lambda (took a spurious arg, breaking
get_builder()) so its correlated queries resolve.
Real sqlite end-to-end tests assert actual counts (0-related returns 0,
callback-constrained counts) for HasMany, HasManyThrough, HasOneThrough and
BelongsToMany; fixtures use ORM insert APIs. Relabel three factory/collection
tests whose names misleadingly contained 'with_count'.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
✅ Code Review: APPROVE (do not merge — stacked on #170; merge order #168→#169→#170→#171)Posting as a comment — GitHub blocks a formal approve on one's own PR. Reviewed the top Correctness — verified independently
Pattern consistency
TestsReal sqlite, ORM-first fixtures (Schema + Gates reproduced locally
Non-blocking noteRelationships declared without explicit keys (relying on LGTM. Merge left to PM per the stack order. |
Summary
Implements end-to-end
with_count()(LaravelwithCount) on the async ORM — the feature was previously unwired (BaseRelationship.get_with_count_querywasNotImplementedErrorand the*Through/BelongsToManyoverrides chained on the asynccount()coroutine, so nothing worked).Changes
QueryBuilder.select_count(column="*", alias=None)— synchronousCOUNTaggregate builder (the query-building counterpart to the asynccount()), used to compose correlatedCOUNTsubqueries.QueryBuilder.with_count(relation, callback=None)+Model.with_count(...)— adds a correlated(SELECT COUNT(*) ...) AS {relation}_countcolumn; the optional callback constrains the count subquery.BaseRelationship.get_with_count_query— real default for direct relations (HasMany/HasOne/BelongsTo).COUNTsynchronously viaselect_count()(fixes the async-count()blocker, no un-awaited coroutine).__init__— fixed the resolver lambda (it took a spurious arg, breakingget_builder()), so its correlated queries resolve.with_count.Tests
tests/masoniteorm/sqlite/builder/test_sqlite_with_count.py— real sqlite end-to-end asserting actual counts for all four relationship types:Model.with_countclassmethod; correlated-subquery SQL shape.Fixtures use the ORM (
Schemabuilder +Model.insert), not raw SQL.Gates
uv run pytest --ignore=tests/masoniteorm/postgres --cov→ 1829 passed / 7 skipped, coverage 79.23% (≥ fail_under 68).ruff check+ruff format --checkclean.This branch is stacked on #170 (
select_sub/add_select, which providesselect_sub+table()+ the migrated with-count call shape). The diff againstmaintherefore includes #170's commit until it merges. Review/merge order: #170 first, then this. Thewith_count-specific commit is the top one (feat(orm): with_count() correlated relationship counts).