PHPLARA-225 Support relation aggregates on document models - #3560
Conversation
withCount, withExists, withSum, withAvg, withMin and withMax now work on MongoDB models, as well as loadCount, loadExists and loadAggregate. MongoDB has no correlated subquery, so the values cannot be selected with the parent documents as Eloquent does. They are computed with one additional query per aggregate, after the parent documents are read, then set as attributes. Embedded relations need no extra query at all. Unsupported cases (MorphTo, hybrid relations, ordering by an aggregate alias) throw instead of silently returning a wrong value.
- Extract private methods with early returns in hydrateAggregate for readability - Clarify the exception message when the aggregate column is not a string - Cover sum, avg and min on an embedded relation in the tests - Remove implementation detail and extra query cost from the skill docs - Move the relation aggregate example to the query-builder reference only
|
Discussion notes:
|
Split the alias resolution and the per relation query preparation into named private methods, with early returns instead of nested branches. Drop the ticket link from the embedded constraint exception message.
The parent key is null for embedded relations, so the two branches of the former elseif are independent and the wrapper method is not needed. The default value is computed inside aggregateValues.
Reject a column name that starts with a dollar sign, as it would be interpreted as a variable reference in the aggregation pipeline. Throw a descriptive exception when a stored relation key is not a scalar or a stringable object, instead of a PHP cast error.
|
I applied the review feedback and added two hardening checks after a security review:
Both checks have tests. Ready for another look. |
There was a problem hiding this comment.
Pull request overview
Adds MongoDB-compatible support for Eloquent relation aggregates on document models by deferring aggregate computation until after parent documents are loaded, avoiding Eloquent’s correlated subselect + toSql() path that previously threw.
Changes:
- Introduces
MongoDB\Laravel\Helpers\QueriesRelationshipAggregatesto recordwithAggregate()requests and hydrate aggregate attributes duringeagerLoadRelations(). - Adds comprehensive test coverage for supported relations/functions and explicit exceptions for unsupported scenarios (e.g., ordering by aggregate aliases, morphTo, hybrid relations).
- Updates Boost skill documentation and eval expectations to reflect that relation aggregates are now supported and clarifies the server-side sorting limitation.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/skills/laravel-mongodb/evals/evals.json | Updates eval to reflect withCount() support and the “no orderBy on alias” limitation. |
| tests/HybridRelationsTest.php | Adds regression test ensuring hybrid relation aggregates fail with clear exceptions. |
| tests/Eloquent/WithAggregateTest.php | New test suite covering relation aggregates across relation types, defaults, and limitations. |
| src/Helpers/QueriesRelationshipAggregates.php | Implements deferred relation aggregate hydration for MongoDB Eloquent builders. |
| src/Eloquent/Builder.php | Mixes the new aggregates trait into the MongoDB Eloquent builder. |
| resources/boost/skills/laravel-mongodb/SKILL.md | Updates skill guidance to reflect new support and limitations. |
| resources/boost/skills/laravel-mongodb/references/query-builder.md | Documents supported relation aggregates, limitations, and $lookup alternative patterns. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
The embedded constraint guard now rejects limit, offset and distinct in addition to where clauses, as they would be silently ignored and produce a wrong aggregate value. Remove the extra query cost note from the skill, it was incorrect for embedded relations.
| return; | ||
| } | ||
|
|
||
| if (! DocumentModel::isDocumentModel($relation->getRelated()) || $this->isAcrossConnections($relation)) { |
There was a problem hiding this comment.
Note on the isAcrossConnections() condition: it is conservative. Both hydration paths query the connection of the related model (server side $group for HasOneOrMany, eager match in PHP for the others), so a relation between two MongoDB connections could probably work. We keep the restriction for now because cross-connection MongoDB relations are rare and the path is untested. Relaxing this guard later is not a breaking change.
Adds support for
withCount(),withExists(),withSum(),withAvg(),withMin()andwithMax()on document models.loadCount(),loadExists()andloadAggregate()work as well, since Laravel funnels them through the samewithAggregate()method.Before this change, any of them failed with
BadMethodCallException: This method is not supported by MongoDB. Try "toMql()" instead., becauseIlluminate\Database\Eloquent\Concerns\QueriesRelationships::withAggregate()builds a correlated subselect and callstoSql().Resolves PHPLARA-225, PHPLARA-93 and PHPLARA-237. Reported as #3003, #2470, #2435, #1801 and #1339.
How it works
MongoDB has no correlated subquery, so the values cannot be selected with the parent documents as Eloquent does. The new
MongoDB\Laravel\Helpers\QueriesRelationshipAggregatestrait records the requested aggregates inwithAggregate(), then computes them ineagerLoadRelations(), after the parent documents are read, and sets them as attributes.The cost is one additional query per aggregate, whatever the number of parent documents:
HasOneOrManyand its morph variants are aggregated by the server with a$groupon the foreign key.BelongsTo,BelongsToManyandMorphToManycannot be grouped server side (a single related document per parent, or parent keys stored in an array field), so the eager loadingmatch()logic is reused and the values are folded in PHP.EmbedsOneandEmbedsManyneed no query at all, they are computed from the parent document.existsis computed as a grouped count mapped to> 0and stored as a real boolean, so noboolcast is added on the model.Aliases follow Illuminate exactly, so
withCount('books')givesbooks_count,withMax('items', 'amount')givesitems_max_amountandwithCount('books as total')givestotal. Defaults with no related document are0forcount,falseforexistsandnullfor the others.Limitations
Cases that cannot be supported throw a
LogicExceptionrather than returning a wrong value:orderBy()on an aggregate alias: the value does not exist server side, so a$sorton it would silently return unsorted results. A$lookuppipeline is required to sort on an aggregate.MorphToand other relation types outside the set supported byhas().cursor()andlazy()do not calleagerLoadRelations(), so the aliases are absent on those paths, as with any eager loading.The Boost skill and its query builder reference are updated, since they documented these methods as unsupported.
Why not
$lookup?Computing the aggregates inside the parent query with a
$lookupstage would avoid the extra queries, but it is not possible today, for five reasons.The query builder cannot emit a pipeline for a regular
get(). It only switches fromfind()toaggregate()when$groupsor$aggregateis set (src/Query/Builder.php:303), and it has no$lookupsupport at all. Adding one would force every query carrying awithCount()through an aggregation pipeline, changing the execution plan of the parent query itself, not just of the aggregate.$lookupdoes no BSON type coercion. Foreign keys are frequently stored as strings while_idis anObjectId, to the point that the package documentation recommends casting them with'author_id' => 'string'. Eloquent normalizes both sides when matching in PHP,$lookupdoes not, so the counts would silently be0. That is precisely the failure mode this PR avoids by throwing on the cases it cannot compute correctly.Server compatibility. A correlated
$lookup(let+pipeline), which is what counting without materializing the related documents requires, needs MongoDB 5.0, while CI still covers 4.4. ThelocalField/foreignFieldform works on 4.4 but builds the full array of related documents before$size, so an unbounded relation can hit the 16 MB document limit or the 100 MB per stage memory limit.One pipeline generator per relation type.
belongsToManystores the keys in an array field,morphToManyin pivot subdocuments discriminated by a morph type, andembedsManyis already part of the parent document. That is four distinct pipeline builders to write and test, instead of reusing the eager loadingmatch()logic that already handles those dictionaries.Scope. PHPLARA-93 is the open spike about rewriting the query builder on top of aggregation pipelines. This PR delivers the feature now, at one extra query per aggregate whatever the number of parent documents, and
$lookupremains the natural follow up: it is the only way to lift theorderBy()limitation on an aggregate alias.