Module: AttrJson::Model

Extended by:
ActiveSupport::Concern
Includes:
ActiveModel::Model, ActiveModel::Serialization
Defined in:
lib/attr_json/model.rb,
lib/attr_json/model/cocoon_compat.rb,
lib/attr_json/model/nested_model_validator.rb

Overview

Note:

Includes ActiveModel::Model whether you like it or not.

Meant for use in a plain class, turns it into an ActiveModel::Model with attr_json support. NOT for use in an ActiveRecord::Base model, see Record for ActiveRecord use.

Creates an ActiveModel object with typed attributes, easily serializable to json, and with a corresponding ActiveModel::Type representing the class. Meant for use as an attribute of a AttrJson::Record. Can be nested, AttrJson::Models can have attributes that are other AttrJson::Models.

You can control what happens if you set an unknown key (one that you didn't register with attr_json) with the config attribute attr_json_config(unknown_key:).

  • :raise (default) raise ActiveModel::UnknownAttributeError
  • :strip Ignore the unknown key and do not include it, without raising.
  • :allow Allow the unknown key and it's value to be in the serialized hash, and written to the database. May be useful for legacy data or columns that other software touches, to let unknown keys just flow through.

    class Something include AttrJson::Model attr_json_config(unknown_key: :allow) #... end

Similarly, trying to set a Model-valued attribute with an object that can't be cast to a Hash or Model at all will normally raise a AttrJson::Type::Model::BadCast error, but you can set config bad_cast: :as_nil to make it cast to nil, more like typical ActiveRecord cast.

   class Something
     include AttrJson::Model
     attr_json_config(bad_cast: :as_nil)
     #...
   end

Date-type timezone conversion

By default, AttrJson::Model date/time attributes will be ActiveRecord timezone-aware based on settings of config.active_record.time_zone_aware_attributes and ActiveRecord::Base.time_zone_aware_types.

If you'd like to override this, you can set:

attr_json_config(time_zone_aware_attributes: true)
attr_json_config(time_zone_aware_attributes: false)
attr_json_config(time_zone_aware_attributes: [:datetime, :time]) # custom list of types

ActiveRecord serialize

If you want to map a single AttrJson::Model to a json/jsonb column, you can use ActiveRecord serialize feature.

https://api.rubyonrails.org/classes/ActiveRecord/AttributeMethods/Serialization/ClassMethods.html

We provide a simple shim to give you the right API for a "coder" for AR serialize:

class ValueModel include AttrJson::Model attr_json :some_string, :string end

class SomeModel < ApplicationRecord serialize :some_json_column, ValueModel.to_serialize_coder end

Strip nils

When embedded in an attr_json attribute, models are normally serialized with nil values stripped from hash where possible, for a more compact representation. This can be set differently in the type.

attr_json :lang_and_value, LangAndValue.to_type(strip_nils: false)

See #serializable_hash docs for possible values.

Defined Under Namespace

Modules: CocoonCompat Classes: NestedModelValidator

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.attr_json(name, type, **options) ⇒ Object

Type can be an instance of an ActiveModel::Type::Value subclass, or a symbol that will be looked up in ActiveModel::Type.lookup

Parameters:

  • name (Symbol, String)

    name of attribute

  • type (ActiveModel::Type::Value)

    An instance of an ActiveModel::Type::Value (or subclass)

  • options (Hash)

    a customizable set of options

Options Hash (**options):

  • :array (Boolean) — default: false

    Make this attribute an array of given type. Array types default to an empty array. If you want to turn that off, you can add default: AttrJson::AttributeDefinition::NO_DEFAULT_PROVIDED

  • :default (Object) — default: nil

    Default value, if a Proc object it will be #call'd for default.

  • :store_key (String, Symbol) — default: nil

    Serialize to JSON using given store_key, rather than name as would be usual.

  • :validate (Boolean) — default: true

    Mak validation errors on the attributes post up to self, using something similar to an ActiveRecord::Validations::AssociatedValidator



213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/attr_json/model.rb', line 213

def attr_json(name, type, **options)
  options.assert_valid_keys(*(AttributeDefinition::VALID_OPTIONS - [:container_attribute] + [:validate]))

  type = _attr_json_maybe_wrap_timezone_aware(type)

  self.attr_json_registry = attr_json_registry.with(
    AttributeDefinition.new(name.to_sym, type, options.except(:validate))
  )

  # By default, automatically validate nested models
  if (type.kind_of?(AttrJson::Type::Model) || type.kind_of?(AttrJson::Type::PolymorphicModel)) && options[:validate] != false
    # Post validations up with something based on ActiveRecord::Validations::AssociatedValidator
    self.validates_with ::AttrJson::Model::NestedModelValidator, attributes: [name.to_sym]
  end

  _attr_jsons_module.module_eval do
    define_method("#{name}=") do |value|
      _attr_json_write(AttrJson.efficient_to_s(name), value)
    end

    define_method("#{name}") do
      attributes[AttrJson.efficient_to_s(name)]
    end
  end
end

.attr_json_config(new_values = {}) ⇒ Object



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
# File 'lib/attr_json/model.rb', line 112

def attr_json_config(new_values = {})
  if new_values.present?
    # get one without new values, then merge new values into it, and
    # set it locally for this class.
    @attr_json_config = attr_json_config.merge(new_values)
  else
    if instance_variable_defined?("@attr_json_config")
      # we have a custom one for this class, return it.
      @attr_json_config
    elsif superclass.respond_to?(:attr_json_config)
      # return superclass without setting it locally, so changes in superclass
      # will continue effecting us.
      superclass.attr_json_config
    else
      # no superclass, no nothing, set it to blank one.
      @attr_json_config = Config.new(mode: :model)
    end
  end
end

.attribute_namesObject

like the ActiveModel::Attributes method



184
185
186
# File 'lib/attr_json/model.rb', line 184

def attribute_names
  attr_json_registry.attribute_names
end

.attribute_typesObject

like the ActiveModel::Attributes method, hash with name keys, and ActiveModel::Type values



189
190
191
# File 'lib/attr_json/model.rb', line 189

def attribute_types
  attribute_names.collect { |name| [AttrJson.efficient_to_s(name), attr_json_registry.type_for_attribute(name)]}.to_h
end

.new_from_serializable(attributes = {}) ⇒ Object

The inverse of model#serializable_hash -- re-hydrates a serialized hash to a model.

Similar to .new, but translates things that need to be translated in deserialization, like store_keys, and properly calling deserialize (rather than cast) on the underlying types.

Examples:

Model.new_from_serializable(hash)



139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
# File 'lib/attr_json/model.rb', line 139

def new_from_serializable(attributes = {})
  attributes = attributes.collect do |key, value|
    # store keys in arguments get translated to attribute names on initialize.
    if attribute_def = self.attr_json_registry.store_key_lookup("".freeze, AttrJson.efficient_to_s(key))
      key = AttrJson.efficient_to_s(attribute_def.name)
    end

    attr_type = self.attr_json_registry.has_attribute?(key) && self.attr_json_registry.type_for_attribute(key)
    if attr_type
      value = attr_type.deserialize(value)
    end

    [key, value]
  end.to_h

  self.new(attributes)
end

.to_serialization_coderObject

An ActiveModel::Type that can be used to serialize this model across an entire JSON(b) column.

Examples:

using standard ActiveRecord serialize feature.


class MyTable < ApplicationRecord
  serialize :some_json_column, MyModel.to_serialization_coder

  # In Rails 7.1+:
  # serialize :some_json_column, coder: MyModel.to_serialization_coder
end


179
180
181
# File 'lib/attr_json/model.rb', line 179

def to_serialization_coder
  @serialization_coder ||= AttrJson::SerializationCoderFromType.new(to_type)
end

.to_type(strip_nils: :safely) ⇒ Object

an AttrJson::Record or ::Model attribute

Parameters:

  • strip_nils (Boolean, Symbol) (defaults to: :safely)

    [true,false,:safely] as a type, should we strip nils when serializing? By default this type strips nils in :safely mode. See AttrJson::Model#serializable_hash



163
164
165
# File 'lib/attr_json/model.rb', line 163

def to_type(strip_nils: :safely)
  @type ||= AttrJson::Type::Model.new(self, strip_nils: strip_nils)
end

Instance Method Details

#==(other_object) ⇒ Object

Two AttrJson::Model objects are equal if they are the same class AND their #attributes are equal.



406
407
408
# File 'lib/attr_json/model.rb', line 406

def ==(other_object)
  other_object.class == self.class && other_object.attributes == self.attributes
end

#_destroyObject

ActiveRecord objects have a _destroy, related to marked_for_destruction? functionality used with AR nested attributes. We don't mark for destruction, our nested attributes implementation just deletes immediately, but having this simple method always returning false makes things work more compatibly and smoothly with standard code for nested attributes deletion in form builders.



415
416
417
# File 'lib/attr_json/model.rb', line 415

def _destroy
  false
end

#as_json(options = nil) ⇒ Object

ActiveRecord JSON serialization will insist on calling this, instead of the specified type's #serialize, at least in some cases. So it's important we define it -- the default #as_json added by ActiveSupport will serialize all instance variables, which is not what we want.

Parameters:

  • strip_nils (:symbol, Boolean)

    (default false) [true, false, :safely], see #serializable_hash



394
395
396
# File 'lib/attr_json/model.rb', line 394

def as_json(options=nil)
  serializable_hash(options)
end

#assign_attributes(new_attributes) ⇒ Object



307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
# File 'lib/attr_json/model.rb', line 307

def assign_attributes(new_attributes)
  if !new_attributes.respond_to?(:stringify_keys)
    raise ArgumentError, "When assigning attributes, you must pass a hash as an argument."
  end
  return if new_attributes.empty?

  # stringify keys just like https://github.com/rails/rails/blob/4f99a2186479d5f77460622f2c0f37708b3ec1bc/activemodel/lib/active_model/attribute_assignment.rb#L34
  new_attributes.stringify_keys.each do |k, v|
    setter = :"#{k}="
    if respond_to?(setter)
      public_send(setter, v)
    else
      _attr_json_write_unknown_attribute(k, v)
    end
  end
end

#attribute_namesObject

like the ActiveModel::Attributes method



337
338
339
# File 'lib/attr_json/model.rb', line 337

def attribute_names
  self.class.attribute_names
end

#attributesObject



301
302
303
# File 'lib/attr_json/model.rb', line 301

def attributes
  @attributes ||= {}
end

#freezeObject



423
424
425
426
# File 'lib/attr_json/model.rb', line 423

def freeze
  attributes.freeze unless frozen?
  super
end

#has_attribute?(str) ⇒ Boolean

This attribute from ActiveRecord make SimpleForm happy, and able to detect type.

Returns:

  • (Boolean)


332
333
334
# File 'lib/attr_json/model.rb', line 332

def has_attribute?(str)
  self.class.attr_json_registry.has_attribute?(str)
end

#initialize(attributes = {}) ⇒ Object



289
290
291
292
293
# File 'lib/attr_json/model.rb', line 289

def initialize(attributes = {})
  super

  fill_in_defaults!
end

#initialize_dup(other) ⇒ Object



296
297
298
299
# File 'lib/attr_json/model.rb', line 296

def initialize_dup(other) # :nodoc:
  @attributes = @attributes.deep_dup
  super
end

#serializable_hash(options = nil) ⇒ Object

Override from ActiveModel::Serialization to:

  • handle store_key settings

  • serialize by type to make sure any values set directly on hash still

    get properly type-serialized.

  • custom logic for keeping nil values out of serialization to be more compact

Parameters:

  • strip_nils (:symbol, Boolean)

    (default false) Should we keep keys with nil values out of the serialization entirely? You might want to to keep your in-database serialization compact. By default this method does not -- but by default AttrJson::Type::Model sends :safely when serializing.

    • false => do not strip nils
    • :safely => strip nils only when there is no default value for the attribute, so nil can still override the default value
    • true => strip nils even if there is a default value -- in AttrJson context, this means the default will be reapplied over nil on every de-serialization!


360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
# File 'lib/attr_json/model.rb', line 360

def serializable_hash(options=nil)
  strip_nils = options&.has_key?(:strip_nils) ? options.delete(:strip_nils) : false

  unless [true, false, :safely].include?(strip_nils)
    raise ArgumentError, ":strip_nils must be true, false, or :safely"
  end

  super(options).collect do |key, value|
    if attribute_def = self.class.attr_json_registry[key.to_sym]
      key = attribute_def.store_key

      value = attribute_def.serialize(value)
    end

    # strip_nils handling
    if value.nil? && (
       (strip_nils == :safely && !attribute_def&.has_default?) ||
        strip_nils == true )
    then
      # do not include in serializable_hash
      nil
    else
      [key, value]
    end
  end.compact.to_h
end

#to_hObject

We deep_dup on #to_h, you want attributes unduped, ask for #attributes.



400
401
402
# File 'lib/attr_json/model.rb', line 400

def to_h
  attributes.deep_dup
end

#type_for_attribute(attr_name) ⇒ Object

This attribute from ActiveRecord makes SimpleForm happy, and able to detect type.



326
327
328
# File 'lib/attr_json/model.rb', line 326

def type_for_attribute(attr_name)
  self.class.attr_json_registry.type_for_attribute(attr_name)
end