Skip to content

Add conditional rendering of attributes and associations#31

Open
Nitemaeric wants to merge 11 commits into
mainfrom
claude/github-issues-review-wcm8it
Open

Add conditional rendering of attributes and associations#31
Nitemaeric wants to merge 11 commits into
mainfrom
claude/github-issues-review-wcm8it

Conversation

@Nitemaeric

Copy link
Copy Markdown
Contributor

Context

Closes #22.

Serializers should be able to conditionally include or exclude a field from their output, e.g. attribute :id, if: :admin?. The challenge was adding this on the per-object hot path in Serializer#as_json without regressing performance for serializers that don't use the feature (the suite benchmarks itself against Panko/Alba/AMS).

Changes

  • attribute, association, and the attributes/associations shorthands now accept if: and unless: options. Each accepts:
    • a Symbol — sent to the serializer instance (send(:admin?)), no allocation; or
    • a Proc — evaluated in the serializer's context via instance_exec (so it can reference object and, once contexts land, things like current_user).
    attribute :email,    if: :admin?
    attribute :view_count, unless: -> { object.draft? }
    belongs_to :secrets, if: :admin?
  • Performance: a field is only flagged :conditional at definition time when it actually declares if:/unless:. The as_json loop then short-circuits with a single hash lookup (next if attr_options[:conditional] && ...), so fields without conditions never run render_field? and pay no extra method dispatch. if/unless evaluation only happens for fields that opted in.
  • Specs cover Symbol conditions, Proc conditions, unless, combined if+unless, association conditions, and the attributes shorthand applying a shared condition.

Note: this also fixes a latent issue where attributes/associations forwarded ** keyword args to attribute/association, which didn't accept them.

🤖 Generated with Claude Code


Generated by Claude Code

claude added 11 commits June 27, 2026 17:35
Support `if:` and `unless:` options on `attribute`/`association` (and the
`attributes`/`associations` shorthands), accepting a Symbol (sent to the
serializer instance) or a Proc (evaluated in its context):

    attribute :email, if: :admin?
    belongs_to :secrets, if: -> { current_user.admin? }

Conditions are evaluated per object in `#as_json`. To avoid penalising
serializers that don't use the feature, a field is only flagged
`:conditional` when it declares `if:`/`unless:`; the hot path then skips
condition evaluation with a single hash lookup, so unconditional fields
incur no extra method dispatch.

Closes #22.
The push trigger filtered on `branches: ["*"]`, but in GitHub Actions
filter syntax `*` does not match across `/`, so the Ruby (tests, lint)
and Benchmarks jobs were skipped for any branch with a slash in its name
(e.g. `claude/...`, `feature/...`). Use `**` to match all branches.
ruby-head is a nightly, moving target; native gems (e.g. rugged via the
undercover dev dependency) regularly fail to compile against it, which
fails `bundle install` before any tests run. Mark the ruby-head matrix
entry continue-on-error so nightly toolchain breakage doesn't turn the
whole run red, while still surfacing the result.
When serializing a collection (e.g. a has_many association), every item
went through lookup_serializer individually. The resolved class was
memoised in Serialization.cache, but each sibling still allocated the
4-element cache key and hashed it to hit that cache.

Since items of the same class always resolve to the same serializer,
resolve it once and reuse it across siblings, falling back to a fresh
lookup only when an item's class changes (so heterogeneous and nested
collections stay correct).

On a 500-item homogeneous collection this removes one allocation per
item (~25% fewer) and roughly doubles throughput on the lookup-bound
path.
Add a second benchmark pass under `ruby --yjit` so the job summary
reports both interpreter and YJIT numbers side by side, each under its
own heading.
transmutation is a path gem (../) in the benchmark suite, so swapping
lib/ to main's version lets the same job benchmark both revisions on the
same runner. The job summary now reports a head-vs-main × interpreter-
vs-YJIT matrix, making the performance impact of a branch easy to see.
Replace the four-pass lib-swap with a single-process comparison. The
benchmark job runs on Ruby 4.0 with RUBY_NAMESPACE=1; the harness loads
main's library (exported via git archive) into an isolated Namespace and
adds a `transmutation (main)` row beside `transmutation` in every group.

This brings CI back to two passes (interpreter, YJIT), each emitting a
single table where head and main are directly comparable. Loading main
is guarded and non-fatal: if the experimental Namespace feature is
unavailable or errors, the suite benchmarks head alone.
The guard required Namespace.enabled? to exist, which short-circuited the
whole comparison when that method is absent. Relax the guard to attempt
the load whenever the Namespace constant is present, and report the exact
constant-availability or load failure so CI shows why head-only ran.
Ruby's experimental Namespace feature isn't compiled into CI's prebuilt
Ruby, so it can't load two revisions in one process there. Instead, load
main's source into an anonymous module with string module_eval: a
`module Transmutation` nests under the receiver, yielding an isolated
`wrapper::Transmutation` copy with no source rewriting and no Zeitwerk.
The copy is named (TransmutationMain) so the serializer's namespace-based
association lookup resolves its sibling serializers.

The harness adds a `transmutation (main)` row beside `transmutation` in
every group, so a single table compares both revisions. Benchmark job
returns to stock Ruby 3.4, two passes (interpreter, YJIT). Also make the
row label tolerant of names without a loaded gemspec.
The working-tree copy is the branch under review, but it was labelled
"transmutation <version>" (from the loaded gemspec), which read like the
canonical gem rather than the branch. Label it "transmutation (HEAD)" so
each table pairs it explicitly against "transmutation (main)" (the
existing gem version exported from origin/main).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add conditional rendering of attributes and associations

2 participants