aboutsummaryrefslogtreecommitdiffstats
path: root/activerecord
diff options
context:
space:
mode:
Diffstat (limited to 'activerecord')
-rw-r--r--activerecord/CHANGELOG.md9
-rw-r--r--activerecord/lib/active_record/connection_adapters/column.rb1
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/cast.rb30
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/oid.rb11
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb10
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb8
-rw-r--r--activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb19
-rw-r--r--activerecord/lib/active_record/validations/uniqueness.rb65
-rw-r--r--activerecord/test/cases/adapters/postgresql/intrange_test.rb104
-rw-r--r--activerecord/test/cases/validations/uniqueness_validation_test.rb23
-rw-r--r--activerecord/test/schema/postgresql_specific_schema.rb12
11 files changed, 252 insertions, 40 deletions
diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md
index 97b7b8cd1e..e7d3b529fe 100644
--- a/activerecord/CHANGELOG.md
+++ b/activerecord/CHANGELOG.md
@@ -1,5 +1,14 @@
## Rails 4.0.0 (unreleased) ##
+* Added support for `validates_uniqueness_of` in PostgreSQL array columns.
+ Fixes #8075.
+
+ *Pedro Padron*
+
+* Allow int4range and int8range columns to be created in PostgreSQL and properly convert to/from database.
+
+ *Alexey Vasiliev aka leopard*
+
* Do not log the binding values for binary columns.
*Matthew M. Boedicker*
diff --git a/activerecord/lib/active_record/connection_adapters/column.rb b/activerecord/lib/active_record/connection_adapters/column.rb
index df23dbfb60..fd36a5b075 100644
--- a/activerecord/lib/active_record/connection_adapters/column.rb
+++ b/activerecord/lib/active_record/connection_adapters/column.rb
@@ -126,6 +126,7 @@ module ActiveRecord
when :hstore then "#{klass}.string_to_hstore(#{var_name})"
when :inet, :cidr then "#{klass}.string_to_cidr(#{var_name})"
when :json then "#{klass}.string_to_json(#{var_name})"
+ when :intrange then "#{klass}.string_to_intrange(#{var_name})"
else var_name
end
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb b/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb
index c04a799b8d..f7d734a2f1 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb
@@ -92,6 +92,36 @@ module ActiveRecord
parse_pg_array(string).map{|val| oid.type_cast val}
end
+ def string_to_intrange(string)
+ if string.nil?
+ nil
+ elsif "empty" == string
+ (nil..nil)
+ elsif String === string && (matches = /^(\(|\[)([0-9]+),(\s?)([0-9]+)(\)|\])$/i.match(string))
+ lower_bound = ("(" == matches[1] ? (matches[2].to_i + 1) : matches[2].to_i)
+ upper_bound = (")" == matches[5] ? (matches[4].to_i - 1) : matches[4].to_i)
+ (lower_bound..upper_bound)
+ else
+ string
+ end
+ end
+
+ def intrange_to_string(object)
+ if object.nil?
+ nil
+ elsif Range === object
+ if [object.first, object.last].all? { |el| Integer === el }
+ "[#{object.first.to_i},#{object.exclude_end? ? object.last.to_i : object.last.to_i + 1})"
+ elsif [object.first, object.last].all? { |el| NilClass === el }
+ "empty"
+ else
+ nil
+ end
+ else
+ object
+ end
+ end
+
private
HstorePair = begin
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb
index 52344f61c0..18ea83ce42 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/oid.rb
@@ -168,6 +168,14 @@ module ActiveRecord
end
end
+ class IntRange < Type
+ def type_cast(value)
+ return if value.nil?
+
+ ConnectionAdapters::PostgreSQLColumn.string_to_intrange value
+ end
+ end
+
class TypeMap
def initialize
@mapping = {}
@@ -269,6 +277,9 @@ module ActiveRecord
register_type 'hstore', OID::Hstore.new
register_type 'json', OID::Json.new
+ register_type 'int4range', OID::IntRange.new
+ alias_type 'int8range', 'int4range'
+
register_type 'cidr', OID::Cidr.new
alias_type 'inet', 'cidr'
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb
index 62a4d76928..c2fcef94da 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/quoting.rb
@@ -31,6 +31,11 @@ module ActiveRecord
when 'json' then super(PostgreSQLColumn.json_to_string(value), column)
else super
end
+ when Range
+ case column.sql_type
+ when 'int4range', 'int8range' then super(PostgreSQLColumn.intrange_to_string(value), column)
+ else super
+ end
when IPAddr
case column.sql_type
when 'inet', 'cidr' then super(PostgreSQLColumn.cidr_to_string(value), column)
@@ -89,6 +94,11 @@ module ActiveRecord
when 'json' then PostgreSQLColumn.json_to_string(value)
else super(value, column)
end
+ when Range
+ case column.sql_type
+ when 'int4range', 'int8range' then PostgreSQLColumn.intrange_to_string(value)
+ else super(value, column)
+ end
when IPAddr
return super(value, column) unless ['inet','cidr'].include? column.sql_type
PostgreSQLColumn.cidr_to_string(value)
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
index 18bf14d1fb..e10b562fa4 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql/schema_statements.rb
@@ -417,6 +417,14 @@ module ActiveRecord
when 0..6; "timestamp(#{precision})"
else raise(ActiveRecordError, "No timestamp type has precision of #{precision}. The allowed range of precision is from 0 to 6")
end
+ when 'intrange'
+ return 'int4range' unless limit
+
+ case limit
+ when 1..4; 'int4range'
+ when 5..8; 'int8range'
+ else raise(ActiveRecordError, "No range type has byte size #{limit}. Use a numeric with precision 0 instead.")
+ end
else
super
end
diff --git a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
index e24ee1efdd..d62a375470 100644
--- a/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
+++ b/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb
@@ -114,6 +114,9 @@ module ActiveRecord
# JSON
when /\A'(.*)'::json\z/
$1
+ # int4range, int8range
+ when /\A'(.*)'::int(4|8)range\z/
+ $1
# Object identifier types
when /\A-?\d+\z/
$1
@@ -209,9 +212,12 @@ module ActiveRecord
# UUID type
when 'uuid'
:uuid
- # JSON type
- when 'json'
- :json
+ # JSON type
+ when 'json'
+ :json
+ # int4range, int8range types
+ when 'int4range', 'int8range'
+ :intrange
# Small and big integer types
when /^(?:small|big)int$/
:integer
@@ -289,6 +295,10 @@ module ActiveRecord
column(name, 'json', options)
end
+ def intrange(name, options = {})
+ column(name, 'intrange', options)
+ end
+
def column(name, type = nil, options = {})
super
column = self[name]
@@ -329,7 +339,8 @@ module ActiveRecord
cidr: { name: "cidr" },
macaddr: { name: "macaddr" },
uuid: { name: "uuid" },
- json: { name: "json" }
+ json: { name: "json" },
+ intrange: { name: "int4range" }
}
include Quoting
diff --git a/activerecord/lib/active_record/validations/uniqueness.rb b/activerecord/lib/active_record/validations/uniqueness.rb
index 5fa6a0b892..1427189851 100644
--- a/activerecord/lib/active_record/validations/uniqueness.rb
+++ b/activerecord/lib/active_record/validations/uniqueness.rb
@@ -1,10 +1,8 @@
-require 'active_support/core_ext/array/prepend_and_append'
-
module ActiveRecord
module Validations
class UniquenessValidator < ActiveModel::EachValidator # :nodoc:
def initialize(options)
- super(options.reverse_merge(:case_sensitive => true))
+ super({ case_sensitive: true }.merge!(options))
end
# Unfortunately, we have to tie Uniqueness validators to a class.
@@ -15,35 +13,19 @@ module ActiveRecord
def validate_each(record, attribute, value)
finder_class = find_finder_class_for(record)
table = finder_class.arel_table
-
- coder = record.class.serialized_attributes[attribute.to_s]
-
- if value && coder
- value = coder.dump value
- end
+ value = deserialize_attribute(record, attribute, value)
relation = build_relation(finder_class, table, attribute, value)
- relation = relation.and(table[finder_class.primary_key.to_sym].not_eq(record.send(:id))) if record.persisted?
-
- Array(options[:scope]).each do |scope_item|
- reflection = record.class.reflect_on_association(scope_item)
- if reflection
- scope_value = record.send(reflection.foreign_key)
- scope_item = reflection.foreign_key
- else
- scope_value = record.read_attribute(scope_item)
- end
- relation = relation.and(table[scope_item].eq(scope_value))
- end
-
+ relation = relation.and(table[finder_class.primary_key.to_sym].not_eq(record.id)) if record.persisted?
+ relation = scope_relation(record, table, relation)
relation = finder_class.unscoped.where(relation)
-
- if options[:conditions]
- relation = relation.merge(options[:conditions])
- end
+ relation.merge!(options[:conditions]) if options[:conditions]
if relation.exists?
- record.errors.add(attribute, :taken, options.except(:case_sensitive, :scope, :conditions).merge(:value => value))
+ error_options = options.except(:case_sensitive, :scope, :conditions)
+ error_options[:value] = value
+
+ record.errors.add(attribute, :taken, error_options)
end
end
@@ -58,7 +40,7 @@ module ActiveRecord
class_hierarchy = [record.class]
while class_hierarchy.first != @klass
- class_hierarchy.prepend(class_hierarchy.first.superclass)
+ class_hierarchy.unshift(class_hierarchy.first.superclass)
end
class_hierarchy.detect { |klass| !klass.abstract_class? }
@@ -71,18 +53,37 @@ module ActiveRecord
end
column = klass.columns_hash[attribute.to_s]
- value = column.limit ? value.to_s[0, column.limit] : value.to_s if !value.nil? && column.text?
+ value = klass.connection.type_cast(value, column)
+ value = value.to_s[0, column.limit] if value && column.limit && column.text?
if !options[:case_sensitive] && value && column.text?
# will use SQL LOWER function before comparison, unless it detects a case insensitive collation
- relation = klass.connection.case_insensitive_comparison(table, attribute, column, value)
+ klass.connection.case_insensitive_comparison(table, attribute, column, value)
else
- value = klass.connection.case_sensitive_modifier(value) unless value.nil?
- relation = table[attribute].eq(value)
+ value = klass.connection.case_sensitive_modifier(value) unless value.nil?
+ table[attribute].eq(value)
+ end
+ end
+
+ def scope_relation(record, table, relation)
+ Array(options[:scope]).each do |scope_item|
+ if reflection = record.class.reflect_on_association(scope_item)
+ scope_value = record.send(reflection.foreign_key)
+ scope_item = reflection.foreign_key
+ else
+ scope_value = record.read_attribute(scope_item)
+ end
+ relation = relation.and(table[scope_item].eq(scope_value))
end
relation
end
+
+ def deserialize_attribute(record, attribute, value)
+ coder = record.class.serialized_attributes[attribute.to_s]
+ value = coder.dump value if value && coder
+ value
+ end
end
module ClassMethods
diff --git a/activerecord/test/cases/adapters/postgresql/intrange_test.rb b/activerecord/test/cases/adapters/postgresql/intrange_test.rb
new file mode 100644
index 0000000000..58628cebc8
--- /dev/null
+++ b/activerecord/test/cases/adapters/postgresql/intrange_test.rb
@@ -0,0 +1,104 @@
+# encoding: utf-8
+
+require "cases/helper"
+require 'active_record/base'
+require 'active_record/connection_adapters/postgresql_adapter'
+
+class PostgresqlIntrangesTest < ActiveRecord::TestCase
+ class IntRangeDataType < ActiveRecord::Base
+ self.table_name = 'intrange_data_type'
+ end
+
+ def setup
+ @connection = ActiveRecord::Base.connection
+ begin
+ @connection.create_table('intrange_data_type') do |t|
+ t.intrange 'int_range', :default => (1..10)
+ t.intrange 'long_int_range', :limit => 8, :default => (1..100)
+ end
+ rescue ActiveRecord::StatementInvalid
+ return skip "do not test on PG without ranges"
+ end
+ @int_range_column = IntRangeDataType.columns.find { |c| c.name == 'int_range' }
+ @long_int_range_column = IntRangeDataType.columns.find { |c| c.name == 'long_int_range' }
+ end
+
+ def teardown
+ @connection.execute 'drop table if exists intrange_data_type'
+ end
+
+ def test_columns
+ assert_equal :intrange, @int_range_column.type
+ assert_equal :intrange, @long_int_range_column.type
+ end
+
+ def test_type_cast_intrange
+ assert @int_range_column
+ assert_equal(true, @int_range_column.has_default?)
+ assert_equal((1..10), @int_range_column.default)
+ assert_equal("int4range", @int_range_column.sql_type)
+
+ data = "[1,10)"
+ hash = @int_range_column.class.string_to_intrange data
+ assert_equal((1..9), hash)
+ assert_equal((1..9), @int_range_column.type_cast(data))
+
+ assert_equal((nil..nil), @int_range_column.type_cast("empty"))
+ assert_equal((1..5), @int_range_column.type_cast('[1,5]'))
+ assert_equal((2..4), @int_range_column.type_cast('(1,5)'))
+ assert_equal((2..39), @int_range_column.type_cast('[2,40)'))
+ assert_equal((10..20), @int_range_column.type_cast('(9,20]'))
+ end
+
+ def test_type_cast_long_intrange
+ assert @long_int_range_column
+ assert_equal(true, @long_int_range_column.has_default?)
+ assert_equal((1..100), @long_int_range_column.default)
+ assert_equal("int8range", @long_int_range_column.sql_type)
+ end
+
+ def test_rewrite
+ @connection.execute "insert into intrange_data_type (int_range) VALUES ('(1, 6)')"
+ x = IntRangeDataType.first
+ x.int_range = (1..100)
+ assert x.save!
+ end
+
+ def test_select
+ @connection.execute "insert into intrange_data_type (int_range) VALUES ('(1, 4]')"
+ x = IntRangeDataType.first
+ assert_equal((2..4), x.int_range)
+ end
+
+ def test_empty_range
+ @connection.execute %q|insert into intrange_data_type (int_range) VALUES('empty')|
+ x = IntRangeDataType.first
+ assert_equal((nil..nil), x.int_range)
+ end
+
+ def test_rewrite_to_nil
+ @connection.execute %q|insert into intrange_data_type (int_range) VALUES('(1, 4]')|
+ x = IntRangeDataType.first
+ x.int_range = nil
+ assert x.save!
+ assert_equal(nil, x.int_range)
+ end
+
+ def test_invalid_intrange
+ assert IntRangeDataType.create!(int_range: ('a'..'d'))
+ x = IntRangeDataType.first
+ assert_equal(nil, x.int_range)
+ end
+
+ def test_save_empty_range
+ assert IntRangeDataType.create!(int_range: (nil..nil))
+ x = IntRangeDataType.first
+ assert_equal((nil..nil), x.int_range)
+ end
+
+ def test_save_invalid_data
+ assert_raises(ActiveRecord::StatementInvalid) do
+ IntRangeDataType.create!(int_range: "empty1")
+ end
+ end
+end \ No newline at end of file
diff --git a/activerecord/test/cases/validations/uniqueness_validation_test.rb b/activerecord/test/cases/validations/uniqueness_validation_test.rb
index 46212e49b6..46e767af1a 100644
--- a/activerecord/test/cases/validations/uniqueness_validation_test.rb
+++ b/activerecord/test/cases/validations/uniqueness_validation_test.rb
@@ -30,6 +30,11 @@ class ReplyWithTitleObject < Reply
def title; ReplyTitle.new; end
end
+class Employee < ActiveRecord::Base
+ self.table_name = 'postgresql_arrays'
+ validates_uniqueness_of :nicknames
+end
+
class UniquenessValidationTest < ActiveRecord::TestCase
fixtures :topics, 'warehouse-things', :developers
@@ -341,16 +346,28 @@ class UniquenessValidationTest < ActiveRecord::TestCase
assert w6.errors[:city].any?, "Should have errors for city"
assert_equal ["has already been taken"], w6.errors[:city], "Should have uniqueness message for city"
end
-
+
def test_validate_uniqueness_with_conditions
Topic.validates_uniqueness_of(:title, :conditions => Topic.where('approved = ?', true))
Topic.create("title" => "I'm a topic", "approved" => true)
Topic.create("title" => "I'm an unapproved topic", "approved" => false)
-
+
t3 = Topic.new("title" => "I'm a topic", "approved" => true)
assert !t3.valid?, "t3 shouldn't be valid"
-
+
t4 = Topic.new("title" => "I'm an unapproved topic", "approved" => false)
assert t4.valid?, "t4 should be valid"
end
+
+ def test_validate_uniqueness_with_array_column
+ return skip "Uniqueness on arrays has only been tested in PostgreSQL so far." if !current_adapter? :PostgreSQLAdapter
+
+ e1 = Employee.create("nicknames" => ["john", "johnny"], "commission_by_quarter" => [1000, 1200])
+ assert e1.persisted?, "Saving e1"
+
+ e2 = Employee.create("nicknames" => ["john", "johnny"], "commission_by_quarter" => [2200])
+ assert !e2.persisted?, "e2 shouldn't be valid"
+ assert e2.errors[:nicknames].any?, "Should have errors for nicknames"
+ assert_equal ["has already been taken"], e2.errors[:nicknames], "Should have uniqueness message for nicknames"
+ end
end
diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb
index ab2a63d3ea..96fef3a831 100644
--- a/activerecord/test/schema/postgresql_specific_schema.rb
+++ b/activerecord/test/schema/postgresql_specific_schema.rb
@@ -1,7 +1,7 @@
ActiveRecord::Schema.define do
%w(postgresql_tsvectors postgresql_hstores postgresql_arrays postgresql_moneys postgresql_numbers postgresql_times postgresql_network_addresses postgresql_bit_strings postgresql_uuids
- postgresql_oids postgresql_xml_data_type defaults geometrics postgresql_timestamp_with_zones postgresql_partitioned_table postgresql_partitioned_table_parent postgresql_json_data_type).each do |table_name|
+ postgresql_oids postgresql_xml_data_type defaults geometrics postgresql_timestamp_with_zones postgresql_partitioned_table postgresql_partitioned_table_parent postgresql_json_data_type postgresql_intrange_data_type).each do |table_name|
execute "DROP TABLE IF EXISTS #{quote_table_name table_name}"
end
@@ -97,6 +97,16 @@ _SQL
);
_SQL
end
+
+ if 't' == select_value("select 'int4range'=ANY(select typname from pg_type)")
+ execute <<_SQL
+ CREATE TABLE postgresql_intrange_data_type (
+ id SERIAL PRIMARY KEY,
+ int_range int4range,
+ int_long_range int8range
+ );
+_SQL
+ end
execute <<_SQL
CREATE TABLE postgresql_moneys (