fix(agent): serialize every level of a projection deeper than one relation - #357
Merged
Conversation
…ation The Forest-Projection header accepts paths of any depth, and the datasources fetch them, but the record routes built their JSON:API include list from `Projection#relations`, which only knows about the first hop. Everything below `author` was read from the database and then dropped at serialization time. - `Projection#relation_include_paths` returns the dotted path of every hop, so `author:company:name` yields `['author', 'author.company']` - `find_recursive_relationships` now recurses with the related collection's class name; with the parent's, `author.company` was looked up in the parent schema and raised `InvalidIncludeError` - a nested polymorphic hop carries its type and foreign-key columns, without which the target has neither a collection nor an id and is silently dropped - a bare nested polymorphic relation is expanded to `:*` like a bare root one Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 new issues
|
|
Coverage Impact ⬆️ Merging this pull request will increase total coverage on Modified Files with Diff Coverage (5) 🛟 Help
|
hercemer42
requested changes
Aug 14, 2026
FieldValidator destructured with a bare split(':'), so `documentable:*:brand`
yielded suffix `*`, passed the `suffix != '*'` guard, and skipped the
recursion that validates deeper hops. The path reached the serializer intact.
Serializing it used to be harmless because relations(only_keys: true) kept
only the first hop; now that include paths carry every hop, the recursion
looks for a `*` relationship, raises JSONAPI::Serializer::InvalidIncludeError,
and that class descends from Exception rather than StandardError -- so the
controller's rescue never ran, exception_handler never logged, and the client
got a bare Rails 500 with no Forest error payload. It was data-dependent: an
unlinked row short-circuits earlier and still returned 200.
split(':', 2) makes the guard see the whole remainder, turning the path into
the 400 it always should have been, and the recursion no longer has to
recompute the suffix it already had. The controller now also rescues
JSONAPI::Serializer::Error, since every error in that hierarchy escapes the
StandardError rescue the same way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The four helpers added for deep header projections landed as public API on a released gem class, next to a private_class_method list that already covered every other helper of the same kind, so reshaping them later would have been a breaking change for anyone who found them. to_enum and the implicit-receiver calls resolve unchanged once they are private. Also uses POLYMORPHIC_TARGET_WILDCARD in build_projection_fields, which still built the wildcard from a literal, and names the two blocks flagged as complex: polymorphic_linkage_columns for the type/id pair a nested generic relation needs, and field_along_path for the rule that the root segment must exist while deeper ones may legitimately be absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PMerlet
force-pushed
the
fix/deep-projection-serialization
branch
2 times, most recently
from
August 14, 2026 13:12
5eba710 to
076e32c
Compare
The rescue added for JSONAPI::Serializer::Error named a constant that is not loaded in the controller's context: forest_admin_rails requires the agent, but the agent's serializer files are autoloaded, so nothing had pulled jsonapi-serializers in yet. Ruby evaluates rescue class expressions at raise time and short-circuits, so a StandardError still matched on the first class and behaved normally. A non-StandardError -- the whole point of the clause -- reached the second expression and raised NameError instead, replacing the serializer error with a worse one. Verified: without the require, the new specs fail with "uninitialized constant ForestAdminRails::JSONAPI". Two specs pin it: the constant resolves and descends from Exception, and an InvalidIncludeError goes through exception_handler with a 500 and a log line rather than escaping. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
hercemer42
approved these changes
Aug 14, 2026
forest-bot
added a commit
that referenced
this pull request
Aug 14, 2026
## [1.38.3](v1.38.2...v1.38.3) (2026-08-14) ### Bug Fixes * **agent:** serialize every level of a projection deeper than one relation ([#357](#357)) ([a1e8cbd](a1e8cbd))
Member
|
🎉 This PR is included in version 1.38.3 🎉 The release is available on GitHub release Your semantic-release bot 📦🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Context
The
Forest-Projectionheader (#352) accepts paths of any depth —FieldValidatorrecurses through relations — and the datasources fetch them. But the record routes built their JSON:APIincludelist fromProjection#relations, which only knows about the first hop. Everything belowauthorwas read from the database and then dropped at serialization time.This is the behaviour agent-nodejs already has: its serializer walks registered relationships recursively, with no include list at all.
What this fixes
Projection#relation_include_pathsreturns the dotted path of every hop, soauthor:company:nameyields['author', 'author.company']. Used byshow,listandlist_related.find_recursive_relationshipsrecursed with the parent'sclass_name, soauthor.companywas looked up in the parent schema and raisedInvalidIncludeError. Depth 2 could not work even with the right include list.add_polymorphic_type_fieldsonly covers the root level, soauthor:documentable:*arrived withoutauthor:documentable_type/author:documentable_id— and a target with neither a collection nor an id is silently dropped from the payload.:*like a bare root one.documentablewas normalised at the root butauthor:documentablereturned a 400.Tests
Every fix has a test that fails without it.
projection_spec: include paths for 1 / 3 hops, shared hops, polymorphic wildcardforest_serializer_spec: three-hop chain inincludedwith its linkages, stop at the requested depth, absent intermediate record, nested polymorphic resolved from its type column, phantom target linkage rebuilt from the foreign key, unlinked targetquery_string_parser_spec: two-hop path kept, validation of the last field, type and key columns added, bare nested polymorphic normalised, field under a polymorphic rejected, to-many hop rejected at any depthshow_spec/list_spec/list_related_spec: end to end on the three routes, including a related record shared by two rows appearing once inincludedjoin_to_one_optimization_spec: a two-hop path folded into one JOINed query, columns restricted per level, primary key of every level afterwith_pks, nil intermediateactive_record_serializer_spec: nested polymorphic carries type and foreign key, plain field through a polymorphic one-to-one hopn_plus_one_spec: the preload path stays batched, whatever the number of rowsfield_validator_spec: the wildcard allowed, a field nested under the relation rejected, a field nested under the wildcard rejectedSuites run locally:
forest_admin_agent(870),forest_admin_datasource_toolkit(472),forest_admin_rails(110),forest_admin_datasource_active_record(187),forest_admin_datasource_customizer(691),forest_admin_datasource_rpc(168). RuboCop clean.No new capability: consumers gate on
canUseProjectionViaHeaderConsidered and dropped, because this was a ruby-only divergence rather than a feature both agents are gaining.
agent-nodejs has honoured any depth since the day it shipped the header (#1813, 2026-08-10) — that commit already tests
id,owner:address:street,owner:address:country:name, and its get-one route serializes with no include list, so there was nothing to truncate. ruby shipped the same header in 1.38.0 (#352) but passedrelations(only_keys: true)as the include list.So
canUseProjectionViaHeaderhas always meant "every hop is honoured" on node, and meant "truncated at one hop" only on ruby 1.38.0, 1.38.1 and 1.38.2 — three patches released between Aug 11 and Aug 13, superseded by the release carrying this PR. Gating depth ≥ 2 on the existing key is therefore correct for every node agent and every ruby ≥ 1.38.3, and it keeps a permanent public contract out of both agents to cover a window that closes before the frontend work (PRD-767 / ForestAdmin/forestadmin#9881) can consume it. A customer sitting on one of those three ruby patches needs a patch bump, not a handshake.Review follow-ups
documentable:*:brandused to reach the serializer.FieldValidatordestructured with a baresplit(':'), so the suffix was*, the wildcard guard passed and the deeper-hop recursion was skipped. Harmless while include paths kept only the first hop; with every hop carried, the serializer looked for a*relationship and raisedJSONAPI::Serializer::InvalidIncludeError— which descends fromException, notStandardError, so the controller's rescue never ran and the client got a bare Rails 500 with nothing in the customer's logger. Nowsplit(':', 2), which makes it the 400 it always should have been, and the controller also rescuesJSONAPI::Serializer::Error.private_class_methodlist instead of landing as public API on a released gem class,POLYMORPHIC_TARGET_WILDCARDis used in both places that build the wildcard, and the two blocks flagged as complex are named (polymorphic_linkage_columns,field_along_path).Known limits, unchanged by this PR
*_typecolumn raisesCollection 'X' not found. Verified identical at the root and nested — this PR makes the case reachable one hop further, it does not create it.Definition of Done
General
Security
🤖 Generated with Claude Code
Note
Fix serialization of projections deeper than one relation in list, related-list, and show endpoints
Projection#relation_include_pathsto flatten nested relation projections into dot-separated paths (e.g.author.company), replacing the previousrelations(only_keys: true)/relations.keyscalls that only returned top-level relation names.relation_include_pathstoJSONAPI::Serializer, enabling correct compound-document includes for multi-hop relationships.ForestSerializerOverride#find_recursive_relationshipsto resolve the serializer class andclass_nameonce per path hop, fixing nested polymorphic to-one serialization.QueryStringParser#build_header_projection_fieldswith helpers (expand_polymorphic_leaf,nested_polymorphic_linkage_fields) to inject type/key linkage columns for nested polymorphic relations and normalize leaf polymorphic paths to wildcards.includepayloads now contain intermediate dot-path segments that were previously omitted; any consumer relying on the old flat key list will see different include behavior.Changes since #357 opened
jsonapi-serializerslibrary inForestController[6f82c52]JSONAPI::Serializerexception handling inForestController[6f82c52]Macroscope summarized 989c4ad.