store_attribute 0.9.1 → 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5de07480a22b55413fe34512b0d36a6572da92390c998dcc1ad7838866667e5c
4
- data.tar.gz: 78d3d38f95d1f614f6d9233f0a0bfa52b67a65853f741fedbb8ff510e38e6988
3
+ metadata.gz: f7594a9e113dc4e6f0872b45d64904354d8e88020111046c443416d3b9b90192
4
+ data.tar.gz: 5dd045f4f13862a9df72ecb20746a03fcea120308f500faa6e773c6bd47c2996
5
5
  SHA512:
6
- metadata.gz: 3831baa1c5de7d7dee01dfe5ee0be5a1478b95a2e110c37e1c9ebd405c1246e300cc4eadbbf0b3e83742c8ea258db7399ddd0dc3df10c8797e7b911428bbd3e9
7
- data.tar.gz: 774eb359b1ba5e02022f7e931089990f0d169116cbd8f9c725bc7f8bfa8189dce9c649ca3b7b3face0eccbca13b73dbb2a1028ab6f282dfe797b65adffab627c
6
+ metadata.gz: 59d6d8f7dffbfe818e728012ccb3a1394f6a3c83a6b0a9b6b753576c28941a1a32d3fb00abc91cc8a32d8e8329d33e50358e0fe3aaa4157b3d573c7637265fd2
7
+ data.tar.gz: 559c01f0dc9dc5c193c46c55af999aaab586ba1d924546cf9eb226f7e88777147c5c3eea2650cef976870200bd43ce5fe019ddb08459de5abc5ebfd37f48e23a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## master
4
4
 
5
+ ## 1.0.0 (2022-03-17)
6
+
7
+ - **Ruby 2.6+ and Rails 6+** is required.
8
+
9
+ - Refactored internal implementation to use Rails Store implementation as much as possible. ([@palkan][])
10
+
11
+ Use existing Attributes API and Store API instead of duplicating and monkey-patching. Dirty-tracking, accessors and prefixes/suffixes are now handled by Rails. We only provide type coercions for stores.
12
+
13
+ ## 0.9.3 (2021-11-17)
14
+
15
+ - Fix keeping empty store hashes in the changes. ([@markedmondson][])
16
+
17
+ See [PR#22](https://github.com/palkan/store_attribute/pull/22).
18
+
19
+ ## 0.9.2 (2021-10-13)
20
+
21
+ - Fix bug with store mutation during changes calculation. ([@palkan][])
22
+
5
23
  ## 0.9.1
6
24
 
7
25
  - Fix bug with dirty nullable stores. ([@palkan][])
data/README.md CHANGED
@@ -6,15 +6,16 @@
6
6
 
7
7
  ActiveRecord extension which adds typecasting to store accessors.
8
8
 
9
- Compatible with Rails 4.2 and Rails 5+.
10
-
11
- Extracted from not merged PR to Rails: [rails/rails#18942](https://github.com/rails/rails/pull/18942).
9
+ Originally extracted from not merged PR to Rails: [rails/rails#18942](https://github.com/rails/rails/pull/18942).
12
10
 
13
11
  ### Install
14
12
 
15
13
  In your Gemfile:
16
14
 
17
15
  ```ruby
16
+ # for Rails 6+ (7 is supported)
17
+ gem "store_attribute", "~> 1.0"
18
+
18
19
  # for Rails 5+ (6 is supported)
19
20
  gem "store_attribute", "~> 0.8.0"
20
21
 
@@ -31,6 +32,7 @@ store_attribute(store_name, name, type, options)
31
32
  ```
32
33
 
33
34
  Where:
35
+
34
36
  - `store_name` The name of the store.
35
37
  - `name` The name of the accessor to the store.
36
38
  - `type` A symbol such as `:string` or `:integer`, or a type object to be used for the accessor.
@@ -98,3 +100,48 @@ class User < ActiveRecord::Base
98
100
  store :settings, accessors: [:color, :homepage, login_at: :datetime], coder: JSON
99
101
  end
100
102
  ```
103
+
104
+ ### Using defaults
105
+
106
+ With `store_attribute`, you can provide default values for the store attribute. This functionality follows Rails behaviour for `attribute ..., default: ...` (and is backed by Attribute API).
107
+
108
+ You must remember two things when using defaults:
109
+
110
+ - A default value is only populated if no value for the **store** attribute was set, i.e., only when creating a new record.
111
+ - Default values persist as soon as you save the record.
112
+
113
+ The examples below demonstrate these caveats:
114
+
115
+ ```ruby
116
+ # Database schema
117
+ create_table("users") do |t|
118
+ t.string :name
119
+ t.jsonb :extra
120
+ end
121
+
122
+ class RawUser < ActiveRecord::Base
123
+ self.table_name = "users"
124
+ end
125
+
126
+ class User < ActiveRecord::Base
127
+ attribute :name, :string, default: "Joe"
128
+ store_attribute :extra, :expired_at, :date, default: -> { 2.days.from_now }
129
+ end
130
+
131
+ Date.current #=> 2022-03-17
132
+
133
+ user = User.new
134
+ user.name #=> "john"
135
+ user.expired_at #=> 2022-03-19
136
+ user.save!
137
+
138
+ raw_user = RawUser.find(user.id)
139
+ user.name #=> "john"
140
+ user.expired_at #=> 2022-03-19
141
+
142
+ another_raw_user = RawUser.create!
143
+ another_user = User.find(another_raw_user.id)
144
+
145
+ user.name #=> nil
146
+ user.expired_at #=> nil
147
+ ```
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module StoreAttribute
4
+ # Upgrade mutation tracker to return partial changes for typed stores
5
+ module MutationTracker
6
+ def change_to_attribute(attr_name)
7
+ return super unless attributes[attr_name].type.is_a?(ActiveRecord::Type::TypedStore)
8
+
9
+ orig_changes = super
10
+
11
+ return unless orig_changes
12
+
13
+ prev_store, new_store = orig_changes.map(&:dup)
14
+
15
+ prev_store&.each do |key, value|
16
+ if new_store[key] == value
17
+ prev_store.except!(key)
18
+ new_store&.except!(key)
19
+ end
20
+ end
21
+
22
+ [prev_store, new_store]
23
+ end
24
+ end
25
+ end
26
+
27
+ require "active_model/attribute_mutation_tracker"
28
+ ActiveModel::AttributeMutationTracker.prepend(StoreAttribute::MutationTracker)
@@ -2,11 +2,14 @@
2
2
 
3
3
  require "active_record/store"
4
4
  require "store_attribute/active_record/type/typed_store"
5
+ require "store_attribute/active_record/mutation_tracker"
5
6
 
6
7
  module ActiveRecord
7
8
  module Store
8
9
  module ClassMethods # :nodoc:
9
- alias_method :_orig_store, :store
10
+ alias_method :_orig_store_without_types, :store
11
+ alias_method :_orig_store_accessor_without_types, :store_accessor
12
+
10
13
  # Defines store on this model.
11
14
  #
12
15
  # +store_name+ The name of the store.
@@ -32,7 +35,7 @@ module ActiveRecord
32
35
  {}
33
36
  end
34
37
 
35
- _orig_store(store_name, options)
38
+ _orig_store_without_types(store_name, options)
36
39
  store_accessor(store_name, *accessors, **typed_accessors) if accessors
37
40
  end
38
41
 
@@ -57,13 +60,7 @@ module ActiveRecord
57
60
  keys = keys.flatten
58
61
  typed_keys = typed_keys.except(keys)
59
62
 
60
- accessor_prefix, accessor_suffix = _normalize_prefix_suffix(store_name, prefix, suffix)
61
-
62
- _define_accessors_methods(store_name, *keys, prefix: accessor_prefix, suffix: accessor_suffix)
63
-
64
- _prepare_local_stored_attributes(store_name, *keys)
65
-
66
- _define_dirty_tracking_methods(store_name, keys + typed_keys.keys, prefix: accessor_prefix, suffix: accessor_suffix)
63
+ _orig_store_accessor_without_types(store_name, *(keys - typed_keys.keys), prefix: nil, suffix: nil)
67
64
 
68
65
  typed_keys.each do |key, type|
69
66
  store_attribute(store_name, key, type, prefix: prefix, suffix: suffix)
@@ -121,149 +118,77 @@ module ActiveRecord
121
118
  #
122
119
  # For more examples on using types, see documentation for ActiveRecord::Attributes.
123
120
  def store_attribute(store_name, name, type, prefix: nil, suffix: nil, **options)
124
- prefix, suffix = _normalize_prefix_suffix(store_name, prefix, suffix)
125
-
126
- _define_accessors_methods(store_name, name, prefix: prefix, suffix: suffix)
127
-
121
+ _orig_store_accessor_without_types(store_name, name.to_s, prefix: prefix, suffix: suffix)
128
122
  _define_predicate_method(name, prefix: prefix, suffix: suffix) if type == :boolean
129
123
 
130
- # Rails >6.0
131
- if !respond_to?(:decorate_attribute_type) || method(:decorate_attribute_type).parameters.count { |type, _| type == :req } == 1
132
- attr_name = store_name.to_s
133
- was_type = attributes_to_define_after_schema_loads[attr_name]&.first
134
- attribute(attr_name) do |subtype|
135
- if defined?(_lookup_cast_type)
136
- Type::TypedStore.create_from_type(_lookup_cast_type(attr_name, was_type, {}), name, type, **options)
137
- else
138
- Type::TypedStore.create_from_type(subtype, name, type, **options)
139
- end
140
- end
141
- else
142
- decorate_attribute_type(store_name, "typed_accessor_for_#{name}") do |subtype|
143
- Type::TypedStore.create_from_type(subtype, name, type, **options)
144
- end
145
- end
146
-
147
- _prepare_local_stored_attributes(store_name, name)
148
-
149
- _define_dirty_tracking_methods(store_name, [name], prefix: prefix, suffix: suffix)
124
+ _define_store_attribute(store_name) if !_local_typed_stored_attributes? || _local_typed_stored_attributes[store_name].empty?
125
+ _store_local_stored_attribute(store_name, name, type, **options)
150
126
  end
151
127
 
152
- def _prepare_local_stored_attributes(store_name, *keys) # :nodoc:
153
- # assign new store attribute and create new hash to ensure that each class in the hierarchy
154
- # has its own hash of stored attributes.
155
- self.local_stored_attributes ||= {}
156
- self.local_stored_attributes[store_name] ||= []
157
- self.local_stored_attributes[store_name] |= keys
128
+ def _store_local_stored_attribute(store_name, key, cast_type, default: Type::TypedStore::UNDEFINED, **options) # :nodoc:
129
+ cast_type = ActiveRecord::Type.lookup(cast_type, **options) if cast_type.is_a?(Symbol)
130
+ _local_typed_stored_attributes[store_name][key] = [cast_type, default]
158
131
  end
159
132
 
160
- def _define_accessors_methods(store_name, *keys, prefix: nil, suffix: nil) # :nodoc:
161
- _store_accessors_module.module_eval do
162
- keys.each do |key|
163
- accessor_key = "#{prefix}#{key}#{suffix}"
164
-
165
- define_method("#{accessor_key}=") do |value|
166
- write_store_attribute(store_name, key, value)
167
- end
168
-
169
- define_method(accessor_key) do
170
- read_store_attribute(store_name, key)
171
- end
172
- end
173
- end
133
+ def _local_typed_stored_attributes?
134
+ instance_variable_defined?(:@local_typed_stored_attributes)
174
135
  end
175
136
 
176
- def _define_predicate_method(name, prefix: nil, suffix: nil)
177
- _store_accessors_module.module_eval do
178
- name = "#{prefix}#{name}#{suffix}"
137
+ def _local_typed_stored_attributes
138
+ return @local_typed_stored_attributes if _local_typed_stored_attributes?
179
139
 
180
- define_method("#{name}?") do
181
- send(name) == true
140
+ @local_typed_stored_attributes =
141
+ if superclass.respond_to?(:_local_typed_stored_attributes)
142
+ superclass._local_typed_stored_attributes.dup.tap do |h|
143
+ h.transform_values!(&:dup)
144
+ end
145
+ else
146
+ Hash.new { |h, k| h[k] = {}.with_indifferent_access }.with_indifferent_access
182
147
  end
183
- end
184
148
  end
185
149
 
186
- def _define_dirty_tracking_methods(store_attribute, keys, prefix: nil, suffix: nil)
187
- _store_accessors_module.module_eval do
188
- define_method("changes") do
189
- return @changes if defined?(@changes)
190
- changes = super()
191
- self.class.local_stored_attributes.each do |accessor, attributes|
192
- next unless attribute_changed?(accessor)
193
-
194
- prev_store, new_store = changes[accessor]
195
-
196
- prev_store&.each do |key, value|
197
- if new_store[key] == value
198
- prev_store.except!(key)
199
- new_store&.except!(key)
200
- end
201
- end
202
- end
203
- @changes = changes
204
- end
150
+ def _define_store_attribute(store_name)
151
+ attr_name = store_name.to_s
152
+ was_type = attributes_to_define_after_schema_loads[attr_name]&.first
205
153
 
206
- keys.flatten.each do |key|
207
- key = key.to_s
208
- accessor_key = "#{prefix}#{key}#{suffix}"
154
+ # For Rails <6.1
155
+ use_decorator = respond_to?(:decorate_attribute_type) && method(:decorate_attribute_type).parameters.count { |type, _| type == :req } == 2
209
156
 
210
- define_method("#{accessor_key}_changed?") do
211
- return false unless attribute_changed?(store_attribute)
212
- prev_store, new_store = changes[store_attribute]
213
- prev_store&.dig(key) != new_store&.dig(key)
214
- end
157
+ defaultik = Type::TypedStore::Defaultik.new
215
158
 
216
- define_method("#{accessor_key}_change") do
217
- return unless attribute_changed?(store_attribute)
218
- prev_store, new_store = changes[store_attribute]
219
- [prev_store&.dig(key), new_store&.dig(key)]
220
- end
159
+ if use_decorator
160
+ decorate_attribute_type(attr_name, "typed_accessor_for_#{attr_name}") do |subtype|
161
+ subtypes = _local_typed_stored_attributes[attr_name]
162
+ type = Type::TypedStore.create_from_type(subtype)
163
+ defaultik.type = type
164
+ subtypes.each { |name, (cast_type, default)| type.add_typed_key(name, cast_type, default: default) }
221
165
 
222
- define_method("#{accessor_key}_was") do
223
- return unless attribute_changed?(store_attribute)
224
- prev_store, _new_store = changes[store_attribute]
225
- prev_store&.dig(key)
226
- end
166
+ define_default_attribute(attr_name, defaultik.proc, type, from_user: true)
227
167
 
228
- define_method("saved_change_to_#{accessor_key}?") do
229
- return false unless saved_change_to_attribute?(store_attribute)
230
- prev_store, new_store = saved_change_to_attribute(store_attribute)
231
- prev_store&.dig(key) != new_store&.dig(key)
232
- end
168
+ type
169
+ end
170
+ else
171
+ attribute(attr_name, default: defaultik.proc) do |subtype|
172
+ subtypes = _local_typed_stored_attributes[attr_name]
173
+ subtype = _lookup_cast_type(attr_name, was_type, {}) if defined?(_lookup_cast_type)
233
174
 
234
- define_method("saved_change_to_#{accessor_key}") do
235
- return unless saved_change_to_attribute?(store_attribute)
236
- prev_store, new_store = saved_change_to_attribute(store_attribute)
237
- [prev_store&.dig(key), new_store&.dig(key)]
238
- end
175
+ type = Type::TypedStore.create_from_type(subtype)
176
+ defaultik.type = type
177
+ subtypes.each { |name, (cast_type, default)| type.add_typed_key(name, cast_type, default: default) }
239
178
 
240
- define_method("#{accessor_key}_before_last_save") do
241
- return unless saved_change_to_attribute?(store_attribute)
242
- prev_store, _new_store = saved_change_to_attribute(store_attribute)
243
- prev_store&.dig(key)
244
- end
179
+ type
245
180
  end
246
181
  end
247
182
  end
248
183
 
249
- def _normalize_prefix_suffix(store_name, prefix, suffix)
250
- prefix =
251
- case prefix
252
- when String, Symbol
253
- "#{prefix}_"
254
- when TrueClass
255
- "#{store_name}_"
256
- end
184
+ def _define_predicate_method(name, prefix: nil, suffix: nil)
185
+ _store_accessors_module.module_eval do
186
+ name = "#{prefix}#{name}#{suffix}"
257
187
 
258
- suffix =
259
- case suffix
260
- when String, Symbol
261
- "_#{suffix}"
262
- when TrueClass
263
- "_#{store_name}"
188
+ define_method("#{name}?") do
189
+ send(name) == true
264
190
  end
265
-
266
- [prefix, suffix]
191
+ end
267
192
  end
268
193
  end
269
194
  end
@@ -5,12 +5,24 @@ require "active_record/type"
5
5
  module ActiveRecord
6
6
  module Type # :nodoc:
7
7
  class TypedStore < DelegateClass(ActiveRecord::Type::Value) # :nodoc:
8
+ class Defaultik
9
+ attr_accessor :type
10
+
11
+ def proc
12
+ @proc ||= Kernel.proc do
13
+ raise ArgumentError, "Has no type attached" unless type
14
+
15
+ type.build_defaults
16
+ end
17
+ end
18
+ end
19
+
8
20
  # Creates +TypedStore+ type instance and specifies type caster
9
21
  # for key.
10
- def self.create_from_type(basetype, key, type, **options)
11
- typed_store = new(basetype)
12
- typed_store.add_typed_key(key, type, **options)
13
- typed_store
22
+ def self.create_from_type(basetype, **options)
23
+ return basetype.dup if basetype.is_a?(self)
24
+
25
+ new(basetype)
14
26
  end
15
27
 
16
28
  def initialize(subtype)
@@ -21,7 +33,6 @@ module ActiveRecord
21
33
  end
22
34
 
23
35
  UNDEFINED = Object.new
24
- private_constant :UNDEFINED
25
36
 
26
37
  def add_typed_key(key, type, default: UNDEFINED, **options)
27
38
  type = ActiveRecord::Type.lookup(type, **options) if type.is_a?(Symbol)
@@ -36,15 +47,13 @@ module ActiveRecord
36
47
  accessor_types.each do |key, type|
37
48
  if hash.key?(key)
38
49
  hash[key] = type.deserialize(hash[key])
39
- elsif defaults.key?(key)
40
- hash[key] = get_default(key)
41
50
  end
42
51
  end
43
52
  hash
44
53
  end
45
54
 
46
55
  def changed_in_place?(raw_old_value, new_value)
47
- raw_old_value != serialize(new_value)
56
+ deserialize(raw_old_value) != new_value
48
57
  end
49
58
 
50
59
  def serialize(value)
@@ -55,8 +64,6 @@ module ActiveRecord
55
64
  next unless key
56
65
  if value.key?(key)
57
66
  typed_casted[key] = type.serialize(value[key])
58
- elsif defaults.key?(str_key)
59
- typed_casted[key] = type.serialize(get_default(str_key))
60
67
  end
61
68
  end
62
69
  super(value.merge(typed_casted))
@@ -68,8 +75,6 @@ module ActiveRecord
68
75
  accessor_types.each do |key, type|
69
76
  if hash.key?(key)
70
77
  hash[key] = type.cast(hash[key])
71
- elsif defaults.key?(key)
72
- hash[key] = get_default(key)
73
78
  end
74
79
  end
75
80
  hash
@@ -86,6 +91,19 @@ module ActiveRecord
86
91
 
87
92
  delegate :read, :prepare, to: :store_accessor
88
93
 
94
+ def build_defaults
95
+ defaults.transform_values do |val|
96
+ val.is_a?(Proc) ? val.call : val
97
+ end.with_indifferent_access
98
+ end
99
+
100
+ def dup
101
+ self.class.new(__getobj__).tap do |dtype|
102
+ dtype.accessor_types.merge!(accessor_types)
103
+ dtype.defaults.merge!(defaults)
104
+ end
105
+ end
106
+
89
107
  protected
90
108
 
91
109
  # We cannot rely on string keys 'cause user input can contain symbol keys
@@ -103,11 +121,6 @@ module ActiveRecord
103
121
  accessor_types.fetch(key.to_s)
104
122
  end
105
123
 
106
- def get_default(key)
107
- value = defaults.fetch(key)
108
- value.is_a?(Proc) ? value.call : value
109
- end
110
-
111
124
  attr_reader :accessor_types, :defaults, :store_accessor
112
125
  end
113
126
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module StoreAttribute # :nodoc:
4
- VERSION = "0.9.1"
4
+ VERSION = "1.0.0"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: store_attribute
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - palkan
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2021-09-27 00:00:00.000000000 Z
11
+ date: 2022-03-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - ">="
18
18
  - !ruby/object:Gem::Version
19
- version: '5.0'
19
+ version: '6.0'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - ">="
25
25
  - !ruby/object:Gem::Version
26
- version: '5.0'
26
+ version: '6.0'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: pg
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -78,6 +78,7 @@ files:
78
78
  - README.md
79
79
  - lib/store_attribute.rb
80
80
  - lib/store_attribute/active_record.rb
81
+ - lib/store_attribute/active_record/mutation_tracker.rb
81
82
  - lib/store_attribute/active_record/store.rb
82
83
  - lib/store_attribute/active_record/type/typed_store.rb
83
84
  - lib/store_attribute/version.rb
@@ -98,7 +99,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
98
99
  requirements:
99
100
  - - ">="
100
101
  - !ruby/object:Gem::Version
101
- version: 2.5.0
102
+ version: 2.6.0
102
103
  required_rubygems_version: !ruby/object:Gem::Requirement
103
104
  requirements:
104
105
  - - ">="