Daniel Fone

Ruby/Rails Engineer

Persist Invalid Records with ActiveRecord

tl;dr We can override ActiveRecord#save to keep invalid records in the database along with their validation errors.

Some time ago, I had an unusual design brief for a Rails app:

Even if a user submits an invalid record, we need to (a) save a copy to the database, along with the validation errors, and (b) re-render the form with the error messages as per the default Rails behavior.

The idea behind this design was that administrators could see the unsuccessful submissions and intervene to assist the users if necessary.

If anyone ever finds themselves in a similar situation, here’s what I ended up doing:

I’m going to use a model called Subscription by way of example. This same pattern will work on any ActiveRecord model.

Add a new text column

We’ll serialize the validation errors and store them in a field called record_errors.

# /db/migrations/20130524012635_add_record_errors_to_subscriptions.rb
class AddRecordErrorsToSubscriptions < ActiveRecord::Migration
  def change
    add_column :subscriptions, :record_errors, :text
  end
end

Add an ActiveRecord extension to handle persistence

This module will catch validation failures and persist the record anyway. It’s completely independent of the domain/business logic of the application so I like to store it in /lib. This is the main logic for achieving our required behaviour.

# /lib/save_with_errors.rb
require 'active_record'
require 'active_support'

module SaveWithErrors

  def save_with_errors!(*args)
    save_without_errors! *args
  rescue ActiveRecord::RecordInvalid
    save_anyway
    raise # this re-raises the exception we just rescued
  end

  def save_with_errors(*args)
    save_without_errors *args or save_anyway
  end

  def self.included(receiver)
    receiver.serialize :record_errors, Hash
    receiver.alias_method_chain :save, :errors
    receiver.alias_method_chain :save!, :errors
  end

private

  def save_anyway
    dup.tap { |s| s.record_errors = errors.messages }.save(validate: false)
    false
  end

end

Include our ActiveRecord extension on our model

All we need to do is include the SaveWithErrors module into our ActiveRecord model. We should also require ‘save_with_errors’ in config/application.rb.

# /app/models/subscription.rb
require 'save_with_errors'

class Subscription < ActiveRecord::Base
  include SaveWithErrors

  attr_accessible :email, :name, :token

  validate :valid_token

private

  def valid_token
    # something meaningful
    errors.add :token, 'is invalid' unless token == '12345'
  end

end

The Result

Loading development environment (Rails 3.2.13)

> Subscription.create! name: 'My Name', email: 'test@example.com'
  SQL (3.6ms)  INSERT INTO "subscriptions" ("created_at", "email", "name", "record_errors", "token", "updated_at") VALUES (?, ?, ?, ?, ?, ?)  [["created_at", Fri, 24 May 2013 01:55:07 UTC +00:00], ["email", "test@example.com"], ["name", "My Name"], ["record_errors", "--- !omap\n- :token:\n  - is invalid\n"], ["token", nil], ["updated_at", Fri, 24 May 2013 01:55:07 UTC +00:00]]

ActiveRecord::RecordInvalid: Validation failed: Token is invalid
  [... backtrace ...]

Notice that a record has been inserted, but the exception that we’d expect from create! has still been raised. We can verify this by inspecting the last Subscription record:

> y Subscription.last
  Subscription Load (0.3ms)  SELECT "subscriptions".* FROM "subscriptions" ORDER BY "subscriptions"."id" DESC LIMIT 1
--- !ruby/object:Subscription
attributes:
  id: 19
  name: My Name
  email: test@example.com
  token:
  created_at: 2013-05-24 01:55:07.747035000 Z
  updated_at: 2013-05-24 01:55:07.747035000 Z
  record_errors: !omap
  - :token:
    - is invalid

Exactly what we want!

comments powered by Disqus