attr_json 0.2.0 → 1.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -12,7 +12,9 @@
12
12
  attr_reader :name, :type, :original_args, :container_attribute
13
13
 
14
14
  # @param name [Symbol,String]
15
- # @param type [Symbol,ActiveModel::Type::Value]
15
+ # @param type [Symbol,ActiveModel::Type::Value] Symbol is looked up in
16
+ # ActiveRecord::Type.lookup, but with `adapter: nil` for no custom
17
+ # adapter-specific lookup.
16
18
  #
17
19
  # @option options store_key [Symbol,String]
18
20
  # @option options container_attribute [Symbol,ActiveModel::Type::Value]
@@ -40,7 +42,12 @@
40
42
  # ActiveModel::Type.lookup may make more sense, but ActiveModel::Type::Date
41
43
  # seems to have a bug with multi-param assignment. Mostly they return
42
44
  # the same types, but ActiveRecord::Type::Date works with multi-param assignment.
43
- type = ActiveRecord::Type.lookup(type)
45
+ #
46
+ # We pass `adapter: nil` to avoid triggering a db connection.
47
+ # See: https://github.com/jrochkind/attr_json/issues/41
48
+ # This is at the "cost" of not using any adapter-specific types... which
49
+ # maybe preferable anyway?
50
+ type = ActiveRecord::Type.lookup(type, adapter: nil)
44
51
  elsif ! type.is_a? ActiveModel::Type::Value
45
52
  raise ArgumentError, "Second argument (#{type}) must be a symbol or instance of an ActiveModel::Type::Value subclass"
46
53
  end
@@ -71,6 +78,12 @@
71
78
  @default != NO_DEFAULT_PROVIDED
72
79
  end
73
80
 
81
+ # Can be value or proc!
82
+ def default_argument
83
+ return nil unless has_default?
84
+ @default
85
+ end
86
+
74
87
  def provide_default!
75
88
  unless has_default?
76
89
  raise ArgumentError.new("This #{self.class.name} does not have a default defined!")
@@ -0,0 +1,55 @@
1
+ module AttrJson
2
+ # Intentionally non-mutable, to avoid problems with subclass inheritance
3
+ # and rails class_attribute. Instead, you set to new Config object
4
+ # changed with {#merge}.
5
+ class Config
6
+ RECORD_ALLOWED_KEYS = %i{
7
+ default_container_attribute
8
+ default_rails_attribute
9
+ default_accepts_nested_attributes
10
+ }
11
+
12
+ MODEL_ALLOWED_KEYS = %i{
13
+ unknown_key
14
+ bad_cast
15
+ }
16
+
17
+ DEFAULTS = {
18
+ default_container_attribute: "json_attributes",
19
+ default_rails_attribute: false,
20
+ unknown_key: :raise
21
+ }
22
+
23
+ (MODEL_ALLOWED_KEYS + RECORD_ALLOWED_KEYS).each do |key|
24
+ define_method(key) do
25
+ attributes[key]
26
+ end
27
+ end
28
+
29
+ attr_reader :mode
30
+
31
+ def initialize(options = {})
32
+ @mode = options.delete(:mode)
33
+ unless mode == :record || mode == :model
34
+ raise ArgumentError, "required :mode argument must be :record or :model"
35
+ end
36
+ valid_keys = mode == :record ? RECORD_ALLOWED_KEYS : MODEL_ALLOWED_KEYS
37
+ options.assert_valid_keys(valid_keys)
38
+
39
+ options.reverse_merge!(DEFAULTS.slice(*valid_keys))
40
+
41
+ @attributes = options
42
+ end
43
+
44
+ # Returns a new Config object, with changes merged in.
45
+ def merge(changes = {})
46
+ self.class.new(attributes.merge(changes).merge(mode: mode))
47
+ end
48
+
49
+ protected
50
+
51
+ def attributes
52
+ @attributes
53
+ end
54
+ end
55
+ end
@@ -7,6 +7,8 @@ require 'attr_json/attribute_definition/registry'
7
7
  require 'attr_json/type/model'
8
8
  require 'attr_json/model/cocoon_compat'
9
9
 
10
+ require 'attr_json/serialization_coder_from_type'
11
+
10
12
  module AttrJson
11
13
 
12
14
  # Meant for use in a plain class, turns it into an ActiveModel::Model
@@ -21,12 +23,48 @@ module AttrJson
21
23
  # @note Includes ActiveModel::Model whether you like it or not. TODO, should it?
22
24
  #
23
25
  # You can control what happens if you set an unknown key (one that you didn't
24
- # register with `attr_json`) with the class attribute `attr_json_unknown_key`.
26
+ # register with `attr_json`) with the config attribute `attr_json_config(unknown_key:)`.
25
27
  # * :raise (default) raise ActiveModel::UnknownAttributeError
26
28
  # * :strip Ignore the unknown key and do not include it, without raising.
27
29
  # * :allow Allow the unknown key and it's value to be in the serialized hash,
28
30
  # and written to the database. May be useful for legacy data or columns
29
31
  # that other software touches, to let unknown keys just flow through.
32
+ #
33
+ # class Something
34
+ # include AttrJson::Model
35
+ # attr_json_config(unknown_key: :allow)
36
+ # #...
37
+ # end
38
+ #
39
+ # Similarly, trying to set a Model-valued attribute with an object that
40
+ # can't be cast to a Hash or Model at all will normally raise a
41
+ # AttrJson::Type::Model::BadCast error, but you can set config `bad_cast: :as_nil`
42
+ # to make it cast to nil, more like typical ActiveRecord cast.
43
+ #
44
+ # class Something
45
+ # include AttrJson::Model
46
+ # attr_json_config(bad_cast: :as_nil)
47
+ # #...
48
+ # end
49
+ #
50
+ # ## ActiveRecord `serialize`
51
+ #
52
+ # If you want to map a single AttrJson::Model to a json/jsonb column, you
53
+ # can use ActiveRecord `serialize` feature.
54
+ #
55
+ # https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html
56
+ #
57
+ # We provide a simple shim to give you the right API for a "coder" for AR serialize:
58
+ #
59
+ # class ValueModel
60
+ # include AttrJson::Model
61
+ # attr_json :some_string, :string
62
+ # end
63
+ #
64
+ # class SomeModel < ApplicationRecord
65
+ # serialize :some_json_column, ValueModel.to_serialize_coder
66
+ # end
67
+ #
30
68
  module Model
31
69
  extend ActiveSupport::Concern
32
70
 
@@ -41,23 +79,51 @@ module AttrJson
41
79
 
42
80
  class_attribute :attr_json_registry, instance_accessor: false
43
81
  self.attr_json_registry = ::AttrJson::AttributeDefinition::Registry.new
44
-
45
- # :raise, :strip, :allow. :raise is default. Is there some way to enforce this.
46
- class_attribute :attr_json_unknown_key
47
- self.attr_json_unknown_key ||= :raise
48
82
  end
49
83
 
50
84
  class_methods do
51
- # Like `.new`, but translate store keys in hash
85
+ def attr_json_config(new_values = {})
86
+ if new_values.present?
87
+ # get one without new values, then merge new values into it, and
88
+ # set it locally for this class.
89
+ @attr_json_config = attr_json_config.merge(new_values)
90
+ else
91
+ if instance_variable_defined?("@attr_json_config")
92
+ # we have a custom one for this class, return it.
93
+ @attr_json_config
94
+ elsif superclass.respond_to?(:attr_json_config)
95
+ # return superclass without setting it locally, so changes in superclass
96
+ # will continue effecting us.
97
+ superclass.attr_json_config
98
+ else
99
+ # no superclass, no nothing, set it to blank one.
100
+ @attr_json_config = Config.new(mode: :model)
101
+ end
102
+ end
103
+ end
104
+
105
+
106
+ # The inverse of model#serializable_hash -- re-hydrates a serialized hash to a model.
107
+ #
108
+ # Similar to `.new`, but translates things that need to be translated in deserialization,
109
+ # like store_keys, and properly calling deserialize on the underlying types.
110
+ #
111
+ # @example Model.new_from_serializable(hash)
52
112
  def new_from_serializable(attributes = {})
53
- attributes = attributes.transform_keys do |key|
113
+ attributes = attributes.collect do |key, value|
54
114
  # store keys in arguments get translated to attribute names on initialize.
55
115
  if attribute_def = self.attr_json_registry.store_key_lookup("", key.to_s)
56
- attribute_def.name.to_s
57
- else
58
- key
116
+ key = attribute_def.name.to_s
59
117
  end
60
- end
118
+
119
+ attr_type = self.attr_json_registry.has_attribute?(key) && self.attr_json_registry.type_for_attribute(key)
120
+ if attr_type
121
+ value = attr_type.deserialize(value)
122
+ end
123
+
124
+ [key, value]
125
+ end.to_h
126
+
61
127
  self.new(attributes)
62
128
  end
63
129
 
@@ -65,6 +131,21 @@ module AttrJson
65
131
  @type ||= AttrJson::Type::Model.new(self)
66
132
  end
67
133
 
134
+ def to_serialization_coder
135
+ @serialization_coder ||= AttrJson::SerializationCoderFromType.new(to_type)
136
+ end
137
+
138
+ # like the ActiveModel::Attributes method
139
+ def attribute_names
140
+ attr_json_registry.attribute_names
141
+ end
142
+
143
+ # like the ActiveModel::Attributes method, hash with name keys, and ActiveModel::Type values
144
+ def attribute_types
145
+ attribute_names.collect { |name| [name.to_s, attr_json_registry.type_for_attribute(name)]}.to_h
146
+ end
147
+
148
+
68
149
  # Type can be an instance of an ActiveModel::Type::Value subclass, or a symbol that will
69
150
  # be looked up in `ActiveModel::Type.lookup`
70
151
  #
@@ -107,27 +188,6 @@ module AttrJson
107
188
  end
108
189
  end
109
190
 
110
- # This should kind of be considered 'protected', but the semantics
111
- # of how we want to call it don't give us a visibility modifier that works.
112
- # Prob means refactoring called for. TODO?
113
- def fill_in_defaults(hash)
114
- # Only if we need to mutate it to add defaults, we'll dup it first. deep_dup not neccesary
115
- # since we're only modifying top-level here.
116
- duped = false
117
- attr_json_registry.definitions.each do |definition|
118
- if definition.has_default? && ! (hash.has_key?(definition.store_key.to_s) || hash.has_key?(definition.store_key.to_sym))
119
- unless duped
120
- hash = hash.dup
121
- duped = true
122
- end
123
-
124
- hash[definition.store_key] = definition.provide_default!
125
- end
126
- end
127
-
128
- hash
129
- end
130
-
131
191
  private
132
192
 
133
193
  # Define an anonymous module and include it, so can still be easily
@@ -143,11 +203,15 @@ module AttrJson
143
203
  end
144
204
 
145
205
  def initialize(attributes = {})
146
- if !attributes.respond_to?(:transform_keys)
147
- raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
148
- end
206
+ super
207
+
208
+ fill_in_defaults!
209
+ end
149
210
 
150
- super(self.class.fill_in_defaults(attributes))
211
+ # inspired by https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/activemodel/lib/active_model/attributes.rb
212
+ def initialize_dup(other) # :nodoc:
213
+ @attributes = @attributes.deep_dup
214
+ super
151
215
  end
152
216
 
153
217
  def attributes
@@ -185,6 +249,11 @@ module AttrJson
185
249
  self.class.attr_json_registry.has_attribute?(str)
186
250
  end
187
251
 
252
+ # like the ActiveModel::Attributes method
253
+ def attribute_names
254
+ self.class.attribute_names
255
+ end
256
+
188
257
  # Override from ActiveModel::Serialization to #serialize
189
258
  # by type to make sure any values set directly on hash still
190
259
  # get properly type-serialized.
@@ -217,12 +286,9 @@ module AttrJson
217
286
  end
218
287
 
219
288
  # Two AttrJson::Model objects are equal if they are the same class
220
- # or one is a subclass of the other, AND their #attributes are equal.
221
- # TODO: Should we allow subclasses to be equal, or should they have to be the
222
- # exact same class?
289
+ # AND their #attributes are equal.
223
290
  def ==(other_object)
224
- (other_object.is_a?(self.class) || self.is_a?(other_object.class)) &&
225
- other_object.attributes == self.attributes
291
+ other_object.class == self.class && other_object.attributes == self.attributes
226
292
  end
227
293
 
228
294
  # ActiveRecord objects [have a](https://github.com/rails/rails/blob/v5.1.5/activerecord/lib/active_record/nested_attributes.rb#L367-L374)
@@ -234,8 +300,25 @@ module AttrJson
234
300
  false
235
301
  end
236
302
 
303
+ # like ActiveModel::Attributes at
304
+ # https://github.com/rails/rails/blob/8015c2c2cf5c8718449677570f372ceb01318a32/activemodel/lib/active_model/attributes.rb#L120
305
+ #
306
+ # is not a full deep freeze
307
+ def freeze
308
+ attributes.freeze unless frozen?
309
+ super
310
+ end
311
+
237
312
  private
238
313
 
314
+ def fill_in_defaults!
315
+ self.class.attr_json_registry.definitions.each do |definition|
316
+ if definition.has_default? && !attributes.has_key?(definition.name.to_s)
317
+ self.send("#{definition.name.to_s}=", definition.provide_default!)
318
+ end
319
+ end
320
+ end
321
+
239
322
  def _attr_json_write(key, value)
240
323
  if attribute_def = self.class.attr_json_registry[key.to_sym]
241
324
  attributes[key.to_s] = attribute_def.cast(value)
@@ -247,7 +330,7 @@ module AttrJson
247
330
 
248
331
 
249
332
  def _attr_json_write_unknown_attribute(key, value)
250
- case attr_json_unknown_key
333
+ case self.class.attr_json_config.unknown_key
251
334
  when :strip
252
335
  # drop it, no-op
253
336
  when :allow
@@ -16,7 +16,11 @@ module AttrJson
16
16
 
17
17
  def assign_nested_attributes(attributes)
18
18
  if attr_def.array_type?
19
- assign_nested_attributes_for_model_array(attributes)
19
+ if attr_def.type.base_type_primitive?
20
+ assign_nested_attributes_for_primitive_array(attributes)
21
+ else
22
+ assign_nested_attributes_for_model_array(attributes)
23
+ end
20
24
  else
21
25
  assign_nested_attributes_for_single_model(attributes)
22
26
  end
@@ -38,6 +42,27 @@ module AttrJson
38
42
  end
39
43
  end
40
44
 
45
+
46
+ # Implementation for an `#{attribute_name}_attributes=` method, when the attr_json
47
+ # attribute in question is recognized as an array of primitive values (not nested models)
48
+ #
49
+ # Really just exists to filter out blank/empty strings with reject_if.
50
+ #
51
+ # It will insist on filtering out empty strings and nils from arrays (ignores reject_if argument),
52
+ # since that's the only reason to use it. It will respect limit argument.
53
+ #
54
+ # Filtering out empty strings can be convenient for using a hidden field in a form to
55
+ # make sure an empty array gets set if all individual fields are removed from form using
56
+ # cocoon-like javascript.
57
+ def assign_nested_attributes_for_primitive_array(attributes_array)
58
+ options = nested_attributes_options[attr_name]
59
+ check_record_limit!(options[:limit], attributes_array)
60
+
61
+ attributes_array = attributes_array.reject { |value| value.blank? }
62
+
63
+ model_send("#{attr_name}=", attributes_array)
64
+ end
65
+
41
66
  # Copied with signficant modifications from:
42
67
  # https://github.com/rails/rails/blob/master/activerecord/lib/active_record/nested_attributes.rb#L407
43
68
  def assign_nested_attributes_for_single_model(attributes)
@@ -29,6 +29,11 @@ module AttrJson
29
29
  # * _allow_destroy_, no such option. Effectively always true, doesn't make sense to try to gate this with our implementation.
30
30
  # * _update_only_, no such option. Not really relevant to this architecture where you're embedded models have no independent existence.
31
31
  #
32
+ # If called on an array of 'primitive' (not AttrJson::Model) objects, it will do a kind of weird
33
+ # thing where it creates an `#{attribute_name}_attributes=` method that does nothing
34
+ # but filter out empty strings and nil values. This can be convenient in hackily form
35
+ # handling array of primitives, see guide doc on forms.
36
+ #
32
37
  # @overload attr_json_accepts_nested_attributes_for(define_build_method: true, reject_if: nil, limit: nil)
33
38
  # @param define_build_method [Boolean] Default true, provide `build_attribute_name`
34
39
  # method that works like you expect. [Cocoon](https://github.com/nathanvda/cocoon),
@@ -74,7 +79,8 @@ module AttrJson
74
79
  end
75
80
  end
76
81
 
77
- if options[:define_build_method]
82
+ # No build method for our wacky array of primitive type.
83
+ if options[:define_build_method] && !(attr_def.array_type? && attr_def.type.base_type_primitive?)
78
84
  _attr_jsons_module.module_eval do
79
85
  build_method_name = "build_#{attr_name.to_s.singularize}"
80
86
  if method_defined?(build_method_name)
@@ -137,20 +137,20 @@ module AttrJson
137
137
  end
138
138
 
139
139
  def saved_changes
140
- saved_changes = model.saved_changes
141
- return {} if saved_changes == {}
140
+ original_saved_changes = model.saved_changes
141
+ return {} if original_saved_changes.blank?
142
142
 
143
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]
144
+ if container_change = original_saved_changes[definition.container_attribute]
145
+ old_v = container_change.dig(0, definition.store_key)
146
+ new_v = container_change.dig(1, definition.store_key)
147
147
  if old_v != new_v
148
148
  [ definition.name.to_s, formatted_before_after(old_v, new_v, definition) ]
149
149
  end
150
150
  end
151
151
  end.compact.to_h
152
152
 
153
- prepared_changes(json_attr_changes, saved_changes)
153
+ prepared_changes(json_attr_changes, original_saved_changes)
154
154
  end
155
155
 
156
156
  def saved_changes?
@@ -198,21 +198,21 @@ module AttrJson
198
198
  end
199
199
 
200
200
  def changes_to_save
201
- changes_to_save = model.changes_to_save
201
+ original_changes_to_save = model.changes_to_save
202
202
 
203
- return {} if changes_to_save == {}
203
+ return {} if original_changes_to_save.blank?
204
204
 
205
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]
206
+ if container_change = original_changes_to_save[definition.container_attribute]
207
+ old_v = container_change.dig(0, definition.store_key)
208
+ new_v = container_change.dig(1, definition.store_key)
209
209
  if old_v != new_v
210
210
  [ definition.name.to_s, formatted_before_after(old_v, new_v, definition) ]
211
211
  end
212
212
  end
213
213
  end.compact.to_h
214
214
 
215
- prepared_changes(json_attr_changes, changes_to_save)
215
+ prepared_changes(json_attr_changes, original_changes_to_save)
216
216
  end
217
217
 
218
218
  def has_changes_to_save?
@@ -270,7 +270,13 @@ module AttrJson
270
270
  # find it from currently declared attributes.
271
271
  # https://github.com/rails/rails/blob/6aa5cf03ea8232180ffbbae4c130b051f813c670/activemodel/lib/active_model/attribute_methods.rb#L463-L468
272
272
  def matched_attribute_method(method_name)
273
- matches = self.class.send(:attribute_method_matchers_matching, method_name)
273
+ if self.class.respond_to?(:attribute_method_patterns_matching, true)
274
+ # Rails 7.1+
275
+ matches = self.class.send(:attribute_method_patterns_matching, method_name)
276
+ else
277
+ matches = self.class.send(:attribute_method_matchers_matching, method_name)
278
+ end
279
+
274
280
  matches.detect do |match|
275
281
  registry.has_attribute?(match.attr_name)
276
282
  end
@@ -10,6 +10,20 @@ module AttrJson
10
10
  end
11
11
 
12
12
  def contains_relation
13
+ contains_relation_impl do |relation, query, params|
14
+ relation.where(query, params)
15
+ end
16
+ end
17
+
18
+ def contains_not_relation
19
+ contains_relation_impl do |relation, query, params|
20
+ relation.where.not(query, params)
21
+ end
22
+ end
23
+
24
+ protected
25
+
26
+ def contains_relation_impl
13
27
  result_relation = relation
14
28
 
15
29
  group_attributes_by_container.each do |container_attribute, attributes|
@@ -18,14 +32,12 @@ module AttrJson
18
32
  attributes.each do |key, value|
19
33
  add_to_param_hash!(param_hash, key, value)
20
34
  end
21
- result_relation = result_relation.where("#{relation.table_name}.#{container_attribute} @> (?)::jsonb", param_hash.to_json)
35
+ result_relation = yield(result_relation, "#{relation.table_name}.#{container_attribute} @> (?)::jsonb", param_hash.to_json)
22
36
  end
23
37
 
24
38
  result_relation
25
39
  end
26
40
 
27
- protected
28
-
29
41
  def merge_param_hash!(original, new)
30
42
  original.deep_merge!(new) do |key, old_val, new_val|
31
43
  if old_val.is_a?(Array) && old_val.first.is_a?(Hash) && new_val.is_a?(Array) && new_val.first.is_a?(Hash)
@@ -17,6 +17,8 @@ module AttrJson
17
17
  #
18
18
  # some_model.jsonb_contains(a_string: "foo").first
19
19
  #
20
+ # some_model.not_jsonb_contains(a_string: "bar").first
21
+ #
20
22
  # See more in {file:README} docs.
21
23
  module QueryScopes
22
24
  extend ActiveSupport::Concern
@@ -29,6 +31,10 @@ module AttrJson
29
31
  scope(:jsonb_contains, lambda do |attributes|
30
32
  QueryBuilder.new(self, attributes).contains_relation
31
33
  end)
34
+
35
+ scope(:not_jsonb_contains, lambda do |attributes|
36
+ QueryBuilder.new(self, attributes).contains_not_relation
37
+ end)
32
38
  end
33
39
  end
34
40
  end