attr_json 0.1.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.
@@ -0,0 +1,140 @@
1
+ require 'attr_json/attribute_definition'
2
+ require 'attr_json/attribute_definition/registry'
3
+ require 'attr_json/type/container_attribute'
4
+
5
+ module AttrJson
6
+ # The mix-in to provide AttrJson support to ActiveRecord::Base models.
7
+ # We call it `Record` instead of `ActiveRecord` to avoid confusing namespace
8
+ # shadowing errors, sorry!
9
+ #
10
+ # @example
11
+ # class SomeModel < ActiveRecord::Base
12
+ # include AttrJson::Record
13
+ #
14
+ # attr_json :a_number, :integer
15
+ # end
16
+ #
17
+ module Record
18
+ extend ActiveSupport::Concern
19
+
20
+ DEFAULT_CONTAINER_ATTRIBUTE = :json_attributes
21
+
22
+ included do
23
+ unless self < ActiveRecord::Base
24
+ raise TypeError, "AttrJson::Record can only be used with an ActiveRecord::Base model. #{self} does not appear to be one. Are you looking for ::AttrJson::Model?"
25
+ end
26
+
27
+ class_attribute :attr_json_registry, instance_accessor: false
28
+ self.attr_json_registry = AttrJson::AttributeDefinition::Registry.new
29
+
30
+ class_attribute :default_json_container_attribute, instance_acessor: false
31
+ self.default_json_container_attribute ||= DEFAULT_CONTAINER_ATTRIBUTE
32
+ end
33
+
34
+ class_methods do
35
+ # Type can be a symbol that will be looked up in `ActiveModel::Type.lookup`,
36
+ # or an ActiveModel:::Type::Value).
37
+ #
38
+ # @param name [Symbol,String] name of attribute
39
+ #
40
+ # @param type [ActiveModel::Type::Value] An instance of an ActiveModel::Type::Value (or subclass)
41
+ #
42
+ # @option options [Boolean] :array (false) Make this attribute an array of given type.
43
+ #
44
+ # @option options [Object] :default (nil) Default value, if a Proc object it will be #call'd
45
+ # for default.
46
+ #
47
+ # @option options [String,Symbol] :store_key (nil) Serialize to JSON using
48
+ # given store_key, rather than name as would be usual.
49
+ #
50
+ # @option options [Symbol,String] :container_attribute (self.default_json_container_attribute) The real
51
+ # json(b) ActiveRecord attribute/column to serialize as a key in. Defaults to
52
+ # `self.default_json_container_attribute`, which defaults to `:attr_jsons`
53
+ #
54
+ # @option options [Boolean] :validate (true) Create an ActiveRecord::Validations::AssociatedValidator so
55
+ # validation errors on the attributes post up to self.
56
+ #
57
+ # @option options [Boolean] :rails_attribute (false) Create an actual ActiveRecord
58
+ # `attribute` for name param. A Rails attribute isn't needed for our functionality,
59
+ # but registering thusly will let the type be picked up by simple_form and
60
+ # other tools that may look for it via Rails attribute APIs.
61
+ def attr_json(name, type, **options)
62
+ options = {
63
+ rails_attribute: false,
64
+ validate: true,
65
+ container_attribute: self.default_json_container_attribute
66
+ }.merge!(options)
67
+ options.assert_valid_keys(AttributeDefinition::VALID_OPTIONS + [:validate, :rails_attribute])
68
+ container_attribute = options[:container_attribute]
69
+
70
+ # TODO arg check container_attribute make sure it exists. Hard cause
71
+ # schema isn't loaded yet when class def is loaded. Maybe not.
72
+
73
+ # Want to lazily add an attribute cover to the json container attribute,
74
+ # only if it hasn't already been done. WARNING we are using internal
75
+ # Rails API here, but only way to do this lazily, which I thought was
76
+ # worth it. On the other hand, I think .attribute is idempotent, maybe we don't need it...
77
+ unless attributes_to_define_after_schema_loads[container_attribute.to_s] &&
78
+ attributes_to_define_after_schema_loads[container_attribute.to_s].first.is_a?(AttrJson::Type::ContainerAttribute)
79
+ attribute container_attribute.to_sym, AttrJson::Type::ContainerAttribute.new(self, container_attribute)
80
+ end
81
+
82
+ self.attr_json_registry = attr_json_registry.with(
83
+ AttributeDefinition.new(name.to_sym, type, options.except(:rails_attribute, :validate))
84
+ )
85
+
86
+ # By default, automatically validate nested models
87
+ if type.kind_of?(AttrJson::Type::Model) && options[:validate]
88
+ self.validates_with ActiveRecord::Validations::AssociatedValidator, attributes: [name.to_sym]
89
+ end
90
+
91
+ # We don't actually use this for anything, we provide our own covers. But registering
92
+ # it with usual system will let simple_form and maybe others find it.
93
+ if options[:rails_attribute]
94
+ self.attribute name.to_sym, self.attr_json_registry.fetch(name).type
95
+ end
96
+
97
+ _attr_jsons_module.module_eval do
98
+ define_method("#{name}=") do |value|
99
+ attribute_def = self.class.attr_json_registry.fetch(name.to_sym)
100
+ # write_store_attribute copied from Rails store_accessor implementation.
101
+ # https://github.com/rails/rails/blob/74c3e43fba458b9b863d27f0c45fd2d8dc603cbc/activerecord/lib/active_record/store.rb#L90-L96
102
+
103
+ # special handling for nil, sorry, because if name key was previously
104
+ # not present, write_store_attribute by default will decide there was
105
+ # no change and refuse to make the change. TODO messy.
106
+ if value.nil? && !public_send(attribute_def.container_attribute).has_key?(attribute_def.store_key)
107
+ public_send :"#{attribute_def.container_attribute}_will_change!"
108
+ public_send(attribute_def.container_attribute)[attribute_def.store_key] = nil
109
+ else
110
+ # use of `write_store_attribute` is copied from Rails store_accessor implementation.
111
+ # https://github.com/rails/rails/blob/74c3e43fba458b9b863d27f0c45fd2d8dc603cbc/activerecord/lib/active_record/store.rb#L90-L96
112
+ write_store_attribute(attribute_def.container_attribute, attribute_def.store_key, attribute_def.cast(value))
113
+ end
114
+ end
115
+
116
+ define_method("#{name}") do
117
+ attribute_def = self.class.attr_json_registry.fetch(name.to_sym)
118
+
119
+ # use of `read_store_attribute` is copied from Rails store_accessor implementation.
120
+ # https://github.com/rails/rails/blob/74c3e43fba458b9b863d27f0c45fd2d8dc603cbc/activerecord/lib/active_record/store.rb#L90-L96
121
+ read_store_attribute(attribute_def.container_attribute, attribute_def.store_key)
122
+ end
123
+ end
124
+ end
125
+
126
+ private
127
+
128
+ # Define an anonymous module and include it, so can still be easily
129
+ # overridden by concrete class. Design cribbed from ActiveRecord::Store
130
+ # https://github.com/rails/rails/blob/4590d7729e241cb7f66e018a2a9759cb3baa36e5/activerecord/lib/active_record/store.rb
131
+ def _attr_jsons_module # :nodoc:
132
+ @_attr_jsons_module ||= begin
133
+ mod = Module.new
134
+ include mod
135
+ mod
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
@@ -0,0 +1,281 @@
1
+ module AttrJson
2
+ module Record
3
+ # This only works in Rails 5.1+, and only uses the 'new style' dirty
4
+ # tracking methods, available in Rails 5.1+.
5
+ #
6
+ # Add into an ActiveRecord object with AttrJson::Record,
7
+ # to track dirty changes to attr_jsons, off the attr_json_changes
8
+ # object.
9
+ #
10
+ # some_model.attr_json_changes.saved_changes
11
+ # some_model.attr_json_changes.json_attr_before_last_save
12
+ #
13
+ # All methods ordinarily in ActiveRecord::Attributes::Dirty should be available,
14
+ # including synthetic attribute-specific ones like `will_save_change_to_attribute_name?`.
15
+ # By default, they _only_ report changes from json attributes.
16
+ # To have a merged list also including ordinary AR changes, add on `merged`:
17
+ #
18
+ # some_model.attr_json_changes.merged.saved_changes
19
+ # some_model.attr_json_changes.merged.ordinary_attr_before_last_save
20
+ #
21
+ # Complex nested models will show up in changes as the cast models. If you want
22
+ # the raw json instead, use `as_json`:
23
+ #
24
+ # some_model.attr_json_changes.as_json.saved_changes
25
+ #
26
+ # You can combine as_json and merged if you like:
27
+ #
28
+ # some_model.attr_json_changes.as_json.merged.saved_changes
29
+ #
30
+ # See more in [separate documentation guide](../../../doc_src/dirty_tracking.md)
31
+ #
32
+ # See what methods are available off of the object returned by {attr_json_changes}
33
+ # in {Dirty::Implementation} -- should be the AR dirty-tracking methods you expect.
34
+ module Dirty
35
+ def attr_json_changes
36
+ Implementation.new(self)
37
+ end
38
+
39
+
40
+ class Implementation
41
+ # The attribute_method stuff is copied from ActiveRecord::Dirty,
42
+ # to give you all the same synthetic per-attribute methods.
43
+ # We make it work with overridden #matched_attribute_method below.
44
+ include ActiveModel::AttributeMethods
45
+
46
+ # Attribute methods for "changed in last call to save?"
47
+ attribute_method_affix(prefix: "saved_change_to_", suffix: "?")
48
+ attribute_method_prefix("saved_change_to_")
49
+ attribute_method_suffix("_before_last_save")
50
+
51
+ # Attribute methods for "will change if I call save?"
52
+ attribute_method_affix(prefix: "will_save_change_to_", suffix: "?")
53
+ attribute_method_suffix("_change_to_be_saved", "_in_database")
54
+
55
+ attr_reader :model
56
+
57
+ def initialize(model, merged: false, merge_containers: false, as_json: false)
58
+ @model = model
59
+ @merged = !!merged
60
+ @merge_containers = !!merge_containers
61
+ @as_json = !!as_json
62
+ end
63
+
64
+ # return a copy with `merged` attribute true, so dirty tracking
65
+ # will include ordinary AR attributes too, and you can do things like:
66
+ #
67
+ # model.attr_json_changes.merged.saved_change_to_attribute?(ordinary_or_attr_json)
68
+ #
69
+ # By default, the json container attributes are included too. If you
70
+ # instead want our dirty tracking to pretend they don't exist:
71
+ #
72
+ # model.attr_json_changes.merged(containers: false).etc
73
+ #
74
+ def merged(containers: true)
75
+ self.class.new(model, merged: true, merge_containers: containers,
76
+ as_json: as_json?)
77
+ end
78
+
79
+ # return a copy with as_json parameter set to true, so change diffs
80
+ # will be the json structures serialized, not the cast models.
81
+ # for 'primitive' types will be the same, but for AttrJson::Models
82
+ # very different.
83
+ def as_json
84
+ self.class.new(model, as_json: true,
85
+ merged: merged?,
86
+ merge_containers: merge_containers?)
87
+ end
88
+
89
+ # should we handle ordinary AR attributes too in one merged
90
+ # change tracker?
91
+ def merged?
92
+ @merged
93
+ end
94
+
95
+ # if we're `merged?` and `merge_containers?` is **false**, we
96
+ # _omit_ our json container attributes from our dirty tracking.
97
+ # only has meaning if `merged?` is true. Defaults to true.
98
+ def merge_containers?
99
+ @merge_containers
100
+ end
101
+
102
+ def as_json?
103
+ @as_json
104
+ end
105
+
106
+
107
+ def saved_change_to_attribute(attr_name)
108
+ attribute_def = registry[attr_name.to_sym]
109
+ if ! attribute_def
110
+ if merged? && (merge_containers? || ! registry.container_attributes.include?(attr_name.to_s))
111
+ return model.saved_change_to_attribute(attr_name)
112
+ else
113
+ return nil
114
+ end
115
+ end
116
+
117
+ json_container = attribute_def.container_attribute
118
+
119
+ (before_container, after_container) = model.saved_change_to_attribute(json_container)
120
+
121
+ formatted_before_after(
122
+ before_container.try(:[], attribute_def.store_key),
123
+ after_container.try(:[], attribute_def.store_key),
124
+ attribute_def)
125
+ end
126
+
127
+ def attribute_before_last_save(attr_name)
128
+ saved_change = saved_change_to_attribute(attr_name)
129
+ return nil if saved_change.nil?
130
+
131
+ saved_change[0]
132
+ end
133
+
134
+ def saved_change_to_attribute?(attr_name)
135
+ return nil unless registry[attr_name.to_sym] || merged? && (merge_containers? || ! registry.container_attributes.include?(attr_name.to_s))
136
+ ! saved_change_to_attribute(attr_name).nil?
137
+ end
138
+
139
+ def saved_changes
140
+ saved_changes = model.saved_changes
141
+ return {} if saved_changes == {}
142
+
143
+ json_attr_changes = registry.definitions.collect do |definition|
144
+ if container_change = saved_changes[definition.container_attribute]
145
+ old_v = container_change[0][definition.store_key]
146
+ new_v = container_change[1][definition.store_key]
147
+ if old_v != new_v
148
+ [ definition.name.to_s, formatted_before_after(old_v, new_v, definition) ]
149
+ end
150
+ end
151
+ end.compact.to_h
152
+
153
+ prepared_changes(json_attr_changes, saved_changes)
154
+ end
155
+
156
+ def saved_changes?
157
+ saved_changes.present?
158
+ end
159
+
160
+
161
+ def attribute_in_database(attr_name)
162
+ to_be_saved = attribute_change_to_be_saved(attr_name)
163
+ if to_be_saved.nil?
164
+ if merged? && (merge_containers? || ! registry.container_attributes.include?(attr_name.to_s))
165
+ return model.attribute_change_to_be_saved(attr_name)
166
+ else
167
+ return nil
168
+ end
169
+ end
170
+
171
+ to_be_saved[0]
172
+ end
173
+
174
+ def attribute_change_to_be_saved(attr_name)
175
+ attribute_def = registry[attr_name.to_sym]
176
+ if ! attribute_def
177
+ if merged? && (merge_containers? || ! registry.container_attributes.include?(attr_name.to_s))
178
+ return model.attribute_change_to_be_saved(attr_name)
179
+ else
180
+ return nil
181
+ end
182
+ end
183
+
184
+ json_container = attribute_def.container_attribute
185
+
186
+ (before_container, after_container) = model.attribute_change_to_be_saved(json_container)
187
+
188
+ formatted_before_after(
189
+ before_container.try(:[], attribute_def.store_key),
190
+ after_container.try(:[], attribute_def.store_key),
191
+ attribute_def
192
+ )
193
+ end
194
+
195
+ def will_save_change_to_attribute?(attr_name)
196
+ return nil unless registry[attr_name.to_sym] || merged? && (merge_containers? || ! registry.container_attributes.include?(attr_name.to_s))
197
+ ! attribute_change_to_be_saved(attr_name).nil?
198
+ end
199
+
200
+ def changes_to_save
201
+ changes_to_save = model.changes_to_save
202
+
203
+ return {} if changes_to_save == {}
204
+
205
+ json_attr_changes = registry.definitions.collect do |definition|
206
+ if container_change = changes_to_save[definition.container_attribute]
207
+ old_v = container_change[0][definition.store_key]
208
+ new_v = container_change[1][definition.store_key]
209
+ if old_v != new_v
210
+ [ definition.name.to_s, formatted_before_after(old_v, new_v, definition) ]
211
+ end
212
+ end
213
+ end.compact.to_h
214
+
215
+ prepared_changes(json_attr_changes, changes_to_save)
216
+ end
217
+
218
+ def has_changes_to_save?
219
+ changes_to_save.present?
220
+ end
221
+
222
+ def changed_attribute_names_to_save
223
+ changes_to_save.keys
224
+ end
225
+
226
+ def attributes_in_database
227
+ changes_to_save.transform_values(&:first)
228
+ end
229
+
230
+ private
231
+
232
+ # returns an array of before and after, possibly formatted with as_json.
233
+ # if both before and after are nil, returns nil.
234
+ def formatted_before_after(before_v, after_v, attribute_def)
235
+ return nil if before_v.nil? && after_v.nil?
236
+
237
+ if as_json?
238
+ before_v = attribute_def.type.serialize(before_v) unless before_v.nil?
239
+ after_v = attribute_def.type.serialize(after_v) unless after_v.nil?
240
+ end
241
+
242
+ [
243
+ before_v,
244
+ after_v
245
+ ]
246
+
247
+ end
248
+
249
+ # Takes a hash of _our_ attr_json changes, and possibly
250
+ # merges them into the hash of all changes from the parent record,
251
+ # depending on values of `merged?` and `merge_containers?`.
252
+ def prepared_changes(json_attr_changes, all_changes)
253
+ if merged?
254
+ all_changes.merge(json_attr_changes).tap do |merged|
255
+ unless merge_containers?
256
+ merged.except!(*registry.container_attributes)
257
+ end
258
+ end
259
+ else
260
+ json_attr_changes
261
+ end
262
+ end
263
+
264
+ def registry
265
+ model.class.attr_json_registry
266
+ end
267
+
268
+ # Override from ActiveModel::AttributeMethods
269
+ # to not require class-static define_attribute, but instead dynamically
270
+ # find it from currently declared attributes.
271
+ # https://github.com/rails/rails/blob/6aa5cf03ea8232180ffbbae4c130b051f813c670/activemodel/lib/active_model/attribute_methods.rb#L463-L468
272
+ def matched_attribute_method(method_name)
273
+ matches = self.class.send(:attribute_method_matchers_matching, method_name)
274
+ matches.detect do |match|
275
+ registry.has_attribute?(match.attr_name)
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
@@ -0,0 +1,84 @@
1
+ module AttrJson
2
+ module Record
3
+ # Implementation class called by #jsonb_contains scope method. Ordinarily
4
+ # you don't need to use it yourself, but you can.
5
+ class QueryBuilder
6
+ attr_reader :relation, :input_attributes
7
+ def initialize(relation, input_attributes)
8
+ @relation = relation
9
+ @input_attributes = input_attributes
10
+ end
11
+
12
+ def contains_relation
13
+ result_relation = relation
14
+
15
+ group_attributes_by_container.each do |container_attribute, attributes|
16
+ param_hash = {}
17
+
18
+ attributes.each do |key, value|
19
+ add_to_param_hash!(param_hash, key, value)
20
+ end
21
+ result_relation = result_relation.where("#{relation.table_name}.#{container_attribute} @> (?)::jsonb", param_hash.to_json)
22
+ end
23
+
24
+ result_relation
25
+ end
26
+
27
+ protected
28
+
29
+ def merge_param_hash!(original, new)
30
+ original.deep_merge!(new) do |key, old_val, new_val|
31
+ if old_val.is_a?(Array) && old_val.first.is_a?(Hash) && new_val.is_a?(Array) && new_val.first.is_a?(Hash)
32
+ [merge_param_hash!(old_val.first, new_val.first)]
33
+ elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
34
+ merge_param_hash!(old_val, new_val)
35
+ else
36
+ new_val
37
+ end
38
+ end
39
+ end
40
+
41
+
42
+ def add_to_param_hash!(param_hash, key_path_str, value)
43
+ key_path = key_path_str.to_s.split(".")
44
+ first_key, rest_keys = key_path.first, key_path[1..-1]
45
+ attr_def = relation.attr_json_registry.fetch(first_key)
46
+
47
+ value = if rest_keys.present?
48
+ attr_def.type.value_for_contains_query(rest_keys, value)
49
+ else
50
+ attr_def.serialize(attr_def.cast value)
51
+ end
52
+
53
+ if value.kind_of?(Hash)
54
+ param_hash[attr_def.store_key] ||= {}
55
+ merge_param_hash!(param_hash[attr_def.store_key], value)
56
+ else
57
+ param_hash[attr_def.store_key] = value
58
+ end
59
+
60
+ # it's a mutator not functional don't you forget it.
61
+ return nil
62
+ end
63
+
64
+ # returns a hash with keys container attributes, values hashes of attributes
65
+ # belonging to that container attribute.
66
+ def group_attributes_by_container
67
+ @group_attributes_by_container ||= begin
68
+ hash_by_container_attribute = {}
69
+
70
+ input_attributes.each do |key_path, value|
71
+ key = key_path.to_s.split(".").first
72
+ attr_def = relation.attr_json_registry.fetch(key)
73
+ container_attribute = attr_def.container_attribute
74
+
75
+ hash_by_container_attribute[container_attribute] ||= {}
76
+ hash_by_container_attribute[container_attribute][key_path] = value
77
+ end
78
+
79
+ hash_by_container_attribute
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end