From 0093e681cb12ed5e789050f9678df3706a554bbb Mon Sep 17 00:00:00 2001 From: Josh Bassett Date: Fri, 22 Dec 2017 14:09:23 +1100 Subject: [PATCH] Add unique constraint --- README.md | 47 +++++++++++++++++++ lib/rein.rb | 2 + lib/rein/constraint/unique.rb | 41 +++++++++++++++++ spec/integration/constraints_spec.rb | 18 ++++++++ spec/migrations/2_create_books_table.rb | 7 +-- spec/migrations/3_add_constraints.rb | 1 + spec/rein/constraint/unique_spec.rb | 61 +++++++++++++++++++++++++ 7 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 lib/rein/constraint/unique.rb create mode 100644 spec/rein/constraint/unique_spec.rb diff --git a/README.md b/README.md index a777a23..1913b92 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ advantage of reversible Rails migrations. * [Getting Started](#getting-started) * [Constraint Types](#constraint-types) * [Foreign Key Constraints](#foreign-key-constraints) + * [Unique Constraints](#unique-constraints) * [Inclusion Constraints](#inclusion-constraints) * [Length Constraints](#length-constraints) * [Match Constraints](#match-constraints) @@ -111,6 +112,52 @@ To remove a foreign key constraint: remove_foreign_key_constraint :books, :authors ``` +### Unique Constraints + +A unique constraint specifies that certain columns in a table must be unique. + +For example, all the books should have unique ISBNs: + +```ruby +add_unique_constraint :books, :isbn +``` + +By default, the database checks unique constraints immediately (i.e. as soon as +a record is created or updated). If a record with a duplicate value exists, +then the database will raise an error. + +Sometimes it is necessary to wait until the end of a transaction to do the +checking (e.g. maybe you want to swap the ISBNs for two books). To do so, you +need to tell the database to *defer* checking the constraint until the end of +the current transaction: + +```sql +BEGIN; +SET CONSTRAINTS books_isbn_unique DEFERRED; +UPDATE books SET isbn = 'foo' WHERE id = 1; +UPDATE books SET isbn = 'bar' WHERE id = 2; +COMMIT; +``` + +This [blog +post](https://hashrocket.com/blog/posts/deferring-database-constraints) offers +a good explanation of how to do this in a Rails app when using the +`acts_as_list` plugin. + +If you *always* want to defer checking a unique constraint, then you can set +the `deferred` option to `true`: + +```ruby +add_unique_constraint :books, :isbn, deferred: true +``` + +If you really don't want the ability to optionally defer a unique constraint in +a transaction, then you can set the `deferrable` option to `false`: + +```ruby +add_unique_constraint :authors, :name, deferrable: false +``` + ### Inclusion Constraints An inclusion constraint specifies the possible values that a column value can diff --git a/lib/rein.rb b/lib/rein.rb index aff91f4..7520c66 100644 --- a/lib/rein.rb +++ b/lib/rein.rb @@ -7,6 +7,7 @@ require 'rein/constraint/numericality' require 'rein/constraint/presence' require 'rein/constraint/primary_key' +require 'rein/constraint/unique' require 'rein/schema' require 'rein/type/enum' require 'rein/view' @@ -21,6 +22,7 @@ class Migration # :nodoc: include Rein::Constraint::Numericality include Rein::Constraint::Presence include Rein::Constraint::PrimaryKey + include Rein::Constraint::Unique include Rein::Schema include Rein::Type::Enum include Rein::View diff --git a/lib/rein/constraint/unique.rb b/lib/rein/constraint/unique.rb new file mode 100644 index 0000000..c69a657 --- /dev/null +++ b/lib/rein/constraint/unique.rb @@ -0,0 +1,41 @@ +require 'rein/util' + +module Rein + module Constraint + # This module contains methods for defining unique constraints. + module Unique + include ActiveRecord::ConnectionAdapters::Quoting + + def add_unique_constraint(*args) + reversible do |dir| + dir.up do _add_unique_constraint(*args) end + dir.down { _remove_unique_constraint(*args) } + end + end + + def remove_unique_constraint(*args) + reversible do |dir| + dir.up do _remove_unique_constraint(*args) end + dir.down { _add_unique_constraint(*args) } + end + end + + private + + def _add_unique_constraint(table, attributes, options = {}) + attributes = [attributes].flatten + name = Util.constraint_name(table, attributes.join('_'), 'unique', options) + initially = options[:deferred] ? 'DEFERRED' : 'IMMEDIATE' + sql = "ALTER TABLE #{table} ADD CONSTRAINT #{name} UNIQUE (#{attributes.join(', ')})" + sql << " DEFERRABLE INITIALLY #{initially}" unless options[:deferrable] == false + execute(sql) + end + + def _remove_unique_constraint(table, attributes, options = {}) + attributes = [attributes].flatten + name = Util.constraint_name(table, attributes.join('_'), 'unique', options) + execute("ALTER TABLE #{table} DROP CONSTRAINT #{name}") + end + end + end +end diff --git a/spec/integration/constraints_spec.rb b/spec/integration/constraints_spec.rb index af492bf..be0efd1 100644 --- a/spec/integration/constraints_spec.rb +++ b/spec/integration/constraints_spec.rb @@ -6,6 +6,7 @@ class Book < ActiveRecord::Base; end def create_book(attributes = {}) attributes = { author_id: 1, + isbn: '0451529065', title: 'On the Origin of Species', state: 'available', published_month: 1 @@ -25,6 +26,23 @@ def create_book(attributes = {}) expect { create_book(author_id: 1) }.to_not raise_error end + it 'raises an error if the ISBN is not unique' do + expect { create_book(isbn: 'foo') }.to_not raise_error + expect { create_book(isbn: 'bar') }.to_not raise_error + expect { create_book(isbn: 'foo') }.to raise_error(ActiveRecord::RecordNotUnique) + end + + it 'allows checking unique ISBNs to be deferred' do + foo = create_book(isbn: 'foo') + bar = create_book(isbn: 'bar') + + Author.transaction do + Author.connection.execute 'SET CONSTRAINTS books_isbn_unique DEFERRED' + foo.update!(isbn: 'bar') + bar.update!(isbn: 'foo') + end + end + it 'raises an error if the title is not present' do expect { create_book(title: '') }.to raise_error(ActiveRecord::StatementInvalid, /PG::CheckViolation/) expect { create_book(title: 'On the Origin of Species') }.to_not raise_error diff --git a/spec/migrations/2_create_books_table.rb b/spec/migrations/2_create_books_table.rb index 4c829fe..e7eb21c 100644 --- a/spec/migrations/2_create_books_table.rb +++ b/spec/migrations/2_create_books_table.rb @@ -2,13 +2,14 @@ class CreateBooksTable < Migration def change create_table :books do |t| if ActiveRecord::VERSION::STRING >= '5.0.0' - # Rails 5 automatically adds an index for belongs_to associations. - # Turn this behaviour off, so we can manually add the index in a - # later migration and test adding an index via rein. + # Rails 5 automatically adds an index for belongs_to associations. Turn + # this behaviour off, so we can manually add the index in a later + # migration and test adding an index via Rein. t.belongs_to :author, null: false, index: false else t.belongs_to :author, null: false end + t.string :isbn, null: false t.string :title, null: false t.string :state, null: false t.integer :published_month, null: false diff --git a/spec/migrations/3_add_constraints.rb b/spec/migrations/3_add_constraints.rb index f623300..98be528 100644 --- a/spec/migrations/3_add_constraints.rb +++ b/spec/migrations/3_add_constraints.rb @@ -1,6 +1,7 @@ class AddConstraints < Migration def change add_foreign_key_constraint :books, :authors, on_delete: :cascade, index: true + add_unique_constraint :books, :isbn add_presence_constraint :books, :title add_inclusion_constraint :books, :state, in: %w[available on_loan on_hold] add_match_constraint :books, :title, accepts: '\A[a-zA-Z0-9\s]*\Z', rejects: '\t' diff --git a/spec/rein/constraint/unique_spec.rb b/spec/rein/constraint/unique_spec.rb new file mode 100644 index 0000000..5550928 --- /dev/null +++ b/spec/rein/constraint/unique_spec.rb @@ -0,0 +1,61 @@ +require 'spec_helper' + +RSpec.describe Rein::Constraint::Unique do + subject(:adapter) do + Class.new do + include Rein::Constraint::Unique + end.new + end + + let(:dir) { double(up: nil, down: nil) } + + before do + allow(dir).to receive(:up).and_yield + allow(adapter).to receive(:reversible).and_yield(dir) + allow(adapter).to receive(:execute) + end + + describe '#add_unique_constraint' do + context 'given an single attribute' do + it 'adds a constraint' do + expect(adapter).to receive(:execute).with('ALTER TABLE books ADD CONSTRAINT books_call_number_unique UNIQUE (call_number) DEFERRABLE INITIALLY IMMEDIATE') + adapter.add_unique_constraint(:books, :call_number) + end + end + + context 'given an array of attributes' do + it 'adds a deferrable constraint that is initially immediate' do + expect(adapter).to receive(:execute).with('ALTER TABLE books ADD CONSTRAINT books_call_number_title_unique UNIQUE (call_number, title) DEFERRABLE INITIALLY IMMEDIATE') + adapter.add_unique_constraint(:books, %w[call_number title]) + end + end + + context 'given a name option' do + it 'adds a constraint with that name' do + expect(adapter).to receive(:execute).with('ALTER TABLE books ADD CONSTRAINT books_call_number_is_unique UNIQUE (call_number) DEFERRABLE INITIALLY IMMEDIATE') + adapter.add_unique_constraint(:books, :call_number, name: 'books_call_number_is_unique') + end + end + + context 'given a deferred option' do + it 'adds a deferrable constraint that is initially deferred' do + expect(adapter).to receive(:execute).with('ALTER TABLE books ADD CONSTRAINT books_call_number_unique UNIQUE (call_number) DEFERRABLE INITIALLY DEFERRED') + adapter.add_unique_constraint(:books, :call_number, deferred: true) + end + end + + context 'given a deferrable option' do + it 'adds an immediate constraint' do + expect(adapter).to receive(:execute).with('ALTER TABLE books ADD CONSTRAINT books_call_number_unique UNIQUE (call_number)') + adapter.add_unique_constraint(:books, :call_number, deferrable: false) + end + end + end + + describe '#remove_unique_constraint' do + it 'removes a constraint' do + expect(subject).to receive(:execute).with('ALTER TABLE books DROP CONSTRAINT books_state_unique') + subject.remove_unique_constraint(:books, :state) + end + end +end