Skip to content

Validate the elements of an Array params scope without the attributes iterator - #2933

Closed
ericproulx wants to merge 1 commit into
perf/root-scope-validation-fast-pathfrom
perf/array-element-validation-fast-path
Closed

ericproulx wants to merge 1 commit into
perf/root-scope-validation-fast-pathfrom
perf/array-element-validation-fast-path

Conversation

@ericproulx

@ericproulx ericproulx commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Stacked on #2927 (it builds on ParamsScope#always_validated? and follows validate_attributes!); this PR targets that branch, so the diff shows only this change.

Summary

A validator on requires :items, type: Array do ... end, or optional, went through the AttributesIterator for every element. That meant an index bookkeeping call, a yield per attribute, an emptiness test, and, per attribute, the scope's required? and meets_dependency?.

Those answers are fixed when three things hold:

  • the scope depends on no other param;
  • every scope above it is always validated;
  • it is the only scope on the chain that iterates elements.

Its params are then the request's Array as it came in. The iterator only contributes the element index that the error names carry (items[3][id] is missing) 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:

  • It records each element's index in the tracker.
  • In an optional scope, it passes over the elements the iterator did: empty ones, and the placeholder map_params puts where the scope's params were missing.
  • It runs the same per-attribute check, required? || (hash_like?(element) && element.key?(attr_name)), collecting the errors in the same order.

ParamsScope#validated_when_given? names the condition, and always_validated? is now that plus being required. A required scope like that also skips should_validate?, which always answers true for it. An optional one still asks, because its "every element is blank" check uses blank?, which also covers false and whitespace. Nested Array scopes and given blocks keep the iterator.

Benchmarks

Median of interleaved subprocess rounds, Ruby 4.0.6. POST with a JSON body {"items": [...]}, where each element holds requires :id, type: Integer, optional :name, type: String and optional :qty, type: Integer, values: 1..100.

scenario vs master + #2927, no JIT (9 rounds) YJIT (7 rounds)
requires, 1 element +28.5%
requires, 10 elements +32.4% +33.6%
requires, 50 elements +40.5% +38.9%
optional, 10 elements +21.2% +25.9%

Paths without an Array scope are flat: JSON body −0.3%, nested Hash body −0.8%, query params +0.4% (another run: −1.7%, with base-vs-base at −0.8%). The noise floor today is about ±1.6%.

Behaviour

Byte-identical to master and to #2927 over 164 requests: 82 cases against both the Hash and the HashWithIndifferentAccess params builder. They cover:

  • required and optional Array scopes with every built-in validator, a custom one, as:, default:, and mutually_exclusive;
  • nested Hash and Array scopes inside elements, and given inside an element;
  • Array[JSON], and Arrays of Arrays;
  • fail_fast, an Array inside a Hash, and a with group inside an Array;
  • a Hash scope given an Array, with optional and required Array scopes inside it;
  • malformed elements (nil, false, blank Strings, numbers, nested Arrays), a Hash or a String where the Array should be, and query-string Arrays.

The 104-case validation matrix from #2927 is also byte-identical.

Missing specs, added

Each of these passed the whole suite when mutated. The new specs pass on master and on this branch:

  • An optional Array scope skips the elements that are empty. [{}, {not_key: 'foo'}] reports only items[1][key] is missing.
  • An optional Array scope is not validated at all when every element is blank. [false, ' '] passes, because should_validate? uses blank?.
  • A Hash scope given an Array reports only itself. meta: [{}] answers meta is invalid and nothing about the optional Array scope inside it.

Test plan

  • Full RSpec suite passes locally.
  • RuboCop clean.
  • Mutation-checked, 15 of 15 killed, each against the validation, endpoint, API, exception and integration specs:
    • ignoring the nesting depth;
    • recording no index, or index 0;
    • ignoring required?, or validating absent optional attributes;
    • dropping the hash_like? guard, or the Array guard;
    • stopping at the first error;
    • dropping the empty-element skip, or applying it to required scopes;
    • validating the placeholder, or treating only nil as empty;
    • letting optional scopes skip should_validate?;
    • validated_when_given? ignoring the dependency, or the parent chain.
  • CI green.

🤖 Generated with Claude Code

@ericproulx
ericproulx force-pushed the perf/array-element-validation-fast-path branch from 6332e9e to 9a7ec1c Compare September 11, 2026 09:20
@github-actions

github-actions Bot commented Sep 11, 2026

Copy link
Copy Markdown

Danger Report

No issues found.

View run

… 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>
@ericproulx
ericproulx force-pushed the perf/array-element-validation-fast-path branch from 9a7ec1c to 1fdd620 Compare September 11, 2026 09:54
@ericproulx ericproulx changed the title Validate the elements of a required Array params scope without the attributes iterator Validate the elements of an Array params scope without the attributes iterator Sep 11, 2026
@ericproulx

Copy link
Copy Markdown
Contributor Author

Closing in favour of #2936, which combines #2922#2934 into one PR, re-benchmarked as a whole against master. The write-up here (behaviour matrix, mutation results) still describes this part of the change.

@ericproulx ericproulx closed this Sep 11, 2026
@ericproulx
ericproulx deleted the perf/array-element-validation-fast-path branch September 15, 2026 21:49
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.

1 participant