Skip to content

Conversation

@ericproulx
Copy link
Contributor

@ericproulx ericproulx commented Feb 12, 2026

Instantiate validators at compile time instead of runtime

Summary

Validators are now instantiated at compile time (in ParamsScope and ContractScope) rather than at request time via ValidatorFactory. Validators are thread safe since their initial state always stays the same. This eliminates per-request object allocation overhead and allows expensive setup (message resolution, option parsing) to happen once.

Changes

Core architecture

  • ParamsScope#validate and ContractScope now store validator instances instead of option hashes in namespace_stackable[:validations]
  • ParamsScope#coerce_type receives validations.extract!(:coerce, :coerce_with, :coerce_message) instead of the full hash, replacing the individual delete calls that followed
  • Endpoint#run_validators takes a request: keyword arg, reads validators directly from inheritable_setting.route[:saved_validations], and short-circuits with return if validators.blank?
  • Removed Endpoint#validations enumerator method entirely
  • Removed ValidatorFactory -- its indirection is no longer needed

Validator base (Validators::Base)

  • fail_fast? is now an explicit public method
  • Moved validate!, message, options_key? to private
  • options_key? simplified to a single key parameter (removed unused optional second arg)
  • Added new private helpers: hash_like?, option_value, scrub
  • message now accepts a block for lazy default message generation (default_key || yield)
  • @fail_fast, @allow_blank = opts.values_at(:fail_fast, :allow_blank) in constructor
  • self.inherited hook reorganized (moved in file, unchanged behavior)

Validator optimizations -- eagerly compute in initialize

  • AllowBlankValidator: caches @value (via option_value) and @exception_message; uses hash_like? and scrub helpers
  • CoerceValidator: resolves type and builds converter in constructor; caches @exception_message; inlines valid_type? check; removes type method, converter attr_reader, and valid_type? method; uses hash_like? instead of params.is_a?(Hash)
  • DefaultValidator: pre-builds a @default_call lambda; removes validate_param! entirely, inlining the call into validate!
  • ExceptValuesValidator: validates proc arity in constructor; pre-builds @excepts_call lambda via option_value; caches @exception_message; uses hash_like?
  • LengthValidator: validates arguments and builds @exception_message using block-based message { build_message_exception }; renames build_message to private build_message_exception; simplifies nil checks
  • ValuesValidator: validates proc arity in constructor; pre-builds @values_call lambda via option_value; caches @exception_message; uses hash_like? and scrub helpers
  • RegexpValidator: caches @value (via option_value) and @exception_message; removes local scrub method (now uses Base#scrub); uses hash_like?
  • SameAsValidator: eagerly resolves @value and @exception_message (with I18n.t formatting) in constructor; removes build_message
  • PresenceValidator: caches @exception_message; uses hash_like?
  • AllOrNoneOfValidator, MutuallyExclusiveValidator: cache @exception_message in constructor
  • AtLeastOneOfValidator: caches @exception_message; inverts guard to return if keys_in_common(params).any?
  • ExactlyOneOfValidator: caches two messages: @exactly_one_exception_message and @mutual_exclusion_exception_message
  • ContractScopeValidator: no longer inherits from Base; standalone class with constructor signature (schema:) and its own fail_fast? method

params: argument simplification across all validators

  • Grape::Exceptions::Validation.new(params: [@scope.full_name(attr_name)]) simplified to params: @scope.full_name(attr_name) throughout, since Validation now coerces to array internally

Validation exception

  • Grape::Exceptions::Validation -- attr_accessor :params, :message_key changed to attr_reader; params is now always coerced to an array in the constructor

Specs

  • Replaced shared let_it_be(:app) with per-describe let(:app) blocks across all validator specs so each test group defines only the route(s) it needs
  • Removed endpoint_run_validators.grape notification expectations for empty-validator cases (no longer instrumented when validators are blank)

Removed test-prof dependency

  • Removed test-prof gem from Gemfile
  • Deleted spec/config/spec_test_prof.rb
  • Removed config from the spec helper directory loader since no config files remain

Test plan

  • Run full test suite (bundle exec rspec)
  • Verify no regressions in validation behavior
  • Benchmark request throughput to confirm reduced per-request allocations

@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 2 times, most recently from 89c0146 to 2ecb403 Compare February 12, 2026 13:02
- Store validator instances in ParamsScope/ContractScope and have Endpoint#run_validators read them directly
- Remove ValidatorFactory indirection and eagerly compute validator messages/options in constructors
- Normalize Grape::Exceptions::Validation params handling and refactor validator specs to define routes per example group
- Drop test-prof dependency and its spec config

Co-authored-by: Cursor <cursoragent@cursor.com>
@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?

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