[12.x] Fix circular references support on JSON:API component#58329
[12.x] Fix circular references support on JSON:API component#58329mateusjatenee wants to merge 12 commits into
Conversation
|
@timacdonald brought up some interesting edge cases; already got failing tests for them. Will fix in the morning, drafting the PR until then |
I don't think there's a way to avoid object tracking here, it's the only way to distinguish between For the second case (same object, different type through chaperone): I think something like this could work? $visitedResources = [
spl_object_id($this->resource).':'.$this->resolveResourceType($request) => true,
];
// in the while loop
$resourceKey = spl_object_id($resourceInstance->resource).':'.$type;That way, it allows the same object to appear with different resource types. For the first case (
This applies across the entire document, duplicating a resource in both |
|
@dxnter good stuff! Appreciate it. I wonder if we should support case 2. Maybe it's not as uncommon as I think. Regarding #1 - agreed on tackling that separately. |
|
@mateusjatenee Perfect use for $visitedObjects[$this->resource] = [$this->resolveResourceType($request) => true];
// in the loop
if (isset($visitedObjects[$underlyingResource][$type])) { ... }
$visitedObjects[$underlyingResource] ??= [];
$visitedObjects[$underlyingResource][$type] = true;Without it, something like this would fail: $user = User::factory()->create();
$post = Post::factory()->create(['user_id' => $user->getKey()]);
// Post's author uses AuthorResource ("authors"), root uses UserResource ("users")
$response = $this->getJson("/users/{$user->getKey()}/with-chaperone-posts?".http_build_query([
'include' => 'chaperonePosts.author',
]));
// author is blocked because it's the same object
$response->assertJsonPath('included.1.type', 'authors');imo it does seem a bit unusual to do, but since there's support for mapping relationships to different resource classes it should probably work when combined with chaperone? 🤷♂️ |
|
Object tracking is definitely unavoidable for JSON:API resolution. Things like: User::with(['posts.author:id,name'])->get(['id', 'name', 'email']);must return {
"data": [
{
"type": "users",
"id": "1",
"attributes": {
"name": "Alice Johnson",
"email": "alice@example.com"
},
"relationships": {
"posts": {
"data": [
{ "type": "posts", "id": "11" }
]
}
}
}
],
"included": [
{
"type": "posts",
"id": "11",
"attributes": {
"title": "Second Post"
},
"relationships": {
"author": {
"data": { "type": "users", "id": "1" }
}
}
}
]
}the User resource merged at the top level, because by the spec only one instance of the resource can exist in the document. Same goes if we reverse and the user is included - it still needs to be merged from different User models into a single one. |
|
@donnysim @dxnter appreciate the help here! I decided to stop being lazy and actually read the spec in full (and your comments). Correct me if I'm wrong, but this is what I got from it: Resource identity = type + id
So:
Additionally, there's the scenario where a user might inadvertently have multiple instances of the same model (ID) on the object graph, but not necessarily the same instance. However that's already addressed separately — I added a test still. Edit: |
|
Yeah, I think this should just focus on circular references as intended, and the whole duplication and merging should be tackled and raised separately as maybe it would cause a breaking change, though hopefully not as I can't imagine how you're supposed to find the correct entry if multiple are present. |
|
@mateusjatenee, we should deduplicate on the resolved resource ID and type, not the model itself. Imagine I have a user resource (type: "user") and an author resource (type: "author"). Both are powered by a user model. Both user 1 and author 1 are allowed to appear in the response.
I know the framework implementation strayed a bit from mine, so I'm not entirely sure where to do this kinda thing, but that is what I would expect to happen. |
|
@timacdonald right — the current implementation does not deduplicate on the model instance, but on The reason it isn't |
|
OK - just pushed a commit that dedupes on Note that I did have to update the That solves the initial circular reference problem for |
This reverts commit c32d36a.
5512a88 to
5cbb6cc
Compare
|
Closing in favor of #58329 - we can discuss dedup separately |
Right now,
JsonApiResourceenters an infinite loop when serializing models with bidirectional Eloquent relationships that usechaperone().For example, imagine you have:
The following hangs:
This happens because on
resolveIncludedResourceObjectswe callincludePreviouslyLoadedRelationships, which includes all available relations for a model oncompileResourceRelationships.So, essentially:
Userresource addsPostto the mapPostresource callsincludePreviouslyLoadedRelationships-> seesauthoris loaded (via chaperone) → adds the sameUserinstance back to the mapUseris processed again -> adds the samePostback -> infinite loopWe can't remove
includePreviouslyLoadedRelationships, as it'd break nested relationships (I think, pretty tired, might need to revisit this), so my fix involves tracking which objects we have visited onresolveIncludedResourceObjects, which is only invoked for the "root" resource/model.My first approach tracked
{type}-{id}, but it breaks some use cases (and tests), e.g.BelongsToManywhere you have e.g. the same model attached to another model but with different pivot data, for exampletestItCanGenerateJsonApiResponseWithRelationshipsUsingSparseIncludedwhere it expects two teams, but it'd only give one back. Tracking the object itself withspl_object_idhandles that.There are some edge cases I can think of that this solution does not support:
/users/1?include=posts.user— I don't see when this would be useful tbh but it'd not work.Postauthor usesAuthorResource(type: authors) while the root usesUserResource(type: users). ThePostrelationship identifier would reference authors/1, but there'd be no matching entry in included since we skip the same object. Bit of a weird setup though.2 is a bit hard to understand, here's some Claude art:
I'd prefer to avoid tracking objects tbh. I can revisit this tomorrow and go through the code more carefully to see if there's a better option, but wanted to at least get a failing test in place.