Skip to content

Instantly share code, notes, and snippets.

@iiwo
Last active April 26, 2022 14:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iiwo/0a9227a444cf7cb19985c9824cf85ae5 to your computer and use it in GitHub Desktop.
Save iiwo/0a9227a444cf7cb19985c9824cf85ae5 to your computer and use it in GitHub Desktop.
through_association_autosave.rb
# frozen_string_literal: true
# This script illustrates caveats of ActiveRecord association autosave
# when using a combination of database constrains with a through association (join model)
#
# ## USAGE
#
# ruby through_association_autosave.rb
#
# ## EDGE CASE
#
# Removing a (seemingly unrelated) validation can result in an exception to be raised when creating a model instance:
#
# 1. When a (has_one) through association item is assigned ActiveRecord will automatically build a join model object
# (see https://guides.rubyonrails.org/association_basics.html)
#
# 2. When *save* is invoked on the parent model, it will automatically save the associated join object
# following the association autosave logic:
# - when autosave is not explicitly defined it will only save new records using `save(validate: true)`
# and not explicitly raise exceptions if invalid
# (see https://github.com/rails/rails/blob/6-1-stable/activerecord/lib/active_record/autosave_association.rb#L491-L517)
#
# 3. When an unfulfilled database constraint exists (e.g. a NOT NULL constraint) but is not covered by an ActiveRecord validation
# and the automatically built object is:
# - valid (in terms of ActiveRecord validations): the save attempt will *raise an exception*
# - invalid (in terms of ActiveRecord validations): the save attempt will *fail silently* and not raise an exception
###################################
# SETUP
###################################
require "bundler/inline"
##
# GEMS
##
gemfile(true) do
source "https://rubygems.org"
gem "activerecord", "6.1"
gem "sqlite3"
gem "rspec"
end
##
# CONFIG
##
require "active_record"
require "rspec"
require "logger"
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
ActiveRecord::Base.logger = Logger.new(STDOUT)
##
# TABLES
##
ActiveRecord::Schema.define do
create_table :libraries
create_table :shelves do |t|
t.belongs_to :library
t.boolean :wooden, null: false
end
create_table :books do |t|
t.belongs_to :shelve
end
end
##
# MODELS
##
class Library < ActiveRecord::Base
has_many :shelves
end
class Shelve < ActiveRecord::Base
belongs_to :library
has_many :books
attr_accessor :make_invalid
validate :some_validation
def some_validation
errors.add(:base, 'is invalid') if make_invalid
end
end
class Book < ActiveRecord::Base
belongs_to :shelve
has_one :library, through: :shelve
end
###################################
# TEST
###################################
require "rspec/autorun"
RSpec.describe 'autosave validation and constraints bahavior' do
let(:library) { Library.create! }
subject(:create_book_with_library) do
# creating a book in a library will transparently build a shelve via a through association
Book.new(library: library).tap do |book|
# save! will not raise an error when the association join model instance is invalid
book.save!
end
end
context 'when the through item(shelve) is valid' do
context 'when an unfulfilled database constraint exists' do
it 'will raise ActiveRecord::NotNullViolation error' do
expect { create_book_with_library }.to raise_exception(ActiveRecord::NotNullViolation)
end
end
context 'when the database constraint is fulfilled' do
it 'will persist the join model' do
book = create_book_with_library
expect(book.shelve.persisted?).to eq(true)
end
end
end
context 'when the through item(shelve) is invalid' do
before do
allow_any_instance_of(Shelve).to receive(:make_invalid).and_return(true)
end
context 'when an unfulfilled database constraint exists' do
it 'will NOT raise ActiveRecord::NotNullViolation error' do
expect { create_book_with_library }.not_to raise_exception
end
it 'will not persist the join model' do
book = create_book_with_library
expect(book.shelve.persisted?).to eq(false)
end
end
end
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment