Skip to content

Instantiate validators at definition time#2657

Draft
ericproulx wants to merge 1 commit intomasterfrom
revisit_validators
Draft

Instantiate validators at definition time#2657
ericproulx wants to merge 1 commit intomasterfrom
revisit_validators

Conversation

@ericproulx
Copy link
Contributor

@ericproulx ericproulx commented Feb 12, 2026

Summary

Validators are now instantiated once at route definition time rather than per-request via `ValidatorFactory`. This eliminates repeated object allocation on every request and moves expensive setup (option parsing, converter building, message formatting) out of the hot path.

Because validator instances are shared across all concurrent requests they are frozen after initialization, enforced by a `Validators::Base.new` override that calls `super.freeze`. All inputs (`options`, `opts`, `attrs`) arrive pre-frozen from the DSL boundary via `DeepFreeze` and `Array#freeze`, so subclass ivars derived from them are frozen by construction. A new `Grape::Util::DeepFreeze` helper recursively freezes Hash/Array/String values while intentionally leaving Procs, coercers, Classes, and other mutable objects unfrozen.

Changes

Core

  • `ParamsScope#validate` instantiates the validator class directly and stores the instance in `namespace_stackable[:validations]`; removes `ValidatorFactory`
  • `Endpoint#run_validators` reads validator instances directly from `saved_validations` and removes the `validations` enumerator method
  • `ParamsScope` is frozen at the end of `initialize`; `element` is no longer a public reader; `parent` and `Attr#key`/`Attr#scope` changed from `attr_accessor` to `attr_reader`
  • `full_path` is now cached at init time as `@full_path` (via private `build_full_path`) and exposed as an `attr_reader`, making it safe to call on a frozen scope
  • `coerce_type` now receives only the pre-extracted coercion keys (`validations.extract!(:coerce, :coerce_with, :coerce_message)`) so callers don't need to delete them afterwards
  • `derive_validator_options` result is frozen before being passed to validators
  • `depends_on_met?` refactored to use `Enumerable#all?` with destructuring

`Validators::Base`

  • `Base.new` overrides `.new` to call `super.freeze`, ensuring every validator is immutable after construction
  • `@attrs` frozen via `Array(attrs).freeze`; `@option` deep-frozen via `Grape::Util::DeepFreeze.deep_freeze`
  • `fail_fast?` promoted to explicit public method
  • `validate_param!` promoted to `protected` with a `NotImplementedError` default
  • New private helpers extracted: `validation_error!`, `hash_like?` (replaces inline `respond_to?(:key?)` checks), `option_value`, `scrub`, and a refactored `message` with optional block for computed fallbacks

`Grape::Util::DeepFreeze`

  • New module with a single `deep_freeze(obj)` function
  • Freezes Hash (keys + values), Array (elements), and String recursively
  • Returns all other types (Proc, Class, coercers, etc.) untouched

Validator-level eager initialization

  • `AllowBlankValidator`: caches `@value` and `@exception_message`
  • `AllOrNoneOfValidator` / `AtLeastOneOfValidator` / `MutuallyExclusiveValidator`: cache exception messages; minor logic cleanups (`.any?`, `@attrs.length`)
  • `CoerceValidator`: resolves type and builds converter at definition time; `@converter` is left unfrozen automatically (DeepFreeze skips non-Hash/Array/String types); `valid_type?` removed in favour of inline `is_a?(Types::InvalidValue)`; `params:` argument now passes a scalar instead of a single-element array
  • `ContractScopeValidator`: no longer inherits from `Base`; simple `initialize(schema:)` called directly from `ContractScope`; adds standalone `fail_fast?` returning `false`
  • `DefaultValidator`: pre-builds `@default_call` lambda at init; reads `@option` directly (drops separate `@default` ivar)
  • `ExactlyOneOfValidator`: caches both `@exactly_one_exception_message` and `@mutual_exclusion_exception_message`
  • `ExceptValuesValidator`: validates that Proc must have arity zero at init (raises `ArgumentError` otherwise); wraps value in `@excepts_call` lambda for a uniform call interface
  • `LengthValidator`: uses `values_at` for option extraction
  • `PresenceValidator` / `RegexpValidator` / `SameAsValidator`: cache exception messages
  • `ValuesValidator`: splits into `@values_call` + `@values_is_predicate` to distinguish zero-arity collection procs from per-element predicate procs

Specs

  • New `DeepFreezeSpec`
  • New specs for `SameAsValidator` and `ExceptValuesValidator`
  • `ContractScopeValidatorSpec` / `custom_validations_spec` updated for the new instantiation model

Test plan

  • `bundle exec rspec`
  • Verify no regressions in validation behaviour
  • Confirm frozen validator instances raise `FrozenError` on any attempt to mutate state at request time

@ericproulx ericproulx marked this pull request as draft February 12, 2026 08:42
@github-actions
Copy link

github-actions bot commented Feb 12, 2026

Danger Report

No issues found.

View run

@ericproulx ericproulx force-pushed the revisit_validators branch 2 times, most recently from 5d145a5 to f87920f Compare February 12, 2026 08:49
@ericproulx
Copy link
Contributor Author

Missing UPGRADING notes. Working on it

@ericproulx ericproulx force-pushed the revisit_validators branch 3 times, most recently from 2ecb403 to cf04c9d Compare February 12, 2026 13:04
@dblock
Copy link
Member

dblock commented Feb 12, 2026

Is there a tradeoff with things like @exception_message = message(:all_or_none) being always allocated? can they become class variables?

@ericproulx
Copy link
Contributor Author

Is there a tradeoff with things like @exception_message = message(:all_or_none) being always allocated? can they become class variables?
message involves @option so it can't be a class variable.

@ericproulx ericproulx force-pushed the revisit_validators branch 12 times, most recently from e16efcf to 0b9e34b Compare February 18, 2026 22:27
@ericproulx ericproulx force-pushed the revisit_validators branch 8 times, most recently from a503556 to 2b16ba1 Compare February 23, 2026 11:05
@ericproulx ericproulx force-pushed the revisit_validators branch 7 times, most recently from b744723 to 30b09ea Compare February 28, 2026 17:03
@ericproulx ericproulx force-pushed the revisit_validators branch 17 times, most recently from 52b7862 to adac9ed Compare March 14, 2026 21:54
@ericproulx ericproulx marked this pull request as ready for review March 14, 2026 22:05
@ericproulx ericproulx force-pushed the revisit_validators branch 2 times, most recently from 2a39a4d to 2d51b32 Compare March 14, 2026 22:35
@ericproulx ericproulx marked this pull request as draft March 14, 2026 22:35
Validators are now instantiated once at route definition time rather
than per-request, eliminating repeated allocation overhead. Instances
are frozen to make them safe for sharing across requests.

Freezing strategy: inputs (attrs, options, opts) are frozen at the DSL
boundary before entering the validator, so subclass ivars derived from
them are frozen by construction. Base.new reduces to super.freeze.
Remove freeze_state! and ValidatorFactory.

ParamsScope: precompute full_path via build_full_path before
instance_eval so child scopes can read the parent path immediately.
Simplify meets_hash_dependency? with all? and dependency.first.

Validators::Base: add validation_error! helper to replace repeated
Grape::Exceptions::Validation.new calls across single-attr validators.

Fix DefaultValidator to always dup duplicable default values regardless
of frozen state, preserving per-request isolation.

Add DeepFreeze utility (freezes Hash/Array/String recursively, skips
Procs, coercers, and classes). Add specs for DeepFreeze,
SameAsValidator, and ExceptValuesValidator.
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.

2 participants