store_attribute 0.9.3 → 1.0.0

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: 30037caf4b9958aaeb466c389e8e59221c1f165758b98ea8935aeee305772351
4
- data.tar.gz: 85326e5621d54adc901f281c9762a313b21053e4e3189fc49bb1fac025d76451
3
+ metadata.gz: f7594a9e113dc4e6f0872b45d64904354d8e88020111046c443416d3b9b90192
4
+ data.tar.gz: 5dd045f4f13862a9df72ecb20746a03fcea120308f500faa6e773c6bd47c2996
5
5
  SHA512:
6
- metadata.gz: 2c638242092c8e601e17d9d45c603ca2135fa74cc44fa5b57466d37a1f83c43f870570abd27c44e3d7f6d8f2f335c67c16af5c61b1e89bacc4140e4c3a441749
7
- data.tar.gz: 668a9a68039681b1e5e1812d762e95b08129b969d1b740e3bb80b1ad5aec1ac3bb8acd69ccff611da65b9718320f85f0ade4d2e89c2242ed87cb23e8deabb197
6
+ metadata.gz: 59d6d8f7dffbfe818e728012ccb3a1394f6a3c83a6b0a9b6b753576c28941a1a32d3fb00abc91cc8a32d8e8329d33e50358e0fe3aaa4157b3d573c7637265fd2
7
+ data.tar.gz: 559c01f0dc9dc5c193c46c55af999aaab586ba1d924546cf9eb226f7e88777147c5c3eea2650cef976870200bd43ce5fe019ddb08459de5abc5ebfd37f48e23a
data/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
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
+
5
13
  ## 0.9.3 (2021-11-17)
6
14
 
7
15
  - Fix keeping empty store hashes in the changes. ([@markedmondson][])
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,154 +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}"
133
+ def _local_typed_stored_attributes?
134
+ instance_variable_defined?(:@local_typed_stored_attributes)
135
+ end
164
136
 
165
- define_method("#{accessor_key}=") do |value|
166
- write_store_attribute(store_name, key, value)
167
- end
137
+ def _local_typed_stored_attributes
138
+ return @local_typed_stored_attributes if _local_typed_stored_attributes?
168
139
 
169
- define_method(accessor_key) do
170
- read_store_attribute(store_name, key)
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)
171
144
  end
145
+ else
146
+ Hash.new { |h, k| h[k] = {}.with_indifferent_access }.with_indifferent_access
172
147
  end
173
- end
174
148
  end
175
149
 
176
- def _define_predicate_method(name, prefix: nil, suffix: nil)
177
- _store_accessors_module.module_eval do
178
- name = "#{prefix}#{name}#{suffix}"
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
179
153
 
180
- define_method("#{name}?") do
181
- send(name) == true
182
- end
183
- end
184
- end
154
+ # For Rails <6.1
155
+ use_decorator = respond_to?(:decorate_attribute_type) && method(:decorate_attribute_type).parameters.count { |type, _| type == :req } == 2
185
156
 
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
- changes = super()
190
- self.class.local_stored_attributes.each do |accessor, attributes|
191
- next unless attribute_changed?(accessor)
157
+ defaultik = Type::TypedStore::Defaultik.new
192
158
 
193
- prev_store, new_store = changes[accessor].map(&:dup)
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) }
194
165
 
195
- prev_store&.each do |key, value|
196
- if new_store[key] == value
197
- prev_store.except!(key)
198
- new_store&.except!(key)
199
- end
200
- end
166
+ define_default_attribute(attr_name, defaultik.proc, type, from_user: true)
201
167
 
202
- if prev_store.present? || new_store.present?
203
- changes[accessor] = prev_store, new_store
204
- else
205
- changes.delete(accessor)
206
- end
207
- end
208
- changes
168
+ type
209
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)
210
174
 
211
- keys.flatten.each do |key|
212
- key = key.to_s
213
- accessor_key = "#{prefix}#{key}#{suffix}"
214
-
215
- define_method("#{accessor_key}_changed?") do
216
- return false unless attribute_changed?(store_attribute)
217
- prev_store, new_store = changes[store_attribute]
218
- prev_store&.dig(key) != new_store&.dig(key)
219
- end
220
-
221
- define_method("#{accessor_key}_change") do
222
- return unless attribute_changed?(store_attribute)
223
- prev_store, new_store = changes[store_attribute]
224
- [prev_store&.dig(key), new_store&.dig(key)]
225
- end
226
-
227
- define_method("#{accessor_key}_was") do
228
- return unless attribute_changed?(store_attribute)
229
- prev_store, _new_store = changes[store_attribute]
230
- prev_store&.dig(key)
231
- end
232
-
233
- define_method("saved_change_to_#{accessor_key}?") do
234
- return false unless saved_change_to_attribute?(store_attribute)
235
- prev_store, new_store = saved_change_to_attribute(store_attribute)
236
- prev_store&.dig(key) != new_store&.dig(key)
237
- end
238
-
239
- define_method("saved_change_to_#{accessor_key}") do
240
- return unless saved_change_to_attribute?(store_attribute)
241
- prev_store, new_store = saved_change_to_attribute(store_attribute)
242
- [prev_store&.dig(key), new_store&.dig(key)]
243
- 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) }
244
178
 
245
- define_method("#{accessor_key}_before_last_save") do
246
- return unless saved_change_to_attribute?(store_attribute)
247
- prev_store, _new_store = saved_change_to_attribute(store_attribute)
248
- prev_store&.dig(key)
249
- end
179
+ type
250
180
  end
251
181
  end
252
182
  end
253
183
 
254
- def _normalize_prefix_suffix(store_name, prefix, suffix)
255
- prefix =
256
- case prefix
257
- when String, Symbol
258
- "#{prefix}_"
259
- when TrueClass
260
- "#{store_name}_"
261
- end
184
+ def _define_predicate_method(name, prefix: nil, suffix: nil)
185
+ _store_accessors_module.module_eval do
186
+ name = "#{prefix}#{name}#{suffix}"
262
187
 
263
- suffix =
264
- case suffix
265
- when String, Symbol
266
- "_#{suffix}"
267
- when TrueClass
268
- "_#{store_name}"
188
+ define_method("#{name}?") do
189
+ send(name) == true
269
190
  end
270
-
271
- [prefix, suffix]
191
+ end
272
192
  end
273
193
  end
274
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.3"
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.3
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-11-17 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
  - - ">="