Add conditional rendering of attributes and associations#31
Open
Nitemaeric wants to merge 11 commits into
Open
Add conditional rendering of attributes and associations#31Nitemaeric wants to merge 11 commits into
Nitemaeric wants to merge 11 commits into
Conversation
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).
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
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 inSerializer#as_jsonwithout regressing performance for serializers that don't use the feature (the suite benchmarks itself against Panko/Alba/AMS).Changes
attribute,association, and theattributes/associationsshorthands now acceptif:andunless:options. Each accepts:send(:admin?)), no allocation; orinstance_exec(so it can referenceobjectand, once contexts land, things likecurrent_user).:conditionalat definition time when it actually declaresif:/unless:. Theas_jsonloop then short-circuits with a single hash lookup (next if attr_options[:conditional] && ...), so fields without conditions never runrender_field?and pay no extra method dispatch.if/unlessevaluation only happens for fields that opted in.unless, combinedif+unless, association conditions, and theattributesshorthand applying a shared condition.Note: this also fixes a latent issue where
attributes/associationsforwarded**keyword args toattribute/association, which didn't accept them.🤖 Generated with Claude Code
Generated by Claude Code