Validate required Hash params scopes without the attributes iterator - #2927
Closed
ericproulx wants to merge 1 commit into
Closed
Validate required Hash params scopes without the attributes iterator#2927ericproulx wants to merge 1 commit into
ericproulx wants to merge 1 commit into
Conversation
ericproulx
force-pushed
the
perf/root-scope-validation-fast-path
branch
from
September 10, 2026 19:53
e8751c6 to
e42e4e8
Compare
Danger ReportNo issues found. |
Most validators of most endpoints sit on the root `params` scope or a required Hash scope under it, and for them `Validators::Base` ran the generic machinery: `should_validate?`, which resolves the scope's params and walks the parent chain, then an `AttributesIterator` pass that resolves the params again, checks for arrays and the empty-optional sentinel and yields once per attribute, then a block that re-checks the scope's requiredness and dependency. For a root-scope validator that plumbing took about 570 of its 840 ns; a nested one paid more. When a scope and every one of its ancestors is required and depends on nothing, `should_validate?` is true for every request and has no side effects; `ParamsScope#always_validated?` answers that. If the scope also does not iterate elements and its params resolve to a Hash, the iterator path reduces to: for each attribute, `validate_param!(attr, params)` if it is required or present. `#validate` skips `should_validate?` for such a scope, and `#validate!` resolves the params once and runs that loop. Array scopes, `given`, optional scopes and their descendants, and params that resolve to something other than a Hash keep the existing path. `DefaultValidator` and the multiple-params validators, which override `#validate!`, only skip `should_validate?`. The flag is set in `#initialize`, since validators are frozen once built. A scope's parent, optionality, dependency and type are fixed before its block declares anything, so it is final by then. An Array group handed a Hash reporting only its own type error, without its members being validated against the Hash, had no spec; routing that case through the new path passed the whole suite. It now has one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
force-pushed
the
perf/root-scope-validation-fast-path
branch
from
September 10, 2026 20:02
e42e4e8 to
358b8a3
Compare
4 tasks
ericproulx
added a commit
that referenced
this pull request
Sep 11, 2026
…tributes iterator A validator on `requires :items, type: Array do ... end` went through the AttributesIterator for every element: an index bookkeeping call, a yield per attribute, an emptiness test, and per attribute the scope's `required?` and `meets_dependency?`, plus a `should_validate?` walk up the scope chain before any of it. When every scope on the chain is required with no dependency and this is the only one that iterates elements, those answers are fixed: the scope's params are the request's Array as it came in, and the only thing the iterator still contributes is the element index the error names carry. `Validators::Base#validate_elements!` covers that case the way #2927's `validate_attributes!` covers a Hash scope: it records each index and runs the same per-attribute check. Nested Array scopes, optional ones and `given` blocks keep the iterator. Adds a spec that an optional Array scope skips its empty elements: nothing pinned that, and letting optional scopes take the new path passed the suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ericproulx
added a commit
that referenced
this pull request
Sep 11, 2026
… iterator A validator on `requires :items, type: Array do ... end` (or `optional`) went through the AttributesIterator for every element: an index bookkeeping call, a yield per attribute, an emptiness test, and per attribute the scope's `required?` and `meets_dependency?`. When the scope depends on no other param, every scope above it is always validated, and it is the only one on the chain that iterates elements, those answers are fixed: its params are the request's Array as it came in, and the iterator only contributes the element index the error names carry and, for an optional scope, passing over empty elements. `Validators::Base#validate_elements!` covers that case the way #2927's `validate_attributes!` covers a Hash scope. `ParamsScope#validated_when_given?` names the condition, and `always_validated?` is that plus being required; a required scope like that also skips `should_validate?`, which always answers true for it. Nested Array scopes and `given` blocks keep the iterator. Adds three specs for behaviour nothing pinned, each found by a mutation that passed the suite: an optional Array scope skips its empty elements, it is not validated at all when every element is blank, and a Hash scope given an Array reports only itself, not the optional Array scope inside. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5 tasks
Contributor
Author
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.
Summary
Most validators of most endpoints sit on the root
paramsscope or a required Hash scope under it, and for themValidators::Baseran the generic machinery:scope.should_validate?, which resolves the scope's params and walks the parent chain, then anAttributesIteratorpass that resolves the params again, checks for arrays and the empty-optional sentinel and yields once per attribute, then a block that re-checks the scope's requiredness and dependency. For a root-scope validator that plumbing took about 570 of its 840 ns; a nested one paid more.When a scope and every one of its ancestors is required and depends on nothing,
should_validate?is true for every request and has no side effects.ParamsScope#always_validated?answers that. If the scope also does not iterate elements and its params resolve to a Hash, the iterator path reduces to: for each attribute,validate_param!(attr, params)if it is required or present. So:#validateskipsshould_validate?for such a scope.#validate!resolves the scope's params once and runs that loop (#validate_attributes!).given, optional scopes and their descendants, and params that resolve to something other than a Hash keep the existing path.DefaultValidatorand the multiple-params validators, which override#validate!, only skipshould_validate?.#initialize, since validators are frozen once built; a scope's parent, optionality, dependency and type are fixed before its block declares anything.Per validator: root presence 658 → 311 ns, root coerce 728 → 406 ns, nested coerce 1.77 → 1.02 µs.
Benchmarks
Median of 7 interleaved subprocess rounds against master, Ruby 4.0.6, no JIT:
valuesand defaults,mutually_exclusive(10 validators)declared(params), optional nested HashUnder YJIT, an earlier root-only version of this measured +11.3% on the JSON POST, +12.0% on the 10-validator GET and +7.3% on the 3-param GET.
Missing spec, added
An Array group handed a Hash reports only its own type error; its members are not validated against the Hash as though it were one element. Nothing pinned that: sending such a scope down the new path passed the whole suite while adding
items[id] is missingto the error. The new spec passes before and after this change and fails in that case.Behaviour
Byte-identical to master over a 104-case validation matrix, under both the HashWithIndifferentAccess and Hash params builders: every built-in validator (
values,except_values,regexp,length,same_as,allow_blank,default,as,mutually_exclusive,all_or_none_of,at_least_one_of,given,fail_fast, a custom validator), required and optional nested Hashes three levels deep, awithgroup inside a nested Hash, a String or an Array sent where a Hash is declared, a Hash sent for an Array group, andtype: JSONgiven a Hash, an Array and garbage. The routing and coercion matrices are unchanged too.Test plan
required?fails 29 specs; ignoring whether the key is present, 6; taking the new path for every scope, 74; stopping at the first error, 2; skippingshould_validate?for every scope, 6;always_validated?ignoring optionality, 7; ignoring the parent chain, 1; taking the new path for array scopes, 1 (the new spec); dropping the Hash guard, 2.🤖 Generated with Claude Code