diff --git a/activerecord/CHANGELOG.md b/activerecord/CHANGELOG.md index 97b7b8cd1ef4d..11e23ad0f46fa 100644 --- a/activerecord/CHANGELOG.md +++ b/activerecord/CHANGELOG.md @@ -1,5 +1,9 @@ ## Rails 4.0.0 (unreleased) ## +* 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 df23dbfb6026d..fd36a5b075f9b 100644 --- a/activerecord/lib/active_record/connection_adapters/column.rb +++ b/activerecord/lib/active_record/connection_adapters/column.rb @@ -126,6 +126,7 @@ def type_cast_code(var_name) 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 c04a799b8d985..3772f7ddaa2c9 100644 --- a/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb +++ b/activerecord/lib/active_record/connection_adapters/postgresql/cast.rb @@ -92,6 +92,29 @@ def string_to_array(string, oid) 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 Range === object + "[#{object.first},#{object.exclude_end? ? object.last : object.last.to_i + 1})" + 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 52344f61c0eb5..18ea83ce42a7d 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 @@ def type_cast(value) 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 @@ def self.registered_type?(name) 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 62a4d76928825..c2fcef94daacd 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 @@ def quote(value, column = nil) #:nodoc: 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 @@ def type_cast(value, column, array_member = false) 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 18bf14d1fbb05..e10b562fa4ea7 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 @@ def type_to_sql(type, limit = nil, precision = nil, scale = nil) 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 e24ee1efdd51b..d62a37547014e 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 @@ def self.extract_value_from_default(default) # 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 @@ def simplified_type(field_type) # 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 @@ def json(name, options = {}) 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 @@ def new_column_definition(base, name, type) cidr: { name: "cidr" }, macaddr: { name: "macaddr" }, uuid: { name: "uuid" }, - json: { name: "json" } + json: { name: "json" }, + intrange: { name: "int4range" } } include Quoting 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 0000000000000..affacd092a980 --- /dev/null +++ b/activerecord/test/cases/adapters/postgresql/intrange_test.rb @@ -0,0 +1,87 @@ +# 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 + +end diff --git a/activerecord/test/schema/postgresql_specific_schema.rb b/activerecord/test/schema/postgresql_specific_schema.rb index ab2a63d3ea865..96fef3a8313f1 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 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 (