coaster 1.4.37 → 1.4.39

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
  SHA256:
3
- metadata.gz: bba62b6d537a978d276cce15b24fbadc212264a4f76c2b79957c2db624c82123
4
- data.tar.gz: 11824cc9943176d3cc97cee10976ffe753a532dc72235bd69dacf86af6a9d1fc
3
+ metadata.gz: db3e3c711b051a059c028d9de5543c55ea1db20e5134b790308a310357d04780
4
+ data.tar.gz: 75f518e585672bca7c8f84c034b4bbc92e3bb330126d6aebc8c867558a1f7396
5
5
  SHA512:
6
- metadata.gz: c36188aa4545dc5e96d1d30ff2feb8b8324c458ec620dd3de54380eee427d9d2914d07f2b1d03d892790d76f2531f340f237c84765f152a3f95a5a7e5f8503d5
7
- data.tar.gz: 2de119abb062d89d8a5ce7998f19c83c1cae7059f8ae6fc14959e55832662dd8d060fb6591a662cc70ae16729ed26d9b9424a282fa5e583dc59153d1c4320158
6
+ metadata.gz: a529d3b506a6ba67c320314746580007cdd5ff9912acdf4989581e8b622fdf6386e9bed1da388a192345bbbf306d705ff6a133b26c77aac601709906ffc39d32
7
+ data.tar.gz: d48154a8d863cd647a525fa9a3e2e4288d47daf43528f28f9f954092161beb3b4e2ab7ef16f5e77455337f502b1716a10d3a578ff049f04ad8aa1b1a56dc1108
@@ -17,6 +17,66 @@ module Coaster
17
17
  def sprop_previous_change(key) = sprop_previous_changes[key.to_s]
18
18
  def sprop_previously_changed?(key) = sprop_previous_change(key).present?
19
19
  def sprop_previously_was(key) = (ch = sprop_previous_change(key)).present? ? ch[0] : nil
20
+
21
+ if defined?(ActiveRecord::Base) && base < ActiveRecord::Base
22
+ # Internal method to write serialized property value
23
+ # @param key [String, Symbol] property key
24
+ # @param value [Object] value to write
25
+ # @param apply_setter [Boolean] whether to apply setter proc
26
+ def _write_sprop_value(key, value, apply_setter: true)
27
+ setting = self.class.serialized_property_setting(key)
28
+ return unless setting
29
+ col = setting[:column]
30
+ hsh = send(col)
31
+ if value.nil?
32
+ hsh.delete(key.to_s)
33
+ else
34
+ value = setting[:setter].call(value) if apply_setter && setting[:setter]
35
+ hsh[key.to_s] = value
36
+ end
37
+ value
38
+ end
39
+
40
+ def write_attribute(attr_name, value)
41
+ if self.class.serialized_property_setting(attr_name)
42
+ _write_sprop_value(attr_name, value, apply_setter: false)
43
+ else
44
+ super
45
+ end
46
+ end
47
+
48
+ def read_attribute(attr_name)
49
+ if (setting = self.class.serialized_property_setting(attr_name))
50
+ col = setting[:column]
51
+ hsh = super(col) || {}
52
+ val = hsh[attr_name.to_s]
53
+ val = setting[:getter].call(val) if setting[:getter]
54
+ val
55
+ else
56
+ super
57
+ end
58
+ end
59
+
60
+ def [](attr_name)
61
+ if (setting = self.class.serialized_property_setting(attr_name))
62
+ col = setting[:column]
63
+ hsh = super(col) || {}
64
+ val = hsh[attr_name.to_s]
65
+ val = setting[:getter].call(val) if setting[:getter]
66
+ val
67
+ else
68
+ super
69
+ end
70
+ end
71
+
72
+ def []=(attr_name, value)
73
+ if self.class.serialized_property_setting(attr_name)
74
+ _write_sprop_value(attr_name, value, apply_setter: false)
75
+ else
76
+ super
77
+ end
78
+ end
79
+ end
20
80
  end
21
81
  end
22
82
 
@@ -30,14 +90,40 @@ module Coaster
30
90
  end
31
91
  end
32
92
 
93
+ def own_serialized_property_settings
94
+ @own_serialized_property_settings ||= {}
95
+ end
96
+
33
97
  def serialized_property_settings
34
- @serialized_property_settings ||= {}
98
+ @serialized_property_settings ||= if superclass.respond_to?(:serialized_property_settings)
99
+ superclass.serialized_property_settings.dup
100
+ else
101
+ {}
102
+ end
35
103
  end
36
104
 
37
105
  def serialized_property_setting(key)
38
106
  serialized_property_settings[key.to_sym]
39
107
  end
40
108
 
109
+ def set_serialized_property_setting(key, setting)
110
+ own_serialized_property_settings[key.to_sym] = setting
111
+ serialized_property_settings[key.to_sym] = setting
112
+ end
113
+
114
+ def delete_serialized_property_setting(key)
115
+ own_serialized_property_settings.delete(key.to_sym)
116
+ serialized_property_settings.delete(key.to_sym)
117
+ end
118
+
119
+ def rename_serialized_property_setting(from_key, to_key)
120
+ setting = own_serialized_property_settings.delete(from_key.to_sym)
121
+ own_serialized_property_settings[to_key.to_sym] = setting
122
+ serialized_property_settings.delete(from_key.to_sym)
123
+ serialized_property_settings[to_key.to_sym] = setting
124
+ setting
125
+ end
126
+
41
127
  def serialized_column(serialize_column)
42
128
  define_method serialize_column.to_sym do
43
129
  return read_attribute(serialize_column.to_sym) if read_attribute(serialize_column.to_sym)
@@ -53,8 +139,8 @@ module Coaster
53
139
  end
54
140
 
55
141
  def serialized_property(serialize_column, key, type: nil, comment: nil, getter: nil, setter: nil, setter_callback: nil, default: nil, rescuer: nil)
56
- raise DuplicatedProperty, "#{self.name}##{key} duplicated\n#{caller[0..5].join("\n")}" if serialized_property_settings[key.to_sym]
57
- serialized_property_settings[key.to_sym] = {column: serialize_column.to_sym, type: type, comment: comment, getter: getter, setter: setter, setter_callback: setter_callback, default: default, rescuer: rescuer}
142
+ raise DuplicatedProperty, "#{self.name}##{key} duplicated\n#{caller[0..5].join("\n")}" if own_serialized_property_settings[key.to_sym]
143
+ set_serialized_property_setting(key, {column: serialize_column.to_sym, type: type, comment: comment, getter: getter, setter: setter, setter_callback: setter_callback, default: default, rescuer: rescuer})
58
144
  _typed_serialized_property(serialize_column, key, type: type, getter: getter, setter: setter, setter_callback: setter_callback, default: default, rescuer: rescuer)
59
145
  end
60
146
 
@@ -98,7 +184,7 @@ module Coaster
98
184
  end
99
185
  end
100
186
  if type
101
- serialized_property_setting(key.to_sym)[:type] = type
187
+ serialized_property_setting(key)[:type] = type
102
188
  _typed_serialized_property serialize_column, key, type: type, getter: getter, setter: setter, setter_callback: setter_callback, default: default
103
189
  end
104
190
  end
@@ -145,7 +231,7 @@ module Coaster
145
231
  elsif type.respond_to?(:serialized_property_serializer) && (serializer = type.serialized_property_serializer)
146
232
  _define_serialized_property(serialize_column, key, getter: serializer[:getter], setter: serializer[:setter], setter_callback: serializer[:setter_callback], default: default)
147
233
  elsif (type.is_a?(Symbol) && (t = type.to_s.constantize rescue nil)) || (type.is_a?(Class) && type < ActiveRecord::Base && (t = type))
148
- serialized_property_settings["#{key}_id".to_sym] = serialized_property_settings.delete(key.to_sym) # rename key from setting
234
+ rename_serialized_property_setting(key, "#{key}_id")
149
235
  _define_serialized_property serialize_column, "#{key}_id", default: default
150
236
 
151
237
  define_method key.to_sym do
@@ -189,17 +275,22 @@ module Coaster
189
275
  end
190
276
 
191
277
  def _define_serialized_property(serialize_column, key, getter: nil, setter: nil, setter_callback: nil, default: nil)
278
+ is_active_record = defined?(ActiveRecord::Base) && self < ActiveRecord::Base
192
279
  if default
193
280
  if getter
194
281
  define_method key.to_sym do
195
282
  hsh = send(serialize_column.to_sym)
196
- hsh[key.to_s] ||= default.dup
283
+ if hsh[key.to_s].nil?
284
+ hsh[key.to_s] = default.dup
285
+ end
197
286
  getter.call(hsh[key.to_s])
198
287
  end
199
288
  else
200
289
  define_method key.to_sym do
201
290
  hsh = send(serialize_column.to_sym)
202
- hsh[key.to_s] ||= default.dup
291
+ if hsh[key.to_s].nil?
292
+ hsh[key.to_s] = default.dup
293
+ end
203
294
  hsh[key.to_s]
204
295
  end
205
296
  end
@@ -217,20 +308,24 @@ module Coaster
217
308
  end
218
309
  end
219
310
 
220
- if setter
311
+ if is_active_record
221
312
  define_method "#{key}_without_callback=".to_sym do |val|
313
+ col = serialize_column
314
+ hsh = send(col)
222
315
  if val.nil?
223
- send(serialize_column.to_sym).delete(key.to_s)
316
+ hsh.delete(key.to_s)
224
317
  else
225
318
  val = setter.call(val) if setter
226
- send(serialize_column.to_sym)[key.to_s] = val
319
+ hsh[key.to_s] = val
227
320
  end
321
+ val
228
322
  end
229
323
  else
230
324
  define_method "#{key}_without_callback=".to_sym do |val|
231
325
  if val.nil?
232
326
  send(serialize_column.to_sym).delete(key.to_s)
233
327
  else
328
+ val = setter.call(val) if setter
234
329
  send(serialize_column.to_sym)[key.to_s] = val
235
330
  end
236
331
  end
@@ -1,3 +1,3 @@
1
1
  module Coaster
2
- VERSION = '1.4.37'
2
+ VERSION = '1.4.39'
3
3
  end
@@ -6,6 +6,7 @@ class User < ActiveRecord::Base
6
6
  extend Coaster::SerializedProperties
7
7
  serialized_column :data
8
8
  serialized_property :data, :appendix, default: {}
9
+ serialized_property :data, :simple
9
10
  serialized_property :data, :father, type: :User
10
11
  serialized_property :data, :mother, type: self
11
12
 
@@ -14,3 +15,29 @@ class User < ActiveRecord::Base
14
15
  appendix['test_key2'] ||= 0
15
16
  end
16
17
  end
18
+
19
+ class Student < User
20
+ serialized_property :data, :grade
21
+ serialized_property :data, :scores, default: {}
22
+
23
+ # Property with getter (transforms on read)
24
+ serialized_property :data, :score_percentage,
25
+ getter: ->(val) { val.nil? ? nil : "#{val}%" }
26
+
27
+ # Property with setter (transforms on write)
28
+ serialized_property :data, :uppercase_name,
29
+ setter: ->(val) { val.to_s.upcase }
30
+
31
+ # Property with both getter and setter
32
+ serialized_property :data, :encrypted_value,
33
+ getter: ->(val) { val.nil? ? nil : Base64.decode64(val) },
34
+ setter: ->(val) { Base64.strict_encode64(val.to_s) }
35
+
36
+ # Property with type: Time (uses ISO8601 string internally)
37
+ serialized_property :data, :enrolled_at, type: Time
38
+
39
+ # Property with type: :unix_epoch (uses integer timestamp internally)
40
+ serialized_property :data, :graduated_at, type: :unix_epoch
41
+
42
+ serialized_property :data, :teacher, type: 'User'
43
+ end
@@ -1,5 +1,6 @@
1
1
  ActiveRecord::Schema.define do
2
2
  create_table :users do |t|
3
+ t.string :type
3
4
  t.string :key
4
5
  t.string :name
5
6
  t.integer :age
data/test/test_helper.rb CHANGED
@@ -10,11 +10,17 @@ require 'bundler/setup'
10
10
  require 'coaster'
11
11
  require 'logger'
12
12
 
13
+ require 'rails'
13
14
  require 'active_record'
15
+
16
+ class TestApp < Rails::Application
17
+ config.eager_load = false
18
+ end
19
+ Rails.application.initialize!
20
+
14
21
  ActiveRecord::Base.establish_connection adapter: 'sqlite3', database: ":memory:"
15
22
  load File.expand_path('../support/schema.rb', __FILE__)
16
23
  load File.expand_path('../support/models.rb', __FILE__)
17
- require 'rails'
18
24
 
19
25
  class Raven
20
26
  def self.capture_exception(*args)
@@ -11,7 +11,7 @@ module Coaster
11
11
 
12
12
  def test_serialized
13
13
  user = User.create(name: 'abc')
14
- assert_equal([:appendix, :father_id, :mother_id], User.serialized_property_settings.keys)
14
+ assert_equal([:appendix, :simple, :father_id, :mother_id], User.serialized_property_settings.keys)
15
15
  user.init_appendix
16
16
  assert_equal 0, user.appendix['test_key1']
17
17
  assert_equal 0, user.appendix['test_key2']
@@ -24,7 +24,7 @@ module Coaster
24
24
  assert_equal mother, user.mother
25
25
  assert_equal mother.id, user.mother_id
26
26
  assert_equal({"appendix"=>{"test_key1"=>0, "test_key2"=>0}, "father_id"=>father.id, "mother_id"=>mother.id}, user.data)
27
- assert_equal({"appendix" => [nil, {"test_key1" => 0, "test_key2" => 0}], "father_id" => [nil, 2], "mother_id" => [nil, 3]}, user.sprop_changes)
27
+ assert_equal({"appendix" => [nil, {"test_key1" => 0, "test_key2" => 0}], "father_id" => [nil, father.id], "mother_id" => [nil, mother.id]}, user.sprop_changes)
28
28
  assert_equal(true, user.mother_id_changed?)
29
29
  assert_equal(nil, user.mother_id_was)
30
30
  user.save!
@@ -35,6 +35,342 @@ module Coaster
35
35
  assert_equal({"appendix"=>{"test_key1"=>0, "test_key2"=>0}, "father_id"=>father.id, "mother_id"=>mother.id}, user.data)
36
36
  assert_equal mother, user.mother
37
37
  assert_equal father, user.father
38
+ user.write_attribute(:simple, 100)
39
+ assert_equal(100, user.read_attribute(:simple))
40
+ end
41
+
42
+ def test_active_record_attribute_methods
43
+ user = User.create(name: 'test_ar')
44
+
45
+ # Test write_attribute / read_attribute
46
+ user.write_attribute(:simple, 'test_value')
47
+ assert_equal 'test_value', user.read_attribute(:simple)
48
+ assert_equal 'test_value', user.simple
49
+
50
+ # Test [] / []= accessors
51
+ user[:simple] = 'bracket_value'
52
+ assert_equal 'bracket_value', user[:simple]
53
+ assert_equal 'bracket_value', user.simple
54
+
55
+ # Verify it's stored in the serialized column
56
+ assert_equal 'bracket_value', user.data['simple']
57
+
58
+ # Test that changes are persisted
59
+ user.save!
60
+ user.reload
61
+ assert_equal 'bracket_value', user.simple
62
+ assert_equal 'bracket_value', user.read_attribute(:simple)
63
+ assert_equal 'bracket_value', user[:simple]
64
+ end
65
+
66
+ def test_dirty_tracking_with_will_change
67
+ user = User.create(name: 'test_dirty')
68
+
69
+ # Initially no changes
70
+ assert_equal false, user.simple_changed?
71
+
72
+ # After setting value, should be marked as changed
73
+ user.simple = 'new_value'
74
+ assert_equal true, user.simple_changed?
75
+ assert_equal [nil, 'new_value'], user.simple_change
76
+
77
+ # Save and verify previous change tracking
78
+ user.save!
79
+ assert_equal false, user.simple_changed?
80
+ assert_equal true, user.simple_previously_changed?
81
+ assert_equal [nil, 'new_value'], user.simple_previous_change
82
+
83
+ # Update again
84
+ user.simple = 'updated_value'
85
+ assert_equal true, user.simple_changed?
86
+ assert_equal 'new_value', user.simple_was
87
+ user.save!
88
+ assert_equal 'new_value', user.simple_previously_was
89
+ end
90
+
91
+ def test_default_value_persistence
92
+ user = User.create(name: 'test_default')
93
+
94
+ # appendix has default: {}
95
+ # Modifying the returned hash should persist to data
96
+ user.appendix['key1'] = 'value1'
97
+ assert_equal 'value1', user.appendix['key1']
98
+ assert_equal({'key1' => 'value1'}, user.data['appendix'])
99
+
100
+ # Verify persistence after save
101
+ user.save!
102
+ user.reload
103
+ assert_equal 'value1', user.appendix['key1']
104
+ end
105
+
106
+ def test_inherited_model_serialized_properties
107
+ # Student inherits from User and has its own serialized properties
108
+ student = Student.create(name: 'test_student')
109
+
110
+ # Should have access to parent's serialized properties
111
+ student.simple = 'inherited_value'
112
+ assert_equal 'inherited_value', student.simple
113
+
114
+ # Should have access to parent's default property
115
+ student.appendix['parent_key'] = 'parent_value'
116
+ assert_equal 'parent_value', student.appendix['parent_key']
117
+
118
+ # Should have access to own serialized properties
119
+ student.grade = 'A'
120
+ assert_equal 'A', student.grade
121
+
122
+ # Should have access to own default property
123
+ student.scores['math'] = 95
124
+ student.scores['english'] = 88
125
+ assert_equal 95, student.scores['math']
126
+ assert_equal 88, student.scores['english']
127
+
128
+ # Verify data structure
129
+ assert_equal 'inherited_value', student.data['simple']
130
+ assert_equal 'A', student.data['grade']
131
+ assert_equal({'parent_key' => 'parent_value'}, student.data['appendix'])
132
+ assert_equal({'math' => 95, 'english' => 88}, student.data['scores'])
133
+
134
+ # Verify persistence
135
+ student.save!
136
+ student.reload
137
+ assert_equal 'inherited_value', student.simple
138
+ assert_equal 'A', student.grade
139
+ assert_equal 'parent_value', student.appendix['parent_key']
140
+ assert_equal 95, student.scores['math']
141
+ end
142
+
143
+ def test_inherited_model_attribute_methods
144
+ student = Student.create(name: 'test_student_ar')
145
+
146
+ # Test write_attribute / read_attribute for inherited property
147
+ student.write_attribute(:simple, 'write_test')
148
+ assert_equal 'write_test', student.read_attribute(:simple)
149
+
150
+ # Test write_attribute / read_attribute for own property
151
+ student.write_attribute(:grade, 'B+')
152
+ assert_equal 'B+', student.read_attribute(:grade)
153
+
154
+ # Test [] / []= for inherited property
155
+ student[:simple] = 'bracket_inherited'
156
+ assert_equal 'bracket_inherited', student[:simple]
157
+
158
+ # Test [] / []= for own property
159
+ student[:grade] = 'A-'
160
+ assert_equal 'A-', student[:grade]
161
+ end
162
+
163
+ def test_inherited_model_dirty_tracking
164
+ student = Student.create(name: 'test_student_dirty')
165
+
166
+ # Test dirty tracking for inherited property
167
+ student.simple = 'new_simple'
168
+ assert_equal true, student.simple_changed?
169
+
170
+ # Test dirty tracking for own property
171
+ student.grade = 'A'
172
+ assert_equal true, student.grade_changed?
173
+
174
+ student.save!
175
+ assert_equal false, student.simple_changed?
176
+ assert_equal false, student.grade_changed?
177
+ assert_equal true, student.simple_previously_changed?
178
+ assert_equal true, student.grade_previously_changed?
179
+ end
180
+
181
+ def test_inherited_model_settings
182
+ # Student should have both parent's and own property settings via serialized_property_setting
183
+ assert Student.serialized_property_setting(:appendix), 'should access parent appendix'
184
+ assert Student.serialized_property_setting(:simple), 'should access parent simple'
185
+ assert Student.serialized_property_setting(:grade), 'should access own grade'
186
+ assert Student.serialized_property_setting(:scores), 'should access own scores'
187
+
188
+ # serialized_property_settings should include both parent and own settings
189
+ all_settings = Student.serialized_property_settings
190
+ assert all_settings[:appendix], 'should include parent appendix'
191
+ assert all_settings[:simple], 'should include parent simple'
192
+ assert all_settings[:grade], 'should include own grade'
193
+ assert all_settings[:scores], 'should include own scores'
194
+
195
+ # own_serialized_property_settings should only include own settings
196
+ own_settings = Student.own_serialized_property_settings
197
+ assert_nil own_settings[:appendix], 'should not include parent appendix'
198
+ assert_nil own_settings[:simple], 'should not include parent simple'
199
+ assert own_settings[:grade], 'should include own grade'
200
+ assert own_settings[:scores], 'should include own scores'
201
+ end
202
+
203
+ def test_getter_transforms_on_read
204
+ student = Student.create(name: 'test_getter')
205
+
206
+ # Set raw value via write_attribute (bypasses setter)
207
+ student.write_attribute(:score_percentage, 85)
208
+ assert_equal '85%', student.score_percentage
209
+
210
+ # Verify raw value in data
211
+ assert_equal 85, student.data['score_percentage']
212
+
213
+ # Persistence
214
+ student.save!
215
+ student.reload
216
+ assert_equal '85%', student.score_percentage
217
+ end
218
+
219
+ def test_setter_transforms_on_write
220
+ student = Student.create(name: 'test_setter')
221
+
222
+ # Set via setter - should transform to uppercase
223
+ student.uppercase_name = 'john doe'
224
+ assert_equal 'JOHN DOE', student.uppercase_name
225
+ assert_equal 'JOHN DOE', student.data['uppercase_name']
226
+
227
+ # Persistence
228
+ student.save!
229
+ student.reload
230
+ assert_equal 'JOHN DOE', student.uppercase_name
231
+ end
232
+
233
+ def test_write_attribute_bypasses_setter
234
+ student = Student.create(name: 'test_write_attr')
235
+
236
+ # write_attribute should NOT apply setter transformation
237
+ student.write_attribute(:uppercase_name, 'lowercase')
238
+ assert_equal 'lowercase', student.uppercase_name
239
+ assert_equal 'lowercase', student.data['uppercase_name']
240
+
241
+ # But setter should apply transformation
242
+ student.uppercase_name = 'another value'
243
+ assert_equal 'ANOTHER VALUE', student.uppercase_name
244
+ end
245
+
246
+ def test_getter_and_setter_together
247
+ student = Student.create(name: 'test_both')
248
+
249
+ # Set value - setter encodes to Base64
250
+ student.encrypted_value = 'secret data'
251
+
252
+ # Raw value should be Base64 encoded
253
+ raw_value = student.data['encrypted_value']
254
+ assert_equal 'c2VjcmV0IGRhdGE=', raw_value
255
+
256
+ # Getter should decode
257
+ assert_equal 'secret data', student.encrypted_value
258
+
259
+ # Persistence
260
+ student.save!
261
+ student.reload
262
+ assert_equal 'secret data', student.encrypted_value
263
+ assert_equal 'c2VjcmV0IGRhdGE=', student.data['encrypted_value']
264
+ end
265
+
266
+ def test_time_type_property
267
+ Time.zone = 'UTC'
268
+ student = Student.create(name: 'test_time')
269
+ time = Time.zone.parse('2024-06-15 10:30:00 UTC')
270
+
271
+ # Use the _without_callback= method which applies setter via _define_serialized_property
272
+ student.enrolled_at_without_callback = time
273
+
274
+ # Raw value should be ISO8601 string (setter transforms Time to string)
275
+ raw_value = student.data['enrolled_at']
276
+ assert raw_value.is_a?(String), "Expected String but got #{raw_value.class}"
277
+ assert raw_value.include?('2024-06-15')
278
+
279
+ # getter transforms string back to Time
280
+ assert_equal time.to_i, student.enrolled_at.to_i
281
+
282
+ # Persistence
283
+ student.save!
284
+ student.reload
285
+ assert_equal time.to_i, student.enrolled_at.to_i
286
+ end
287
+
288
+ def test_unix_epoch_type_property
289
+ Time.zone = 'UTC'
290
+ student = Student.create(name: 'test_unix')
291
+ time = Time.zone.parse('2024-06-15 10:30:00 UTC')
292
+
293
+ # Use the _without_callback= method which applies setter
294
+ student.graduated_at = time
295
+
296
+ # Raw value should be integer timestamp (setter transforms Time to integer)
297
+ raw_value = student.data['graduated_at']
298
+ assert raw_value.is_a?(Integer), "Expected Integer but got #{raw_value.class}"
299
+ assert_equal time.to_i, raw_value
300
+
301
+ # getter transforms integer back to Time
302
+ assert_equal time.to_i, student.graduated_at.to_i
303
+
304
+ # Persistence
305
+ student.save!
306
+ student.reload
307
+ assert_equal time.to_i, student.graduated_at.to_i
308
+ end
309
+
310
+ def test_time_type_with_direct_data_assignment
311
+ Time.zone = 'UTC'
312
+ student = Student.create(name: 'test_time_direct')
313
+
314
+ student.enrolled_at = Time.parse('2024-01-01T00:00:00.000000Z')
315
+ assert '2024-01-01T00:00:00.000000Z', student.data['enrolled_at']
316
+ student.graduated_at = Time.parse('2028-01-01T00:00:00.000000Z')
317
+ assert Time.parse('2028-01-01T00:00:00.000000Z').to_i, student.data['graduated_at']
318
+
319
+ # getter transforms string to Time
320
+ assert student.enrolled_at.is_a?(ActiveSupport::TimeWithZone)
321
+ assert_equal 2024, student.enrolled_at.year
322
+
323
+ teacher = User.create(name: 'Dr john')
324
+ student.teacher = teacher
325
+ student.save
326
+ student = User.find(student.id)
327
+ assert teacher.id, student.data['teacher_id']
328
+ assert User, student.teacher.class
329
+ assert teacher.id, student.teacher.id
330
+ end
331
+
332
+ def test_read_attribute_applies_getter
333
+ student = Student.create(name: 'test_read_getter')
334
+
335
+ # Set raw value directly in data
336
+ student.data['score_percentage'] = 85
337
+
338
+ # read_attribute should apply getter (adds %)
339
+ assert_equal '85%', student.read_attribute(:score_percentage)
340
+
341
+ # Same with [] accessor
342
+ assert_equal '85%', student[:score_percentage]
343
+ end
344
+
345
+ def test_read_attribute_with_time_type
346
+ Time.zone = 'UTC'
347
+ student = Student.create(name: 'test_read_time')
348
+
349
+ # Set ISO8601 string directly
350
+ student.data['enrolled_at'] = '2024-06-15T10:30:00.000000Z'
351
+
352
+ # Note: type: Time creates getter in _define_serialized_property,
353
+ # but it's not stored in settings, so read_attribute returns raw value
354
+ # The property accessor (enrolled_at) applies the getter
355
+ result = student.read_attribute(:enrolled_at)
356
+ assert result.is_a?(String), "read_attribute returns raw value for type: Time"
357
+
358
+ # Property accessor applies getter
359
+ result2 = student.enrolled_at
360
+ assert result2.is_a?(ActiveSupport::TimeWithZone)
361
+ assert_equal 2024, result2.year
362
+ end
363
+
364
+ def test_nil_value_with_getter_setter
365
+ student = Student.create(name: 'test_nil')
366
+
367
+ # Set then clear
368
+ student.encrypted_value = 'some value'
369
+ assert_equal 'some value', student.encrypted_value
370
+
371
+ student.encrypted_value = nil
372
+ assert_nil student.encrypted_value
373
+ assert_nil student.data['encrypted_value']
38
374
  end
39
375
  end
40
376
  end
@@ -0,0 +1,142 @@
1
+ require 'test_helper'
2
+ require 'minitest/autorun'
3
+
4
+ module Coaster
5
+ class TestSerializedProperty < Minitest::Test
6
+ def setup
7
+ end
8
+
9
+ def teardown
10
+ end
11
+
12
+ def test_dirty
13
+ user = User.create(name: 'abc')
14
+ mother = User.create(name: 'mother')
15
+ user.mother = mother
16
+ assert_equal mother, user.mother
17
+ assert_equal mother.id, user.mother_id
18
+ assert_equal({"mother_id"=>mother.id}, user.data)
19
+ assert_equal({"data" => [{}, {"mother_id"=>mother.id}]}, user.changes)
20
+ assert_equal({"mother_id" => [nil, mother.id]}, user.sprop_changes)
21
+ assert_equal(true, user.mother_id_changed?)
22
+ assert_equal(nil, user.mother_id_was)
23
+ user.save!
24
+ user = User.find(user.id)
25
+ user.mother_id = mother.id
26
+ assert_equal(false, user.mother_id_changed?)
27
+ assert_equal({}, user.changes)
28
+ step_mother = User.create(name: 'step_mother')
29
+ user.mother = step_mother
30
+ assert_equal({"data" => [{"mother_id" => mother.id}, {"mother_id" => step_mother.id}]}, user.changes)
31
+ assert_equal({"mother_id" => [mother.id, step_mother.id]}, user.sprop_changes)
32
+ end
33
+
34
+ # Tests for dirty tracking optimization (skip dirty marking when value unchanged)
35
+ def test_same_value_setter_does_not_mark_dirty
36
+ user = User.create(name: 'test_same_value')
37
+ user.simple = 'initial_value'
38
+ user.save!
39
+
40
+ # Reload to clear dirty state
41
+ user.reload
42
+ assert_equal false, user.changed?
43
+
44
+ # Set same value - should NOT mark dirty
45
+ user.simple = 'initial_value'
46
+ assert_equal false, user.simple_changed?, 'Setting same value should not mark property as changed'
47
+ assert_equal false, user.changed?, 'Setting same value should not mark record as changed'
48
+ end
49
+
50
+ def test_different_value_setter_marks_dirty
51
+ user = User.create(name: 'test_different_value')
52
+ user.simple = 'initial_value'
53
+ user.save!
54
+ user.reload
55
+
56
+ # Set different value - should mark dirty
57
+ user.simple = 'new_value'
58
+ assert_equal true, user.simple_changed?, 'Setting different value should mark property as changed'
59
+ assert_equal true, user.changed?, 'Setting different value should mark record as changed'
60
+ assert_equal ['initial_value', 'new_value'], user.simple_change
61
+ end
62
+
63
+ def test_nil_to_value_marks_dirty
64
+ user = User.create(name: 'test_nil_to_value')
65
+ user.save!
66
+ user.reload
67
+
68
+ assert_nil user.simple
69
+ user.simple = 'some_value'
70
+ assert_equal true, user.simple_changed?
71
+ assert_equal [nil, 'some_value'], user.simple_change
72
+ end
73
+
74
+ def test_value_to_nil_marks_dirty
75
+ user = User.create(name: 'test_value_to_nil')
76
+ user.simple = 'some_value'
77
+ user.save!
78
+ user.reload
79
+
80
+ user.simple = nil
81
+ assert_equal true, user.simple_changed?
82
+ assert_equal ['some_value', nil], user.simple_change
83
+ end
84
+
85
+ def test_nil_to_nil_does_not_mark_dirty
86
+ user = User.create(name: 'test_nil_to_nil')
87
+ user.save!
88
+ user.reload
89
+
90
+ assert_nil user.simple
91
+ user.simple = nil
92
+ assert_equal false, user.simple_changed?, 'Setting nil to nil should not mark as changed'
93
+ assert_equal false, user.changed?
94
+ end
95
+
96
+ def test_same_value_via_write_attribute_does_not_mark_dirty
97
+ user = User.create(name: 'test_write_attr_same')
98
+ user.write_attribute(:simple, 'initial')
99
+ user.save!
100
+ user.reload
101
+
102
+ user.write_attribute(:simple, 'initial')
103
+ assert_equal false, user.simple_changed?
104
+ assert_equal false, user.changed?
105
+ end
106
+
107
+ def test_same_value_via_bracket_accessor_does_not_mark_dirty
108
+ user = User.create(name: 'test_bracket_same')
109
+ user[:simple] = 'initial'
110
+ user.save!
111
+ user.reload
112
+
113
+ user[:simple] = 'initial'
114
+ assert_equal false, user.simple_changed?
115
+ assert_equal false, user.changed?
116
+ end
117
+
118
+ def test_same_value_with_setter_proc_does_not_mark_dirty
119
+ student = Student.create(name: 'test_setter_same')
120
+ student.uppercase_name = 'hello' # Stored as 'HELLO'
121
+ student.save!
122
+ student.reload
123
+
124
+ # Setting same raw input - setter transforms to same value
125
+ student.uppercase_name = 'hello'
126
+ assert_equal false, student.uppercase_name_changed?, 'Same value after setter transform should not mark dirty'
127
+ assert_equal false, student.changed?
128
+ end
129
+
130
+ def test_different_case_with_setter_proc_marks_dirty
131
+ student = Student.create(name: 'test_setter_diff')
132
+ student.uppercase_name = 'hello' # Stored as 'HELLO'
133
+ student.save!
134
+ student.reload
135
+
136
+ # Setting different value
137
+ student.uppercase_name = 'world' # Stored as 'WORLD'
138
+ assert_equal true, student.uppercase_name_changed?
139
+ assert_equal ['HELLO', 'WORLD'], student.uppercase_name_change
140
+ end
141
+ end
142
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: coaster
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.4.37
4
+ version: 1.4.39
5
5
  platform: ruby
6
6
  authors:
7
7
  - buzz jung
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-11-20 00:00:00.000000000 Z
10
+ date: 2025-12-17 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: oj
@@ -234,6 +234,7 @@ files:
234
234
  - test/test_month.rb
235
235
  - test/test_object_translation.rb
236
236
  - test/test_serialized_property.rb
237
+ - test/test_serialized_property_dirty.rb
237
238
  - test/test_standard_error.rb
238
239
  - test/test_string.rb
239
240
  - test/test_util.rb
@@ -277,6 +278,7 @@ test_files:
277
278
  - test/test_month.rb
278
279
  - test/test_object_translation.rb
279
280
  - test/test_serialized_property.rb
281
+ - test/test_serialized_property_dirty.rb
280
282
  - test/test_standard_error.rb
281
283
  - test/test_string.rb
282
284
  - test/test_util.rb