Last active
April 26, 2022 14:45
-
-
Save iiwo/0a9227a444cf7cb19985c9824cf85ae5 to your computer and use it in GitHub Desktop.
through_association_autosave.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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