attr_json 0.2.0 → 1.5.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.
@@ -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