Module: JsonbAccessor::Macro::ClassMethods

Defined in:
lib/jsonb_accessor/macro.rb

Instance Method Summary collapse

Instance Method Details

#jsonb_accessor(jsonb_attribute, global_options = {}, **definitions) ⇒ Object



6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/jsonb_accessor/macro.rb', line 6

def jsonb_accessor(jsonb_attribute, global_options = {}, **definitions)
  # Backward compatibility: support the old positional hash syntax where definitions
  # are passed as a plain hash (jsonb_accessor :col, { foo: :string })
  definitions = global_options if global_options.present? && definitions.blank?

  names_and_store_keys = {}
  names_and_defaults = {}
  names_and_attribute_names = {}

  definitions.each do |name, value|
    args = Array(value)
    options = args.last.is_a?(Hash) ? args.pop : {}

    # Determine store keys and default values for each field
    names_and_store_keys[name.to_s] = (options.delete(:store_key) || name).to_s
    names_and_defaults[name.to_s] = options[:default] unless options[:default].nil?

    prefix = options.delete(:prefix) || global_options[:prefix]
    suffix = options.delete(:suffix) || global_options[:suffix]
    attribute_name = JsonbAccessor::Helpers.define_attribute_name(jsonb_attribute, name, prefix, suffix)

    # Define virtual attributes for each field
    names_and_attribute_names[name.to_s] = attribute_name
    attribute attribute_name, *args, **options
  end

  store_key_mapping_method_name = "jsonb_store_key_mapping_for_#{jsonb_attribute}"
  attribute_name_mapping_method_name = "jsonb_attribute_name_mapping_for_#{jsonb_attribute}"
  # Defines methods on the model class
  class_methods = Module.new do
    # Allows us to get a mapping of field names to store keys scoped to the column
    define_method(store_key_mapping_method_name) do
      superclass_mapping = superclass.try(store_key_mapping_method_name) || {}
      superclass_mapping.merge(names_and_store_keys)
    end

    # Allows us to get a mapping of field names to attribute names scoped to the column
    define_method(attribute_name_mapping_method_name) do
      superclass_mapping = superclass.try(attribute_name_mapping_method_name) || {}
      superclass_mapping.merge(names_and_attribute_names)
    end
  end
  # We extend with class methods here so we can use the results of methods it defines to define more useful methods later
  extend class_methods

  # Get store keys to default values mapping
  store_keys_and_defaults = JsonbAccessor::Helpers.convert_keys_to_store_keys(names_and_defaults, public_send(store_key_mapping_method_name))

  # Define jsonb_defaults_mapping_for_<jsonb_attribute>
  defaults_mapping_method_name = "jsonb_defaults_mapping_for_#{jsonb_attribute}"
  class_methods.instance_eval do
    define_method(defaults_mapping_method_name) do
      superclass_mapping = superclass.try(defaults_mapping_method_name) || {}
      superclass_mapping.merge(store_keys_and_defaults)
    end
  end

  all_defaults_mapping = public_send(defaults_mapping_method_name)
  # Fields may have procs as default value. This means `all_defaults_mapping` may contain procs as values. To make this work
  # with the attributes API, we need to wrap `all_defaults_mapping` with a proc itself, making sure it returns a plain hash
  # each time it is evaluated.
  all_defaults_mapping_proc =
    if all_defaults_mapping.present?
      -> { all_defaults_mapping.transform_values { |value| value.respond_to?(:call) ? value.call : value }.to_h.compact }
    end
  attribute jsonb_attribute, :jsonb, default: all_defaults_mapping_proc if all_defaults_mapping_proc.present?

  # Setters are in a module to allow users to override them and still be able to use `super`.
  setters = Module.new do
    # Overrides the setter created by `attribute` above to make sure the jsonb attribute is kept in sync.
    names_and_store_keys.each do |name, store_key|
      attribute_name = names_and_attribute_names[name]

      define_method("#{attribute_name}=") do |value|
        super(value)

        # If enum was defined, take the value from the enum and not what comes out directly from the getter
        attribute_value = defined_enums[attribute_name].present? ? defined_enums[attribute_name][value] : public_send(attribute_name)

        # Rails always saves time based on `default_timezone`. Since #as_json considers timezone, manual conversion is needed
        if attribute_value.acts_like?(:time)
          attribute_value = (JsonbAccessor::Helpers.active_record_default_timezone == :utc ? attribute_value.utc : attribute_value.in_time_zone).strftime("%F %R:%S.%L")
        end

        new_values = (public_send(jsonb_attribute) || {}).merge(store_key => attribute_value)
        write_attribute(jsonb_attribute, new_values)
      end
    end

    # Overrides the jsonb attribute setter to make sure the jsonb fields are kept in sync.
    define_method("#{jsonb_attribute}=") do |value|
      value ||= {}
      names_to_store_keys = self.class.public_send(store_key_mapping_method_name)
      names_to_attribute_names = self.class.public_send(attribute_name_mapping_method_name)

      # this is the raw hash we want to save in the jsonb_attribute
      value_with_store_keys = JsonbAccessor::Helpers.convert_keys_to_store_keys(value, names_to_store_keys)
      write_attribute(jsonb_attribute, value_with_store_keys)

      # this maps attributes to values
      value_with_named_keys = JsonbAccessor::Helpers.convert_store_keys_to_keys(value, names_to_store_keys)

      empty_named_attributes = names_to_store_keys.transform_values { nil }
      empty_named_attributes.merge(value_with_named_keys).each do |name, attribute_value|
        # Only proceed if this attribute has been defined using `jsonb_accessor`.
        next unless names_to_store_keys.key?(name)

        attribute_name = names_to_attribute_names[name]
        write_attribute(attribute_name, attribute_value)
      end
    end
  end
  include setters

  # Makes sure new objects have the appropriate values in their jsonb fields.
  after_initialize do
    next unless has_attribute? jsonb_attribute

    jsonb_values = public_send(jsonb_attribute) || {}
    jsonb_values.each do |store_key, value|
      name = names_and_store_keys.key(store_key)
      attribute_name = names_and_attribute_names[name]

      next unless attribute_name

      write_attribute(
        attribute_name,
        JsonbAccessor::Helpers.deserialize_value(value, self.class.type_for_attribute(attribute_name).type)
      )
      clear_attribute_change(attribute_name) if persisted?
    end
  end

  JsonbAccessor::AttributeQueryMethods.new(self).define(store_key_mapping_method_name, jsonb_attribute)
end