Skip to content

fix(agent): serialize every level of a projection deeper than one relation - #357

Merged
PMerlet merged 4 commits into
mainfrom
fix/deep-projection-serialization
Aug 14, 2026
Merged

fix(agent): serialize every level of a projection deeper than one relation#357
PMerlet merged 4 commits into
mainfrom
fix/deep-projection-serialization

Conversation

@PMerlet

@PMerlet PMerlet commented Aug 13, 2026

Copy link
Copy Markdown
Member

Context

The Forest-Projection header (#352) accepts paths of any depth — FieldValidator recurses through relations — 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.

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_paths returns the dotted path of every hop, so author:company:name yields ['author', 'author.company']. Used by show, list and list_related.
  • find_recursive_relationships recursed with the parent's class_name, so author.company was looked up in the parent schema and raised InvalidIncludeError. Depth 2 could not work even with the right include list.
  • A nested polymorphic hop now carries its type and foreign-key columns. add_polymorphic_type_fields only covers the root level, so author:documentable:* arrived without author:documentable_type / author:documentable_id — and a target with neither a collection nor an id is silently dropped from the payload.
  • A bare nested polymorphic relation is expanded to :* like a bare root one. documentable was normalised at the root but author:documentable returned a 400.

Tests

Every fix has a test that fails without it.

  • projection_spec: include paths for 1 / 3 hops, shared hops, polymorphic wildcard

  • forest_serializer_spec: three-hop chain in included with 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 target

  • query_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 depth

  • show_spec / list_spec / list_related_spec: end to end on the three routes, including a related record shared by two rows appearing once in included

  • join_to_one_optimization_spec: a two-hop path folded into one JOINed query, columns restricted per level, primary key of every level after with_pks, nil intermediate

  • active_record_serializer_spec: nested polymorphic carries type and foreign key, plain field through a polymorphic one-to-one hop

  • n_plus_one_spec: the preload path stays batched, whatever the number of rows

  • field_validator_spec: the wildcard allowed, a field nested under the relation rejected, a field nested under the wildcard rejected

Suites 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 canUseProjectionViaHeader

Considered 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 passed relations(only_keys: true) as the include list.

So canUseProjectionViaHeader has 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:*:brand used to reach the serializer. FieldValidator destructured with a bare split(':'), 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 raised JSONAPI::Serializer::InvalidIncludeError — which descends from Exception, not StandardError, so the controller's rescue never ran and the client got a bare Rails 500 with nothing in the customer's logger. Now split(':', 2), which makes it the 400 it always should have been, and the controller also rescues JSONAPI::Serializer::Error.
  • The four new header-projection helpers are added to the existing private_class_method list instead of landing as public API on a released gem class, POLYMORPHIC_TARGET_WILDCARD is 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

  • An unknown value in a *_type column raises Collection 'X' not found. Verified identical at the root and nested — this PR makes the case reachable one hop further, it does not create it.
  • Permissions and scopes are checked on the root collection only, at any depth, in this agent and in agent-nodejs. Traversing a relation through a projection is not permission-checked. Pre-existing, tracked separately.

Definition of Done

General

  • Write an explicit title for the Pull Request, following Conventional Commits specification
  • Test manually the implemented changes
  • Validate the code quality (indentation, syntax, style, simplicity, readability)

Security

  • Consider the security impact of the changes made

🤖 Generated with Claude Code

Note

Fix serialization of projections deeper than one relation in list, related-list, and show endpoints

  • Adds Projection#relation_include_paths to flatten nested relation projections into dot-separated paths (e.g. author.company), replacing the previous relations(only_keys: true) / relations.keys calls that only returned top-level relation names.
  • Updates list, related-list, and show route handlers to pass relation_include_paths to JSONAPI::Serializer, enabling correct compound-document includes for multi-hop relationships.
  • Refactors ForestSerializerOverride#find_recursive_relationships to resolve the serializer class and class_name once per path hop, fixing nested polymorphic to-one serialization.
  • Extends QueryStringParser#build_header_projection_fields with 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.
  • Risk: serializer include payloads 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

  • Added require for jsonapi-serializers library in ForestController [6f82c52]
  • Added test coverage for JSONAPI::Serializer exception handling in ForestController [6f82c52]

Macroscope summarized 989c4ad.

…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>
@qltysh

qltysh Bot commented Aug 13, 2026

Copy link
Copy Markdown

2 new issues

Tool Category Rule Count
qlty Structure Function with high complexity (count = 5): nested_polymorphic_linkage_fields 2

relation_path = segments[0...index]
[field.foreign_key_type_field, field.foreign_key].map { |column| (relation_path + [column]).join(':') }
end
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 5): nested_polymorphic_linkage_fields [qlty:function-complexity]

break unless field.respond_to?(:foreign_collection)

current_collection = collection.datasource.get_collection(field.foreign_collection)
end

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function with high complexity (count = 9): each_field_along_path [qlty:function-complexity]

@qltysh

qltysh Bot commented Aug 13, 2026

Copy link
Copy Markdown

Qlty


Coverage Impact

⬆️ Merging this pull request will increase total coverage on main by 0.1%.

Modified Files with Diff Coverage (5)

RatingFile% DiffUncovered Line #s
Coverage rating: C Coverage rating: C
...forest_admin_datasource_toolkit/components/query/projection.rb100.0%
Coverage rating: B Coverage rating: B
...ib/forest_admin_agent/serializer/forest_serializer_override.rb100.0%
Coverage rating: A Coverage rating: A
...dmin_agent/lib/forest_admin_agent/utils/query_string_parser.rb100.0%
Coverage rating: D Coverage rating: D
..._rails/app/controllers/forest_admin_rails/forest_controller.rb100.0%
Coverage rating: A Coverage rating: A
...forest_admin_datasource_toolkit/validations/field_validator.rb100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

PMerlet and others added 2 commits August 14, 2026 15:04
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
PMerlet force-pushed the fix/deep-projection-serialization branch 2 times, most recently from 5eba710 to 076e32c Compare August 14, 2026 13:12
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>
@linear-code

linear-code Bot commented Aug 14, 2026

Copy link
Copy Markdown
@PMerlet
PMerlet merged commit a1e8cbd into main Aug 14, 2026
56 checks passed
@PMerlet
PMerlet deleted the fix/deep-projection-serialization branch August 14, 2026 15:21
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))
@forest-bot

Copy link
Copy Markdown
Member

🎉 This PR is included in version 1.38.3 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3 participants