Skip to content

Commit

Permalink
Improve the :min_value/:max_value support for decimal/numeric columns
Browse files Browse the repository at this point in the history
This extends it to work on all databases Sequel supports.

In some cases, the precision/scale information is in the :db_type
schema entry, and in other cases, it is in the :column_size and
:scale schema entries.  Make the code handle both.  To try to get
this working on Oracle, make the Oracle schema support include a
:column_size schema entry.

To handle decimal/numeric columns used for integer types (where
scale is 0), switch column_schema_integer_min_max_values to
take a column schema hash instead of a db_type string.  This
is a backwards incompatible change, and external adapters will
need to update their code if they overrode the method.

PostgreSQL 15+ supports cases where scale is negative, and earlier
versions support cases where scale is greater than precision, so
try to handle both cases.
  • Loading branch information
jeremyevans committed Dec 21, 2022
1 parent ad4da90 commit c99fabf
Show file tree
Hide file tree
Showing 9 changed files with 127 additions and 50 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
=== master

* Include :min_value and :max_value schema entries for decimal/numeric columns on most databases (rolftimmermans, jeremyevans) (#1975)

* Support :graph_use_association_block association option to make eager_graph use the association block (jeremyevans)

* Make many_through_many and many_through_one associations support eager_graph callbacks (jeremyevans)
Expand Down
1 change: 1 addition & 0 deletions lib/sequel/adapters/oracle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ def schema_parse_table(table, opts=OPTS)
:char_used => column.char_used?,
:char_size => column.char_size,
:data_size => column.data_size,
:column_size => column.precision,
:precision => column.precision,
:scale => column.scale,
:fsprecision => column.fsprecision,
Expand Down
4 changes: 2 additions & 2 deletions lib/sequel/adapters/shared/access.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@ def database_error_regexps
# Access's Byte type will accept much larger values,
# even though it only stores 0-255. Do not set min/max
# values for the Byte type.
def column_schema_integer_min_max_values(db_type)
return if /byte/i =~ db_type
def column_schema_integer_min_max_values(column)
return if /byte/i =~ column[:db_type]
super
end

Expand Down
2 changes: 1 addition & 1 deletion lib/sequel/adapters/shared/mysql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -553,7 +553,7 @@ def schema_parse_table(table_name, opts)
# Return nil if CHECK constraints are not supported, because
# versions that don't support check constraints don't raise
# errors for values outside of range.
def column_schema_integer_min_max_values(db_type)
def column_schema_integer_min_max_values(column)
super if supports_check_constraints?
end

Expand Down
10 changes: 5 additions & 5 deletions lib/sequel/adapters/shared/oracle.rb
Original file line number Diff line number Diff line change
Expand Up @@ -178,11 +178,11 @@ def auto_increment_sql
''
end

# Do not support min/max integer values on Oracle, since
# Oracle uses a number type, and integer just adds a
# constaint on the number type.
def column_schema_integer_min_max_values(db_type)
nil
# Support min/max integer values on Oracle only if
# they use a NUMBER column with a fixed precision
# and no scale.
def column_schema_integer_min_max_values(column)
super if column[:db_type] =~ /NUMBER\(\d+\)/i
end

def create_sequence_sql(name, opts=OPTS)
Expand Down
5 changes: 3 additions & 2 deletions lib/sequel/adapters/shared/sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -320,10 +320,11 @@ def column_definition_default_sql(sql, column)
end
end

# SQLite does not restrict the integer type to a specific range.
def column_schema_integer_min_max_values(db_type)
# SQLite does not restrict the integer or decimal type to a specific range.
def column_schema_integer_min_max_values(column)
nil
end
alias column_schema_decimal_min_max_values column_schema_integer_min_max_values

# Array of PRAGMA SQL statements based on the Database options that should be applied to
# new connections.
Expand Down
43 changes: 29 additions & 14 deletions lib/sequel/database/query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,11 @@ def schema(table, opts=OPTS)
c[:max_length] = max_length
end
if !c[:max_value] && !c[:min_value]
min_max = if c[:type] == :integer
column_schema_integer_min_max_values(c[:db_type])
elsif c[:type] == :decimal
column_schema_decimal_min_max_values(c[:db_type])
min_max = case c[:type]
when :integer
column_schema_integer_min_max_values(c)
when :decimal
column_schema_decimal_min_max_values(c)
end
c[:min_value], c[:max_value] = min_max if min_max
end
Expand Down Expand Up @@ -293,7 +294,15 @@ def column_schema_to_ruby_default(default, type)

# Look at the db_type and guess the minimum and maximum integer values for
# the column.
def column_schema_integer_min_max_values(db_type)
def column_schema_integer_min_max_values(column)
db_type = column[:db_type]
if /decimal|numeric|number/i =~ db_type
if min_max = column_schema_decimal_min_max_values(column)
min_max.map!(&:to_i)
end
return min_max
end

unsigned = /unsigned/i =~ db_type
case db_type
when /big|int8/i
Expand All @@ -310,15 +319,21 @@ def column_schema_integer_min_max_values(db_type)
end

# Look at the db_type and guess the minimum and maximum decimal values for
# the column. This is currently only implemented for PostgreSQL.
def column_schema_decimal_min_max_values(db_type)
match = db_type.match(/\((\d+),\s*(\d+)?\)/)

if match
precision = match[1].to_i
scale = match[2].to_i
# the column.
def column_schema_decimal_min_max_values(column)
if column[:column_size] && column[:scale]
precision = column[:column_size]
scale = column[:scale]
elsif /\((\d+)(?:,\s*(-?\d+))?\)/ =~ column[:db_type]
precision = $1.to_i
scale = $2.to_i if $2
end

limit = BigDecimal("9" * (precision - scale) + (scale.zero? ? ".0" : "." + "9" * scale))
if precision
limit = BigDecimal("9" * precision)
if scale
limit /= 10**(scale)
end
[-limit, limit]
end
end
Expand Down Expand Up @@ -389,7 +404,7 @@ def schema_column_type(db_type)
:boolean
when /\A(real|float( unsigned)?|double( precision)?|double\(\d+,\d+\)( unsigned)?)\z/io
:float
when /\A(?:(?:(?:num(?:ber|eric)?|decimal)(?:\(\d+,\s*(\d+|false|true)\))?))\z/io
when /\A(?:(?:(?:num(?:ber|eric)?|decimal)(?:\(\d+,\s*(-?\d+|false|true)\))?))\z/io
$1 && ['0', 'false'].include?($1) ? :integer : :decimal
when /bytea|blob|image|(var)?binary/io
:blob
Expand Down
38 changes: 33 additions & 5 deletions spec/core/database_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2857,9 +2857,9 @@ def db.views(*) end
end

describe "Database#schema min/max values" do
def min_max(type, db_type)
def min_max(type, db_type, opts={})
@db = Sequel::Database.new
@db.define_singleton_method(:schema_parse_table){|*| [[:a, {:db_type=>db_type, :type=>type}]]}
@db.define_singleton_method(:schema_parse_table){|*| [[:a, opts.merge(:db_type=>db_type, :type=>type)]]}
yield @db if block_given?
sch = @db.schema(:t)[0][1]
[sch.fetch(:min_value, :none), sch.fetch(:max_value, :none)]
Expand Down Expand Up @@ -2887,17 +2887,45 @@ def min_max(type, db_type)
min_max(:integer, 'mediumint unsigned').must_equal [0, 16777215]
end

it "should parse minimum and maximum values for decimal types" do
it "should parse minimum and maximum values for decimal(X, Y) types" do
min_max(:decimal, 'decimal(8,2)').must_equal [BigDecimal("-999999.99"), BigDecimal("999999.99")]
min_max(:decimal, 'decimal(8, 2)').must_equal [BigDecimal("-999999.99"), BigDecimal("999999.99")]
min_max(:decimal, 'numeric(8,2)').must_equal [BigDecimal("-999999.99"), BigDecimal("999999.99")]
min_max(:decimal, 'numeric(8, 2)').must_equal [BigDecimal("-999999.99"), BigDecimal("999999.99")]
min_max(:decimal, 'numeric(7,2)').must_equal [BigDecimal("-99999.99"), BigDecimal("99999.99")]
min_max(:decimal, 'numeric(7, 2)').must_equal [BigDecimal("-99999.99"), BigDecimal("99999.99")]
min_max(:decimal, 'decimal(6,-2)').must_equal [BigDecimal("-99999900.0"), BigDecimal("99999900.0")]
min_max(:decimal, 'numeric(6, -2)').must_equal [BigDecimal("-99999900.0"), BigDecimal("99999900.0")]
min_max(:decimal, 'numeric(3,5)').must_equal [BigDecimal("-0.00999"), BigDecimal("0.00999")]
min_max(:decimal, 'decimal(3, 5)').must_equal [BigDecimal("-0.00999"), BigDecimal("0.00999")]
end

it "should parse minimum and maximum values for decimal(X) types handled as integers" do
min_max(:integer, 'decimal(8)').must_equal [-99999999, 99999999]
min_max(:integer, 'numeric(7)').must_equal [-9999999, 9999999]
min_max(:integer, 'number(7)').must_equal [-9999999, 9999999]
min_max(:integer, 'numeric(7)').first.must_be_kind_of Integer
end

it "should parse minimum and maximum values for decimal types with :column_size and :scale schema entries" do
min_max(:decimal, 'decimal', :column_size=>8, :scale=>2).must_equal [BigDecimal("-999999.99"), BigDecimal("999999.99")]
min_max(:decimal, 'numeric', :column_size=>7, :scale=>2).must_equal [BigDecimal("-99999.99"), BigDecimal("99999.99")]
min_max(:decimal, 'decimal', :column_size=>6, :scale=>-2).must_equal [BigDecimal("-99999900.0"), BigDecimal("99999900.0")]
min_max(:decimal, 'decimal', :column_size=>3, :scale=>5).must_equal [BigDecimal("-0.00999"), BigDecimal("0.00999")]
min_max(:integer, 'decimal', :column_size=>8, :scale=>0).must_equal [-99999999, 99999999]
min_max(:integer, 'numeric', :column_size=>7, :scale=>0).must_equal [-9999999, 9999999]
min_max(:integer, 'decimal', :column_size=>7, :scale=>0).first.must_be_kind_of Integer
end

it "should parse minimum and maximum values for tinyint types where database tinyint type is unsigned by default" do
min_max(:integer, 'tinyint'){|db| def db.column_schema_tinyint_type_is_unsigned?; true end}.must_equal [0, 255]
end

it "should not parse minimum and maximum values for plain decimal or numeric types" do
min_max(:integer, 'decimal').must_equal [:none, :none]
min_max(:integer, 'numeric').must_equal [:none, :none]
min_max(:decimal, 'decimal').must_equal [:none, :none]
min_max(:decimal, 'numeric').must_equal [:none, :none]
end

it "should not parse minimum and maximum values for non-integer types" do
min_max(:string, 'varchar').must_equal [:none, :none]
end
Expand Down
72 changes: 51 additions & 21 deletions spec/integration/schema_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -175,13 +175,14 @@
DB.schema(:items).first.last[:db_type].must_equal db_type
end

int_types = [Integer, :Bignum]
limited_decimal_types = []
int_types = [Integer, :Bignum, [Numeric, {:size=>7}]]
decimal_types = [[Numeric, {:size=>[10, 2]}], [BigDecimal, {:size=>[8, 3]}]]

case DB.database_type
when :postgres
int_types.concat([:smallint, :int2, :int4, :int8])
limited_decimal_types.concat(["numeric(10, 2)", "decimal(10, 2)"])
decimal_types.concat(["numeric(3, 5)"])
decimal_types.concat(["numeric(3, -2)"]) if DB.server_version >= 150000
when :mysql
if DB.send(:supports_check_constraints?)
int_types.concat([:tinyint, :smallint, :mediumint, 'int(9)', 'tinyint(2)', "integer unsigned", "bigint unsigned", "tinyint unsigned", "smallint unsigned", "mediumint unsigned", 'int(9) unsigned', 'tinyint(2) unsigned'])
Expand All @@ -194,24 +195,33 @@
int_types.concat([:smallint])
when :sqlanywhere
int_types.concat([:tinyint])
when :sqlite, :oracle
# SQLite doesn't enforce integer type values, even on strict tables.
# Oracle only has a number type with variable precision, not a standard integer type.
when :sqlite
# SQLite doesn't enforce integer/decimal type values, even on strict tables.
int_types.clear
end

if int_types.empty?
it "should not parse maximum and minimum values for integer columns" do
DB.create_table!(:items){Integer :a}
sch = DB.schema(:items).first.last
sch.keys.wont_include :max_value
sch.keys.wont_include :min_value
decimal_types.clear
when :oracle
# Oracle only has a number type with variable precision, not a standard integer type.
int_types = [[Numeric, {:size=>7}]]
end

{
Integer=>int_types,
[Numeric, {:size=>[10,2]}]=>decimal_types,
Numeric=>[1],
}.each do |type, types|
if types.empty?
it "should not parse maximum and minimum values for #{type} columns" do
DB.create_table!(:items){column :a, *type}
sch = DB.schema(:items).first.last
sch.keys.wont_include :max_value
sch.keys.wont_include :min_value
end
end
end

int_types.each do |type|
it "should correctly parse maximum and minimum values for #{type} columns" do
DB.create_table!(:items){column :a, type}
DB.create_table!(:items){column :a, *type}
sch = DB.schema(:items).first.last
max = sch[:max_value]
min = sch[:min_value]
Expand All @@ -222,24 +232,44 @@
proc{ds.insert(min-1)}.must_raise(Sequel::DatabaseError, Sequel::InvalidValue)
ds.insert(max)
ds.insert(min)
ds.select_order_map(:a).must_equal [min, max]
if DB.adapter_scheme == :ibmdb
ds.select_order_map(Sequel.cast(:a, String)).map{|x| Integer(x)}.must_equal [min, max]
else
ds.select_order_map(:a).must_equal [min, max]
end
end
end

limited_decimal_types.each do |type|
decimal_types.each do |type|
it "should correctly parse maximum and minimum values for #{type} columns" do
DB.create_table!(:items){column :a, type}
DB.create_table!(:items){column :a, *type}
sch = DB.schema(:items).first.last
max = sch[:max_value]
min = sch[:min_value]
max.must_be_kind_of BigDecimal
min.must_be_kind_of BigDecimal
ds = DB[:items]
proc{ds.insert(max+1)}.must_raise(Sequel::DatabaseError, Sequel::InvalidValue)
proc{ds.insert(min-1)}.must_raise(Sequel::DatabaseError, Sequel::InvalidValue)

inc = case max.to_s('F')
when /\A9+\.0\z/
1
when /\A(?:0|9+)\.(0*9+)\z/
BigDecimal(1)/(10**$1.length)
when /\A(?:9+)(0+)\.0+\z/
BigDecimal(1) * (10**$1.length)
else
raise "spec error, cannot parse maximum value"
end

proc{ds.insert(max+inc)}.must_raise(Sequel::DatabaseError, Sequel::InvalidValue)
proc{ds.insert(min-inc)}.must_raise(Sequel::DatabaseError, Sequel::InvalidValue)
ds.insert(max)
ds.insert(min)
ds.select_order_map(:a).must_equal [min, max]
if DB.adapter_scheme == :oracle
ds.select_order_map(:a).map{|x| BigDecimal(x.to_s)}.must_equal [min, max]
else
ds.select_order_map(:a).must_equal [min, max]
end
end
end

Expand Down

0 comments on commit c99fabf

Please sign in to comment.