Module: ActiveRecord::Deepstore

Defined in:
lib/active_record/deepstore.rb,
lib/active_record/deepstore/version.rb

Overview

The ActiveRecord::Deepstore module extends ActiveRecord models with additional functionality for handling deeply nested data structures within a database column.

Defined Under Namespace

Classes: Error

Constant Summary collapse

VERSION =
"0.1.2"

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#deep_stored_accessorsObject (readonly)

Returns the value of attribute deep_stored_accessors.



12
13
14
# File 'lib/active_record/deepstore.rb', line 12

def deep_stored_accessors
  @deep_stored_accessors
end

Instance Method Details

#cast_type_from_name(name) ⇒ ActiveRecord::Type::Value

Determines the data type for serialization based on the value type.

Parameters:

  • name (Symbol, String)

    The name of the data type.

Returns:

  • (ActiveRecord::Type::Value)

    The corresponding data type.



211
212
213
# File 'lib/active_record/deepstore.rb', line 211

def cast_type_from_name(name)
  ActiveRecord::Type.lookup name.to_sym, adapter: ActiveRecord::Type.adapter_name_from(self)
end

#cast_type_name_from_value(value) ⇒ Symbol

Determines the data type name based on the value.

Parameters:

  • value (Object)

    The value for which to determine the data type name.

Returns:

  • (Symbol)

    The name of the data type.



219
220
221
222
223
224
225
226
227
228
# File 'lib/active_record/deepstore.rb', line 219

def cast_type_name_from_value(value)
  type_mappings = {
    TrueClass => :boolean,
    FalseClass => :boolean,
    NilClass => :string,
    Hash => :text
  }

  type_mappings.fetch(value.class, value.class.name.underscore.to_sym)
end

#clear_deep_store_changes_informationvoid

This method returns an undefined value.

Clears deep store changes information.



140
141
142
143
144
145
# File 'lib/active_record/deepstore.rb', line 140

define_method(:clear_deep_store_changes_information) do
  self.class.deep_stored_accessors.each do |accessor|
    formatted_accessor = accessor.to_s.parameterize.underscore
    instance_variable_set(:"@#{formatted_accessor}_was", send(formatted_accessor))
  end
end

#deep_accessor_name(accessor_name, key) ⇒ String

Generates a unique name for accessor methods based on the accessor name and key.

Parameters:

  • accessor_name (Symbol, String)

    The name of the deep store accessor.

  • key (Symbol, String)

    The key within the hash.

Returns:

  • (String)

    The generated accessor name.



203
204
205
# File 'lib/active_record/deepstore.rb', line 203

def deep_accessor_name(accessor_name, key)
  "#{key.to_s.parameterize.underscore}_#{accessor_name.to_s.parameterize.underscore}"
end

#deep_store(accessor_name, payload, suffix: true, column_required: true) ⇒ void

This method returns an undefined value.

Defines behavior for storing deeply nested data in a database column.

Parameters:

  • accessor_name (Symbol, String)

    The name of the accessor for the deep store.

  • payload (Hash)

    The hash representing the deeply nested data.

  • suffix (Boolean) (defaults to: true)

    Whether to include a suffix in the accessor name.

  • column_required (Boolean) (defaults to: true)

    Whether the corresponding column is required in the database table.

Raises:

  • (ActiveRecord::Deepstore::Error)

    If the deep store is already declared.

  • (NotImplementedError)

    If the required column is not found in the database table.



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
# File 'lib/active_record/deepstore.rb', line 54

def deep_store(accessor_name, payload, suffix: true, column_required: true)
  accessor_name = accessor_name.to_s.parameterize.underscore

  raise Error, "Deep store '#{accessor_name}' is already declared" if deep_stores.include?(accessor_name)

  @deep_stores << accessor_name

  if column_required && (columns.find do |c|
                           c.name == accessor_name.to_s
                         end).blank?
    raise NotImplementedError,
          "Column #{accessor_name} not found for table #{table_name}"
  end

  serialize accessor_name, type: Hash, default: payload, yaml: { unsafe_load: true } if payload.is_a?(Hash)

  define_method(:"default_#{accessor_name}") { payload.try(:with_indifferent_access) || payload }

  define_method(:"reset_#{accessor_name}") { assign_attributes(accessor_name => send(:"default_#{accessor_name}")) }

  define_method(:"reset_#{accessor_name}!") { update(accessor_name => send(:"default_#{accessor_name}")) }

  define_method(:"#{accessor_name}_changes") do
    old_value = send(:"#{accessor_name}_was")
    current_value = send(accessor_name)
    old_value == current_value ? {} : { old_value => current_value }
  end

  define_method(:"#{accessor_name}=") do |value|
    old_value = send(:"#{accessor_name}_was")

    if value.is_a?(Hash)
      value = {}.with_indifferent_access if value.blank?
      self.class.leaves(value).each do |leaf_path, leaf_value|
        default_value = leaf_path.inject(payload.with_indifferent_access) do |h, key|
          h.is_a?(Hash) ? h.fetch(key, h) : h
        end
        cast_type = self.class.cast_type_from_name(self.class.cast_type_name_from_value(default_value))

        # Traverse the hash using the leaf path and update the leaf value.
        leaf_key = leaf_path.pop
        parent_hash = leaf_path.inject(value, :[])
        #  old_leaf_value = parent_hash[leaf_key]
        new_leaf_value = cast_type.cast(leaf_value)
        old_parent_hash = parent_hash.dup
        parent_hash[leaf_key] = new_leaf_value

        instance_variable_set(:"@#{leaf_path.join("_")}_#{accessor_name}_was",
                              old_parent_hash.with_indifferent_access)
      end

      formatted_value = payload.with_indifferent_access.deep_merge(value)
    else
      default_value = send(:"default_#{accessor_name}")
      cast_type = self.class.cast_type_from_name(self.class.cast_type_name_from_value(default_value))
      formatted_value = cast_type.cast(value)
    end

    instance_variable_set(:"@#{accessor_name}_was", old_value)

    super(formatted_value)
  end
  # rubocop:enable Metrics/AbcSize
  # rubocop:enable Metrics/CyclomaticComplexity
  # rubocop:enable Metrics/MethodLength
  # rubocop:enable Metrics/PerceivedComplexity

  return unless payload.is_a?(Hash)

  payload.each do |key, value|
    deep_store_accessor(accessor_name, payload, key, value, suffix)
  end
end

#deep_store_accessor(accessor_name, payload, key, value, suffix) ⇒ void

This method returns an undefined value.

Defines accessor methods for nested keys within the deep store hash.

Parameters:

  • accessor_name (Symbol, String)

    The name of the deep store accessor.

  • payload (Hash)

    The hash representing the deeply nested data.

  • key (Symbol, String)

    The key within the hash.

  • value (Object)

    The value associated with the key.

  • suffix (Boolean)

    Whether to include a suffix in the accessor name.



155
156
157
158
159
160
161
162
163
164
165
166
167
# File 'lib/active_record/deepstore.rb', line 155

def deep_store_accessor(accessor_name, payload, key, value, suffix)
  store_json_accessor(accessor_name, payload, key, suffix)

  deep_store(deep_accessor_name(accessor_name, key), value, column_required: false)

  return if value.is_a?(Hash)

  define_method(deep_accessor_name(accessor_name, key)) do
    return value unless (hash = public_send(accessor_name)).is_a?(Hash) && hash.key?(key)

    hash[key]
  end
end

#deep_storesArray<String>

Retrieves or initializes the array containing names of attributes declared as deep stores.

Returns:

  • (Array<String>)

    The array containing names of deep stores.



17
18
19
# File 'lib/active_record/deepstore.rb', line 17

def deep_stores
  @deep_stores ||= []
end

#leaves(hash, path: [], current_depth: 0, max_depth: nil) ⇒ Hash

Recursively traverses a nested hash and returns a flattened representation of leaf nodes along with their paths.

Parameters:

  • hash (Hash)

    The nested hash to traverse.

  • path (Array) (defaults to: [])

    The current path in the hash.

  • current_depth (Integer) (defaults to: 0)

    The current depth in the hash traversal.

  • max_depth (Integer, nil) (defaults to: nil)

    The maximum depth to traverse. If nil, traverses the entire hash.

Returns:

  • (Hash)

    The flattened representation of leaf nodes along with their paths.



28
29
30
31
32
33
34
35
36
37
38
# File 'lib/active_record/deepstore.rb', line 28

def leaves(hash, path: [], current_depth: 0, max_depth: nil)
  hash.each_with_object({}) do |(key, value), result|
    current_path = path + [key]

    if value.is_a?(Hash) && (max_depth.nil? || current_depth < max_depth)
      result.merge!(leaves(value, path: current_path, current_depth: current_depth + 1, max_depth: max_depth))
    else
      result[current_path] = value
    end
  end
end

#reloadvoid

This method returns an undefined value.

Reloads the model instance and clears deep store changes information.

Parameters:

  • args (Array)

    Arguments to pass to the reload method.



132
133
134
135
# File 'lib/active_record/deepstore.rb', line 132

define_method(:reload) do |*args|
  clear_deep_store_changes_information
  super(*args)
end

#store_json_accessor(accessor_name, hash, key, suffix) ⇒ void

This method returns an undefined value.

Defines accessor methods for individual keys within the nested hash.

Parameters:

  • accessor_name (Symbol, String)

    The name of the deep store accessor.

  • hash (Hash)

    The hash representing the deeply nested data.

  • key (Symbol, String)

    The key within the hash.

  • suffix (Boolean)

    Whether to include a suffix in the accessor name.



178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/active_record/deepstore.rb', line 178

def store_json_accessor(accessor_name, hash, key, suffix)
  store_accessor(accessor_name.to_sym, key, suffix: suffix)
  base_method_name = deep_accessor_name(accessor_name, key)
  @deep_stored_accessors ||= []
  @deep_stored_accessors << base_method_name
  attribute base_method_name, cast_type_name_from_value(hash[key])

  define_method(:"#{base_method_name}_was") do
    method_name = :"#{base_method_name}_was"
    return instance_variable_get("@#{method_name}") if instance_variable_defined?("@#{method_name}")

    instance_variable_set("@#{method_name}", send(base_method_name))
  end

  define_method(:"#{base_method_name}_changed?") do
    send(:"#{base_method_name}_changes").any?
  end
end