LaunchKit
← All posts
· 16 min read · by The LaunchKit team · 0 views

Rails validations in practice

Rails validations are five features wearing one name. The declaration, validates :email, presence: true, gives nobody any trouble. The other four do: the context that decides whether a declaration runs at all, the condition that decides the same thing by a different route, the validator object that the declaration secretly instantiates, and the errors object that is a good deal more structured than full_messages suggests. Confusion about validations is almost always confusion about one of those four, and it always has the same shape: a validation that did not run, and nothing anywhere saying so.

Everything below was run against activerecord 8.1.3.1 and activemodel 8.1.3.1 on Ruby 4.0.5, with PostgreSQL 17.7. Source line numbers are from the installed gems.

on: :create is an if: with a name

Contexts and conditions look like two features and compile to one. From active_model/validations.rb, in the validate class method:

if options.key?(:on)
  options = options.merge(if: [predicate_for_validation_context(options[:on]), *options[:if]])
end

if options.key?(:except_on)
  options = options.dup
  options[:except_on] = Array(options[:except_on])
  options[:unless] = [
    ->(o) { options[:except_on].intersect?(Array(o.validation_context)) },
    *options[:unless]
  ]
end

Lines 173 and 177. on: becomes an :if, except_on: becomes an :unless, and both end up in the same callback options as anything you wrote by hand. A Rails validation context is a conditional validation whose condition is "which name did the caller pass", and the only thing that makes it different in practice is that the caller passes it rather than the model deciding.

Which matters more than it sounds, and here is the part that bites. A context replaces the default one, it does not add to it:

class Account < ActiveRecord::Base
  validates :email,      presence: true
  validates :slug,       presence: true, on: :create
  validates :plan,       presence: true, on: :update
  validates :vat_number, presence: true, on: :billing
end
new record, valid?                    -> false ["Slug can't be blank"]
new record, valid?(:billing)          -> false ["Vat number can't be blank"]
persisted, save                       -> false ["Plan can't be blank"]
persisted, save(context: :billing)    -> false ["Vat number can't be blank"]

The last line is the trap. save(context: :billing) on a persisted record did not check plan. Its on: :update validator never ran, because validation_context was :billing and not :update. Anyone reaching for a custom context to add a check has silently removed all the others. valid? takes an Array for exactly this reason, and validation_context is public on the record and returns whatever you passed, Array included.

except_on: is new in 8.1 ("Add except_on: option for validation callbacks" in the Active Model CHANGELOG) and is the honest inverse. validates :vat_number, presence: true, except_on: :create saved a new record with a blank vat_number and refused the same record on update.

The context a plain form object never enters

ActiveModel::Model and Active Record disagree about what a bare valid? means, and the disagreement is silent. Active Record overrides valid? so that the default context is new_record? ? :create : :update. Plain Active Model has no such notion, so the context is nil and an on: :create validator is skipped. Same two lines of declaration, opposite outcomes:

Account (ActiveRecord)  #valid?         -> false ["Slug can't be blank"]
Form (ActiveModel::Model) #valid?       -> true  []
Form (ActiveModel::Model) #valid?(:create) -> false ["Slug can't be blank"]

This is worth knowing because form objects are where on: looks most attractive and works least. The LaunchKit boilerplate's onboarding steps are ActiveModel::Model classes under app/forms, and Onboarding::BaseForm#save opens with return false unless valid?, no argument. None of the seven shipped step forms declares on: today, so nothing there is broken. The point is what happens to the next person who adds one: the form saves, the spec that exercises the happy path stays green, and the validation is decoration.

For a form object, use if: or no condition at all. A context on a class whose only caller passes no context is a validation you have deleted in a way that looks like configuration.

Why conditional validations spread

Conditions written on the validates line apply to every validator on that line, and almost nobody means that the first time:

validates :vat_number, presence: true, length: { minimum: 4 }, if: :business?
validates :slug,       presence: { if: :business? }, length: { minimum: 3 }
plan "solo":     ["Slug is too short (minimum is 3 characters)"]
plan "business": ["Vat number is too short (minimum is 4 characters)",
                  "Slug is too short (minimum is 3 characters)"]

The line-level if: suppressed both the presence and the length check on vat_number for a solo account. The hash form on slug suppressed only the presence check, and the length check ran for everybody. Two spellings, two meanings, and the error output is the only place the difference shows.

Combinations are all AND. if: [:business?, :european?] fired for business and FR, and stayed quiet for business and US and for solo and FR. if: together with unless: is the same: both have to agree before the validation runs. Strings stopped being accepted some releases ago and now fail loudly at class definition, which is the one kind soul in this area:

ArgumentError: Passing string to be evaluated in :if and :unless conditional options is not
supported. Pass a symbol for an instance method, or a lambda, proc or block, instead.

Here is the position. Conditions metastasise because each one is cheap to add and free to forget. if: :business? reads as a fact about the data, and six months later it is a fact about a signup path, a backfill script and an admin form, none of which the model knows about. Nothing collects them: there is no Account.conditional_validations to read, the condition is a Symbol inside a callback, and the only way to answer "did this record get checked" is to reconstruct every predicate by hand. A context at least appears at the call site, where somebody reading the controller can see it. Reach for on: when the answer depends on who is saving; reach for if: when it depends on what the record holds. The test for any given condition is whether a reader of the calling code can tell that it fired. If business? is a column, if: is right and a context is theatre.

A validator class or a validate method

validates :email, sirename: true is not magic, it is const_get on a camelized string. active_model/validations/validates.rb line 121 builds "#{key.to_s.camelize}Validator", line 124 resolves it, line 126 gives up:

ArgumentError: Unknown validator: 'SirenameValidator'

Raised at class definition time, which is the good case. What you get for subclassing ActiveModel::EachValidator is the option plumbing: allow_nil, allow_blank, message, on, if all work without you writing a line, because active_model/validator.rb line 153 handles them before your validate_each is called:

next if (value.nil? && options[:allow_nil]) || (value.blank? && options[:allow_blank])

Read that line closely, because allow_nil: true does not skip "". A custom validator declared with allow_nil passed nil and failed the empty string, which is correct and is not what most people expect from the option name.

One more thing about validator objects: there is exactly one per declaration, shared by every record. active_model/validations/with.rb line 93 is validator = klass.new(options.dup, &block), called once, at class definition. Three records validated in a row and Probe.validators.first.object_id came back identical all three times. Any instance variable you set in a validator is shared state across every request in the process.

The choice between a validator class and a plain validate :method has one real criterion, which is whether a second model will ever use it. The boilerplate has both and they are on opposite sides. PasswordComplexityValidator is a class, hooked up with validates_with PasswordComplexityValidator on User, because "what counts as a strong password" is a rule any model with a password wants and the client-side hints mirror it. It subclasses ActiveModel::Validator rather than EachValidator, which is the right call for a validator that reads record.password and writes several errors onto one attribute, since there is no per-attribute loop to run. AiTemplate#variables_are_well_formed is a private method, because walking that model's variables jsonb column is not a rule anything else will ever want. The same reasoning that decides whether behaviour belongs in a concern decides this, and it is the same failure mode when you get it wrong: a class with one caller, parameterised for callers that never arrived.

The uniqueness race, run twice

Active Record says as much itself, in active_record/validations/uniqueness.rb: "uniqueness checks on the application level are inherently prone to race conditions", and then, thirty lines later, "In the rare case that a race condition occurs". The word doing the work there is "rare". It is exactly as rare as your concurrency, and a double-clicked signup button or a retried Stripe webhook is not rare at all.

Reproducing it needs the two connections interleaved, which is one callback:

class Account < ActiveRecord::Base
  validates :email, uniqueness: true

  # Block between the uniqueness SELECT and the INSERT, which is where the window is.
  after_validation do
    ARRIVED << :here
    sleep 0.01 while ARRIVED.size < 2
  end
end

Two threads, two pooled connections, same address:

-- validates :email, uniqueness: true, no unique index --
[0, {save: true, errors: [], id: 2}]
[1, {save: true, errors: [], id: 1}]
rows for race@example.com: 2
[[1, "race@example.com"], [2, "race@example.com"]]

Two rows. Both saves returned true, both error arrays empty, nothing logged, nothing raised. Then the same model with one line of migration added:

-- same model, unique index added --
[0, {raised: "ActiveRecord::RecordNotUnique",
     message: "PG::UniqueViolation: ERROR:  duplicate key value violates unique constraint
               \"index_accounts_on_email\""}]
[1, {save: true, errors: [], id: 3}]
rows for race2@example.com: 1

The full exception body carries the DETAIL line too, Key (email)=(dup@example.com) already exists., and e.cause is the PG::UniqueViolation underneath. So the index is the guarantee and the validation is the message, and you want both: without the index you get a duplicate, without the validation every collision reaches the user as a 500 instead of a form error.

The boilerplate does this consistently, which is the only thing that makes it checkable. Every uniqueness: true in its models has an index behind it: users.email_address at schema line 327, ai_templates.key at 60, transactions.stripe_id at 305, stripe_events.stripe_id at 265, referrals.referred_id at 133, and the two scoped ones, oauth_identities [provider, uid] at 121 and email_sequence_enrollments [user_id, sequence_key] at 81. Grepping for uniqueness and checking each hit against db/schema.rb takes two minutes and is worth doing on any codebase you inherit.

case_sensitive and the index it stops using

The case_sensitive option changed meaning in Rails 6.1 and the new meaning is "whatever your database collation says", which is not a constant. On this PostgreSQL, with the database created under the C collation, a plain validates :email, uniqueness: true emits:

SELECT 1 AS one FROM "accounts" WHERE "accounts"."email" = $1 LIMIT $2

With Dan@Example.com already stored, Account.new(email: "dan@example.com").valid? returned true. Same address to every human and to the mail server, two rows to the index. Ask for case_sensitive: false and the query changes shape:

SELECT 1 AS one FROM "accounts" WHERE LOWER("accounts"."slug") = LOWER($1) LIMIT $2

That is a function call on the column, so the btree index on slug is unusable. On 200000 rows, EXPLAIN ANALYZE for the two forms:

= lookup, plain btree
  Index Only Scan using index_accounts_on_slug  (actual time=0.013..0.013 rows=1 loops=1)
  Execution Time: 0.019 ms

LOWER() lookup, same index
  Seq Scan on accounts  (actual time=16.580..16.580 rows=1 loops=1)
    Filter: (lower((slug)::text) = 'slug-199999'::text)
    Rows Removed by Filter: 199998
  Execution Time: 16.588 ms

A seq scan on every signup, and a seq scan that gets slower every week. CREATE UNIQUE INDEX ... ON accounts (LOWER(slug)) restores it to 0.017 ms, and has the pleasant side effect of being the only unique constraint that actually matches what the validation checks.

The boilerplate takes the other road, and the other road is better. User has normalizes :email_address, with: ->(e) { e.strip.downcase }, so the address is lowercased before it is ever compared or stored, the validation stays a plain =, and the ordinary unique index on email_address is both fast and correct. Normalising on write costs you one decision, once, at the point where the data arrives. Normalising on read costs you an expression index you have to remember to create and a query plan nobody looks at.

Where the validation and the index disagree about NULL

NULL is the one case where the validator is stricter than the constraint, and it surprises people in the direction of a false sense of safety. With validates :vat_number, uniqueness: { scope: :country } and two records whose country is nil, the validator builds:

SELECT 1 AS one FROM "accounts" WHERE "accounts"."vat_number" = $1 AND "accounts"."country" IS NULL LIMIT $2

IS NULL, so it finds the existing row and reports ["Vat number has already been taken"]. A plain CREATE UNIQUE INDEX ON accounts (vat_number, country) does not, because SQL says two NULLs are not equal, so the index happily accepted the second row and the table ended up with 2. The application rule and the database rule disagree about a case the application rule appears to cover.

PostgreSQL 15 added the fix and Rails exposes it: add_index :accounts, [:vat_number, :country], unique: true, nulls_not_distinct: true emits ... USING btree (vat_number, country) NULLS NOT DISTINCT, and the duplicate insert then raises ActiveRecord::RecordNotUnique like any other. supports_nulls_not_distinct? is gated on database_version >= 15_00_00 in postgresql_adapter.rb line 283, and the schema dumper writes the option out, so it survives a db:schema:load.

The alternative, and often the better one, is a NOT NULL column with a real default. A scope column that can be NULL is usually a modelling decision nobody made on purpose.

create_or_find_by never finds

create_or_find_by stops working the moment the model gains the uniqueness validation it is there to cope with. The method is built on the exception, from active_record/relation.rb line 273:

The same create_or_find_by call takes two paths depending on whether the model carries a uniqueness validation. On the bare model the INSERT goes out, the database raises ActiveRecord::RecordNotUnique, and the rescue finds the existing row and returns it with id=1 and persisted true. On the validated model valid? stops the save, no INSERT is sent, so the exception the rescue is waiting for never arrives and the caller gets id=nil, persisted false and the error Email has already been taken.

def create_or_find_by(attributes, &block)
  with_connection do |connection|
    record = nil
    transaction(requires_new: true) do
      record = create(attributes, &block)
      record._last_transaction_return_status || raise(ActiveRecord::Rollback)
    end
    record
  rescue ActiveRecord::RecordNotUnique
    # ... find_by!(attributes)

Add validates :email, uniqueness: true and the validation stops the INSERT, so the database never raises, so the rescue never runs. Two models over the same table, one row already present:

Bare:      id=1    persisted=true
Validated: id=nil  persisted=false  errors=["Email has already been taken"]

An unsaved, invalid record handed back from a method whose name promises a persisted one, and create_or_find_by! raises ActiveRecord::RecordInvalid: Validation failed: Email has already been taken rather than the RecordNotUnique its rescue is waiting for. No deprecation, no warning, and a test with an empty table passes.

If you want that method's semantics, do the finding yourself: rescue ActiveRecord::RecordNotUnique around the save, translate it into errors.add(:email, :taken), and return false. Six lines, and they work whether or not the validation is there.

errors.add(:base) is not errors.add(:country)

:base is not a special error type, it is an attribute name that no attribute has. Everything else follows from one method, ActiveModel::Errors#full_message:

errors.full_message(:base,    "x") -> "x"
errors.full_message(:country, "x") -> "Country x"

That prefix is the entire mechanical difference. A record carrying one of each reports:

full_messages:    ["Pro is not sold in :country yet", "Country is not on the Pro list"]
errors[:base]:    ["Pro is not sold in :country yet"]
errors[:country]: ["is not on the Pro list"]
attribute_names:  [:base, :country]

Which decides where to put an error, and the rule is about the form rather than about the model. An error on :country names an input the user can go and change, and every form builder can point at it. An error on :base names none, so it can only ever be rendered in a summary block. Cross-field rules go on :base: "this plan is not sold in this country" is not the country field's fault and is not the plan field's fault. A rule that really is about one attribute goes on that attribute, even when the code that detects it looks at three columns, which is what Referral#referrer_is_not_referred does with errors.add(:referred_id, :invalid).

One behaviour to know before you reach for errors.add outside a validation: valid? clears the errors object first. Three errors added by hand, then a valid? call, and the two the validations produce are all that is left. Errors are a result, not a store.

Where the message comes from

Passing a Symbol to errors.add sends it through I18n, and the lookup chain is long enough that the error tells you the whole thing when nothing matches:

Translation missing. Options considered were:
- en.activerecord.errors.models.account.attributes.base.not_a_real_key
- en.activerecord.errors.models.account.not_a_real_key
- en.activerecord.errors.messages.not_a_real_key
- en.errors.attributes.base.not_a_real_key
- en.errors.messages.not_a_real_key

Most specific first, most general last, and the position you choose is a statement about reuse. The boilerplate uses two of those five and the split is deliberate. AiTemplate#variables_are_well_formed calls errors.add(:variables, :entry_without_name), and the message sits at en.activerecord.errors.models.ai_template.attributes.variables.entry_without_name, the first line of the chain, because "has an entry without a name" is only ever true of that one jsonb column on that one model. PasswordComplexityValidator calls errors.add(:password, :no_uppercase), and that message sits at en.errors.messages.no_uppercase, the last line of the chain, because the validator is written to be attached to any model and a message pinned to User would go missing the moment it was. Interpolation works from either position: invalid_source: "has an invalid source '%{source}'" is filled by the source: key passed to errors.add.

Writing the message inline as a String skips all of this, and for a message with exactly one caller that is a defensible choice. Writing it inline and then needing it in a mailer is how a String becomes a Symbol six months late.

What this does not cover

Scope, so nobody wastes an afternoon. Nothing here is about validates_associated or about how errors propagate through accepts_nested_attributes_for, both of which deserve their own reading. Nothing here is about client-side validation, which is a different mechanism with a different failure mode. Database check constraints, ActiveRecord::CheckViolation and the validates :status, inclusion: that duplicates one are worth a post and are not this one. And the interaction between validations and counter columns barely exists, because update_all and increment_counter do not run validations at all, which is the point of them.

The one thing to take away is the shape of every failure above: a validation that did not run, or ran against a rule the database does not share. Both are silent by construction. The index, the normalisation and the context you pass at the call site are the three places where the silence stops.

#rails #active-record

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.