Skip to content

Commit

Permalink
Merge pull request #32 from nullobject/feature/unique-constraint
Browse files Browse the repository at this point in the history
Add unique constraint
  • Loading branch information
Joshua Bassett authored Dec 22, 2017
2 parents e02602d + 0093e68 commit 887167d
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 3 deletions.
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions lib/rein.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions lib/rein/constraint/unique.rb
Original file line number Diff line number Diff line change
@@ -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
18 changes: 18 additions & 0 deletions spec/integration/constraints_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions spec/migrations/2_create_books_table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/migrations/3_add_constraints.rb
Original file line number Diff line number Diff line change
@@ -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'
Expand Down
61 changes: 61 additions & 0 deletions spec/rein/constraint/unique_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 887167d

Please sign in to comment.