activerecord-databasevalidations 0.1.2 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: c54c89523b089da35cf81ac327720dc7747477e4
4
- data.tar.gz: b6c0284892866070449337290b70c5f481bc1c6b
3
+ metadata.gz: 090f45578b6802cdbdba1069b5d37a9a36936e53
4
+ data.tar.gz: 8804b23a035aaa84fa334caa69fa862971bf0ffc
5
5
  SHA512:
6
- metadata.gz: fa9279114bf9c478f74f52c6753ae91d4829fdf0ba4e076d20981a6c057b016da7de4dad5598ea7a541d3dcfb31d44e93017fe8fe7bbed9fa923b55c21677f2e
7
- data.tar.gz: 8efe103e02f9fdceaea9aea6e8094b9b7a224d605f30eacfad0dffb08221ba48394e96c3adbabea5679d626a0d9d15c01fa0471b0240ebfb95f7d5c4fc5a2beb
6
+ metadata.gz: 48f4ce81a05aa5798f73b24997eea4920027318e4c48cdae4824c806a3cee5c84688058f204af0bf074b29360037108939bca38c0389b52f18cd3d3fd52ab941
7
+ data.tar.gz: 33774245ec628ff1b463559346e3e11699e302b4a41417676caf42ad08b5a8f07f3f613fb0d724c41edd883e7b51b90a660889e75cf4134d9e9526b909c8c438
@@ -1,5 +1,5 @@
1
1
  module ActiveRecord
2
2
  module DatabaseValidations
3
- VERSION = "0.1.2"
3
+ VERSION = "0.1.3"
4
4
  end
5
5
  end
@@ -23,7 +23,7 @@ module ActiveRecord
23
23
 
24
24
  attr_reader :constraints
25
25
 
26
- VALID_CONSTRAINTS = Set[:size, :basic_multilingual_plane, :not_null]
26
+ VALID_CONSTRAINTS = Set[:size, :basic_multilingual_plane, :not_null, :range]
27
27
 
28
28
  def initialize(options = {})
29
29
  @constraints = Set.new(Array.wrap(options[:in]) + Array.wrap(options[:with]))
@@ -60,6 +60,29 @@ module ActiveRecord
60
60
  end
61
61
  end
62
62
 
63
+ def range_validator(klass, column)
64
+ return unless constraints.include?(:range)
65
+ return unless column.number?
66
+
67
+ unsigned = column.sql_type =~ / unsigned\z/
68
+ case column.type
69
+ when :decimal
70
+ args = { attributes: [column.name.to_sym], class: klass, allow_nil: true }
71
+ args[:less_than] = maximum = 10 ** (column.precision - column.scale)
72
+ if unsigned
73
+ args[:greater_than_or_equal_to] = 0
74
+ else
75
+ args[:greater_than] = 0 - maximum
76
+ end
77
+ ActiveModel::Validations::NumericalityValidator.new(args)
78
+
79
+ when :integer
80
+ maximum = unsigned ? 1 << (column.limit * 8) : 1 << (column.limit * 8 - 1)
81
+ minimum = unsigned ? 0 : 0 - maximum
82
+ ActiveModel::Validations::NumericalityValidator.new(attributes: [column.name.to_sym], class: klass, greater_than_or_equal_to: minimum, less_than: maximum, allow_nil: true, only_integer: true)
83
+ end
84
+ end
85
+
63
86
  def basic_multilingual_plane_validator(klass, column)
64
87
  return unless constraints.include?(:basic_multilingual_plane)
65
88
  return unless column.text? && column.collation =~ /\Autf8(?:mb3)?_/
@@ -74,6 +97,7 @@ module ActiveRecord
74
97
  not_null_validator(klass, column),
75
98
  size_validator(klass, column),
76
99
  basic_multilingual_plane_validator(klass, column),
100
+ range_validator(klass, column),
77
101
  ].compact
78
102
  end
79
103
  end
@@ -4,9 +4,19 @@ require 'test_helper'
4
4
 
5
5
  ActiveRecord::Migration.suppress_messages do
6
6
  ActiveRecord::Migration.create_table("unicorns", force: true, options: "CHARACTER SET utf8mb3") do |t|
7
- t.string :string, limit: 40
8
- t.text :tinytext, limit: 255
9
- t.binary :blob
7
+ t.column :string, "VARCHAR(40)"
8
+ t.column :tinytext, "TINYTEXT"
9
+ t.column :blob, "BLOB"
10
+
11
+ t.column :decimal, "DECIMAL(10, 2)"
12
+ t.column :unsigned_decimal, "DECIMAL(5, 3) UNSIGNED"
13
+
14
+ t.column :tinyint, "TINYINT"
15
+ t.column :smallint, "SMALLINT"
16
+ t.column :mediumint, "MEDIUMINT"
17
+ t.column :int, "INT"
18
+ t.column :bigint, "BIGINT"
19
+ t.column :unsigned_tinyint, "TINYINT UNSIGNED"
10
20
  end
11
21
  end
12
22
 
@@ -15,52 +25,83 @@ class Unicorn < ActiveRecord::Base
15
25
  end
16
26
 
17
27
  class DataLossTest < Minitest::Test
28
+ include DataLossAssertions
29
+
18
30
  def test_strict_mode_is_disabled
19
31
  refute Unicorn.connection.show_variable(:sql_mode).include?('STRICT_ALL_TABLES')
20
32
  refute Unicorn.connection.show_variable(:sql_mode).include?('STRICT_TRANS_TABLES')
21
33
  end
22
34
 
23
- def test_bounded_string_fields_silently_loses_data_when_strict_mode_is_disabled
24
- fitting_string = 'ü' * 40
25
- assert Unicorn.columns_hash['string'].limit >= fitting_string.length
26
- unicorn = Unicorn.create(string: fitting_string)
27
- assert_equal fitting_string, unicorn.reload.string
35
+ def test_decimal_silently_changes_out_of_bound_values
36
+ maximum = BigDecimal.new(10 ** (Unicorn.columns_hash['decimal'].precision - Unicorn.columns_hash['decimal'].scale))
37
+ delta = BigDecimal.new(10 ** -(Unicorn.columns_hash['decimal'].scale), Unicorn.columns_hash['decimal'].precision)
38
+
39
+ refute_data_loss Unicorn.new(decimal: maximum - delta)
40
+ assert_data_loss Unicorn.new(decimal: maximum)
41
+ refute_data_loss Unicorn.new(decimal: 0 - maximum + delta)
42
+ assert_data_loss Unicorn.new(decimal: 0 - maximum)
28
43
 
29
- overflowing_string = 'ü' * 41
30
- assert Unicorn.columns_hash['string'].limit < overflowing_string.length
31
- unicorn = Unicorn.create(string: overflowing_string)
32
- assert unicorn.reload.string != overflowing_string
44
+
45
+ maximum = BigDecimal.new(10 ** (Unicorn.columns_hash['unsigned_decimal'].precision - Unicorn.columns_hash['unsigned_decimal'].scale))
46
+ delta = BigDecimal.new(10 ** -(Unicorn.columns_hash['unsigned_decimal'].scale), Unicorn.columns_hash['unsigned_decimal'].precision)
47
+
48
+ refute_data_loss Unicorn.new(unsigned_decimal: maximum - delta)
49
+ assert_data_loss Unicorn.new(unsigned_decimal: maximum)
50
+ refute_data_loss Unicorn.new(unsigned_decimal: 0)
51
+ assert_data_loss Unicorn.new(unsigned_decimal: 0 - delta)
33
52
  end
34
53
 
35
- def test_text_field_silently_loses_data_when_strict_mode_is_disabled
36
- fitting_tinytext = '写' * 85
37
- assert_equal 255, fitting_tinytext.bytesize
38
- unicorn = Unicorn.create(tinytext: fitting_tinytext) # <= 255, the TINYTEXT limit
39
- assert_equal fitting_tinytext, unicorn.reload.tinytext
54
+ def test_integers_silently_change_value_outside_of_range
55
+ refute_data_loss Unicorn.new(tinyint: 127)
56
+ refute_data_loss Unicorn.new(tinyint: -128)
57
+ assert_data_loss Unicorn.new(tinyint: 128)
58
+ assert_data_loss Unicorn.new(tinyint: -129)
59
+
60
+ refute_data_loss Unicorn.new(smallint: 32_767)
61
+ refute_data_loss Unicorn.new(smallint: -32_768)
62
+ assert_data_loss Unicorn.new(smallint: 32_768)
63
+ assert_data_loss Unicorn.new(smallint: -32_769)
40
64
 
41
- overflowing_tinytext = 'ü' * 128
42
- assert_equal 256, overflowing_tinytext.bytesize # > 255, the TINYTEXT limit
43
- unicorn = Unicorn.create(tinytext: overflowing_tinytext)
44
- assert unicorn.reload.tinytext != overflowing_tinytext
65
+ refute_data_loss Unicorn.new(mediumint: 8_388_607)
66
+ refute_data_loss Unicorn.new(mediumint: -8_388_608)
67
+ assert_data_loss Unicorn.new(mediumint: 8_388_608)
68
+ assert_data_loss Unicorn.new(mediumint: -8_388_609)
69
+
70
+ refute_data_loss Unicorn.new(int: 2_147_483_647)
71
+ refute_data_loss Unicorn.new(int: -2_147_483_648)
72
+ assert_data_loss Unicorn.new(int: 2_147_483_648)
73
+ assert_data_loss Unicorn.new(int: -2_147_483_649)
74
+
75
+ refute_data_loss Unicorn.new(bigint: 9_223_372_036_854_775_807)
76
+ refute_data_loss Unicorn.new(bigint: -9_223_372_036_854_775_808)
77
+ assert_data_loss Unicorn.new(bigint: 9_223_372_036_854_775_808)
78
+ assert_data_loss Unicorn.new(bigint: -9_223_372_036_854_775_809)
79
+
80
+ refute_data_loss Unicorn.new(unsigned_tinyint: 255)
81
+ refute_data_loss Unicorn.new(unsigned_tinyint: 0)
82
+ assert_data_loss Unicorn.new(unsigned_tinyint: 256)
83
+ assert_data_loss Unicorn.new(unsigned_tinyint: -1)
45
84
  end
46
85
 
47
- def test_binary_field_silently_loses_data_when_strict_mode_is_disabled
48
- fitting_blob = [].pack('x65535')
49
- assert_equal 65535, fitting_blob.bytesize
50
- unicorn = Unicorn.create(blob: fitting_blob) # <= 65535, the BLOB limit
51
- assert_equal fitting_blob, unicorn.reload.blob
86
+ def test_varchar_field_silently_drops_characters_when_over_character_limit
87
+ refute_data_loss Unicorn.new(string: 'ü' * 40)
88
+ assert_data_loss Unicorn.new(string: 'ü' * 41)
89
+ end
90
+
91
+ def test_text_field_silently_drops_charactars_when_when_over_bytesize_limit
92
+ refute_data_loss Unicorn.new(tinytext: '写' * 85) # 85 * 3 = 255 bytes => fits
93
+ assert_data_loss Unicorn.new(tinytext: 'ü' * 128) # 128 * 2 = 256 bytes => doesn't fit :()
94
+ end
52
95
 
53
- overflowing_blob = [].pack('x65536')
54
- assert_equal 65536, overflowing_blob.bytesize # > 65535, the BLOB limit
55
- unicorn = Unicorn.create(blob: overflowing_blob)
56
- assert unicorn.reload.blob != overflowing_blob
96
+ def test_blob_field_silently_drops_bytes_when_when_over_bytesize_limit
97
+ refute_data_loss Unicorn.new(blob: [].pack('x65535')) # 65535 is bytesize limit of blob field
98
+ assert_data_loss Unicorn.new(blob: [].pack('x65536'))
57
99
  end
58
100
 
59
- def test_unchecked_utf8mb3_field_silently_loses_data_when_strict_mode_is_disabled
101
+ def test_utf8mb3_field_sliently_truncates_strings_after_first_4byte_character
60
102
  emoji = '💩'
61
103
  assert_equal 1, emoji.length
62
104
  assert_equal 4, emoji.bytesize
63
- unicorn = Unicorn.create(string: emoji)
64
- assert unicorn.reload.string != emoji
105
+ assert_data_loss Unicorn.new(string: emoji)
65
106
  end
66
107
  end
@@ -2,5 +2,5 @@ test:
2
2
  adapter: mysql2
3
3
  database: database_validations
4
4
  username: travis
5
- encoding: utf8
5
+ encoding: utf8mb4
6
6
  strict: false
@@ -4,18 +4,29 @@ require 'test_helper'
4
4
 
5
5
  ActiveRecord::Migration.suppress_messages do
6
6
  ActiveRecord::Migration.create_table("foos", force: true, options: "CHARACTER SET utf8mb3") do |t|
7
- t.string :string, limit: 40
8
- t.text :tinytext, limit: 255
9
- t.binary :varbinary, limit: 255
7
+ t.string :string, limit: 40
8
+ t.text :tinytext, limit: 255
9
+ t.binary :varbinary, limit: 255
10
10
  t.binary :blob
11
11
  t.text :not_null_text, null: false
12
- t.integer :checked, null: false, default: 0
12
+ t.integer :checked, null: false, default: 0
13
13
  t.integer :unchecked, null: false
14
14
  end
15
15
 
16
16
  ActiveRecord::Migration.create_table("bars", force: true, options: "CHARACTER SET utf8mb4") do |t|
17
17
  t.string :mb4_string
18
18
  end
19
+
20
+ ActiveRecord::Migration.create_table("nums", force: true) do |t|
21
+ t.column :decimal, "DECIMAL(5,2)"
22
+ t.column :unsigned_decimal, "DECIMAL(5,2) UNSIGNED"
23
+ t.column :tinyint, "TINYINT"
24
+ t.column :smallint, "SMALLINT"
25
+ t.column :mediumint, "MEDIUMINT"
26
+ t.column :int, "INT"
27
+ t.column :bigint, "BIGINT"
28
+ t.column :unsigned_int, "INT UNSIGNED"
29
+ end
19
30
  end
20
31
 
21
32
  class Foo < ActiveRecord::Base
@@ -28,7 +39,13 @@ class Bar < ActiveRecord::Base
28
39
  validates :mb4_string, database_constraints: :basic_multilingual_plane
29
40
  end
30
41
 
42
+ class Num < ActiveRecord::Base
43
+ validates :decimal, :unsigned_decimal, :tinyint, :smallint, :mediumint, :int, :bigint, :unsigned_int, database_constraints: :range
44
+ end
45
+
31
46
  class DatabaseConstraintsValidatorTest < Minitest::Test
47
+ include DataLossAssertions
48
+
32
49
  def test_argument_validation
33
50
  assert_raises(ArgumentError) { Bar.validates(:mb4_string, database_constraints: []) }
34
51
  assert_raises(ArgumentError) { Bar.validates(:mb4_string, database_constraints: true) }
@@ -88,9 +105,99 @@ class DatabaseConstraintsValidatorTest < Minitest::Test
88
105
  refute Foo.new(checked: nil).valid?
89
106
  end
90
107
 
91
- def test_should_not_create_a_validor_for_a_utf8mb4_field
92
- assert Bar.new(mb4_string: '💩').valid?
93
- Bar._validators[:mb4_string].first.attribute_validators(Bar, :mb4_string).empty?
108
+ def test_should_not_create_a_validator_for_a_utf8mb4_field
109
+ assert Bar._validators[:mb4_string].first.attribute_validators(Bar, :mb4_string).empty?
110
+ emoji = Bar.new(mb4_string: '💩')
111
+ assert emoji.valid?
112
+ refute_data_loss emoji
113
+ end
114
+
115
+ def test_decimal_range
116
+ subvalidators = Num._validators[:decimal].first.attribute_validators(Num, :decimal)
117
+ assert_equal 1, subvalidators.length
118
+ assert_kind_of ActiveModel::Validations::NumericalityValidator, subvalidators.first
119
+
120
+ inside_upper_bound = Num.new(decimal: '999.99')
121
+ assert inside_upper_bound.valid?
122
+ refute_data_loss(inside_upper_bound)
123
+
124
+ inside_lower_bound = Num.new(decimal: '-999.99')
125
+ assert inside_lower_bound.valid?
126
+ refute_data_loss(inside_lower_bound)
127
+
128
+ outside_upper_bound = Num.new(decimal: '1000.00')
129
+ refute outside_upper_bound.valid?
130
+ assert_data_loss(outside_upper_bound)
131
+
132
+ outside_lower_bound = Num.new(decimal: '-1000.00')
133
+ refute outside_lower_bound.valid?
134
+ assert_data_loss(outside_lower_bound)
135
+ end
136
+
137
+ def test_unsigned_decimal_range
138
+ subvalidators = Num._validators[:unsigned_decimal].first.attribute_validators(Num, :unsigned_decimal)
139
+ assert_equal 1, subvalidators.length
140
+ assert_kind_of ActiveModel::Validations::NumericalityValidator, subvalidators.first
141
+
142
+ inside_upper_bound = Num.new(unsigned_decimal: '999.99')
143
+ assert inside_upper_bound.valid?
144
+ refute_data_loss(inside_upper_bound)
145
+
146
+ inside_lower_bound = Num.new(unsigned_decimal: '0.00')
147
+ assert inside_lower_bound.valid?
148
+ refute_data_loss(inside_lower_bound)
149
+
150
+ outside_upper_bound = Num.new(unsigned_decimal: '1000.00')
151
+ refute outside_upper_bound.valid?
152
+ assert_data_loss(outside_upper_bound)
153
+
154
+ outside_lower_bound = Num.new(unsigned_decimal: '-0.01')
155
+ refute outside_lower_bound.valid?
156
+ assert_data_loss(outside_lower_bound)
157
+ end
158
+
159
+ def test_integer_range
160
+ subvalidators = Num._validators[:bigint].first.attribute_validators(Num, :bigint)
161
+ assert_equal 1, subvalidators.length
162
+ assert_kind_of ActiveModel::Validations::NumericalityValidator, range_validator = subvalidators.first
163
+
164
+ inside_upper_bound = Num.new(tinyint: 127)
165
+ assert inside_upper_bound.valid?
166
+ refute_data_loss(inside_upper_bound)
167
+
168
+ inside_lower_bound = Num.new(smallint: -32_768)
169
+ assert inside_lower_bound.valid?
170
+ refute_data_loss(inside_lower_bound)
171
+
172
+ outside_upper_bound = Num.new(mediumint: 8_388_608)
173
+ refute outside_upper_bound.valid?
174
+ assert_data_loss(outside_upper_bound)
175
+
176
+ outside_lower_bound = Num.new(int: -2_147_483_649)
177
+ refute outside_lower_bound.valid?
178
+ assert_data_loss(outside_lower_bound)
179
+ end
180
+
181
+ def unsigned_integer_range
182
+ subvalidators = Num._validators[:unsigned_int].first.attribute_validators(Num, :unsigned_int)
183
+ assert_equal 1, subvalidators.length
184
+ assert_kind_of ActiveModel::Validations::NumericalityValidator, range_validator = subvalidators.first
185
+
186
+ inside_upper_bound = Num.new(unsigned_int: 4_294_967_295)
187
+ assert inside_upper_bound.valid?
188
+ refute_data_loss(inside_upper_bound)
189
+
190
+ inside_lower_bound = Num.new(unsigned_int: 0)
191
+ assert inside_lower_bound.valid?
192
+ refute_data_loss(inside_lower_bound)
193
+
194
+ outside_upper_bound = Num.new(unsigned_int: 4_294_967_296)
195
+ refute outside_upper_bound.valid?
196
+ assert_data_loss(outside_upper_bound)
197
+
198
+ outside_lower_bound = Num.new(unsigned_int: -1)
199
+ refute outside_lower_bound.valid?
200
+ assert_data_loss(outside_lower_bound)
94
201
  end
95
202
 
96
203
  def test_error_messages
@@ -105,5 +212,6 @@ class DatabaseConstraintsValidatorTest < Minitest::Test
105
212
  def test_encoding_craziness
106
213
  foo = Foo.new(tinytext: ('ü' * 128).encode('ISO-8859-15'), string: ('ü' * 40).encode('ISO-8859-15'))
107
214
  assert foo.invalid?
215
+ assert_data_loss foo
108
216
  end
109
217
  end
@@ -9,6 +9,28 @@ require "yaml"
9
9
 
10
10
  require "active_record/database_validations"
11
11
 
12
+ module DataLossAssertions
13
+ def assert_data_loss(record)
14
+ attributes = record.changed
15
+ provided_values = record.attributes.slice(*attributes)
16
+
17
+ record.save!(validate: false)
18
+
19
+ persisted_values = record.reload.attributes.slice(*attributes)
20
+ refute_equal provided_values, persisted_values
21
+ end
22
+
23
+ def refute_data_loss(record)
24
+ attributes = record.changed
25
+ provided_values = record.attributes.slice(*attributes)
26
+
27
+ record.save!(validate: false)
28
+
29
+ persisted_values = record.reload.attributes.slice(*attributes)
30
+ assert_equal provided_values, persisted_values
31
+ end
32
+ end
33
+
12
34
  Minitest::Test = MiniTest::Unit::TestCase unless defined?(MiniTest::Test)
13
35
 
14
36
  database_yml = YAML.load_file(File.expand_path('../database.yml', __FILE__))
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: activerecord-databasevalidations
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Willem van Bergen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2014-10-16 00:00:00.000000000 Z
11
+ date: 2014-10-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord