attr_json 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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