Skip to content

Commit

Permalink
Implements the store_accessor method (Closes #280)
Browse files Browse the repository at this point in the history
  • Loading branch information
zedtux committed Oct 17, 2021
1 parent 9fdb567 commit 743bc61
Show file tree
Hide file tree
Showing 6 changed files with 706 additions and 1 deletion.
1 change: 1 addition & 0 deletions .ruby_version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2.7
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
### Added
- ActiveRecord `store_accessor` helper method

## [0.40.0] - 2021-10-16
### Fixed
Expand Down
2 changes: 1 addition & 1 deletion lib/no_brainer/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ module NoBrainer::Document
autoload_and_include :Core, :TableConfig, :InjectionLayer, :Attributes, :Readonly,
:Persistance, :Callbacks, :Validation, :Types, :Dirty, :PrimaryKey,
:Association, :Serialization, :Criteria, :Polymorphic, :Index, :Aliases,
:MissingAttributes, :LazyFetch, :AtomicOps, :VirtualAttributes
:MissingAttributes, :LazyFetch, :AtomicOps, :VirtualAttributes, :Store

autoload :DynamicAttributes, :Timestamps

Expand Down
286 changes: 286 additions & 0 deletions lib/no_brainer/document/store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
# frozen_string_literal: true

require 'active_support/core_ext/hash/indifferent_access'

module NoBrainer
module Document
# Store gives you a thin wrapper around serialize for the purpose of storing hashes in a single column.
# It's like a simple key/value store baked into your record when you don't care about being able to
# query that store outside the context of a single record.
#
# You can then declare accessors to this store that are then accessible just like any other attribute
# of the model. This is very helpful for easily exposing store keys to a form or elsewhere that's
# already built around just accessing attributes on the model.
#
# Every accessor comes with dirty tracking methods (+key_changed?+, +key_was+ and +key_change+) and
# methods to access the changes made during the last save (+saved_change_to_key?+, +saved_change_to_key+ and
# +key_before_last_save+).
#
# NOTE: There is no +key_will_change!+ method for accessors, use +store_will_change!+ instead.
#
# Make sure that you declare the database column used for the serialized store as a text, so there's
# plenty of room.
#
# You can set custom coder to encode/decode your serialized attributes to/from different formats.
# JSON, YAML, Marshal are supported out of the box. Generally it can be any wrapper that provides +load+ and +dump+.
#
# NOTE: If you are using structured database data types (e.g. PostgreSQL +hstore+/+json+, or MySQL 5.7+
# +json+) there is no need for the serialization provided by {.store}[rdoc-ref:rdoc-ref:ClassMethods#store].
# Simply use {.store_accessor}[rdoc-ref:ClassMethods#store_accessor] instead to generate
# the accessor methods. Be aware that these columns use a string keyed hash and do not allow access
# using a symbol.
#
# NOTE: The default validations with the exception of +uniqueness+ will work.
# For example, if you want to check for +uniqueness+ with +hstore+ you will
# need to use a custom validation to handle it.
#
# Examples:
#
# class User < ActiveRecord::Base
# store :settings, accessors: [ :color, :homepage ], coder: JSON
# store :parent, accessors: [ :name ], coder: JSON, prefix: true
# store :spouse, accessors: [ :name ], coder: JSON, prefix: :partner
# store :settings, accessors: [ :two_factor_auth ], suffix: true
# store :settings, accessors: [ :login_retry ], suffix: :config
# end
#
# u = User.new(color: 'black', homepage: '37signals.com', parent_name: 'Mary', partner_name: 'Lily')
# u.color # Accessor stored attribute
# u.parent_name # Accessor stored attribute with prefix
# u.partner_name # Accessor stored attribute with custom prefix
# u.two_factor_auth_settings # Accessor stored attribute with suffix
# u.login_retry_config # Accessor stored attribute with custom suffix
# u.settings[:country] = 'Denmark' # Any attribute, even if not specified with an accessor
#
# # There is no difference between strings and symbols for accessing custom attributes
# u.settings[:country] # => 'Denmark'
# u.settings['country'] # => 'Denmark'
#
# # Dirty tracking
# u.color = 'green'
# u.color_changed? # => true
# u.color_was # => 'black'
# u.color_change # => ['black', 'red']
#
# # Add additional accessors to an existing store through store_accessor
# class SuperUser < User
# store_accessor :settings, :privileges, :servants
# store_accessor :parent, :birthday, prefix: true
# store_accessor :settings, :secret_question, suffix: :config
# end
#
# The stored attribute names can be retrieved using {.stored_attributes}[rdoc-ref:rdoc-ref:ClassMethods#stored_attributes].
#
# User.stored_attributes[:settings] # [:color, :homepage, :two_factor_auth, :login_retry]
#
# == Overwriting default accessors
#
# All stored values are automatically available through accessors on the Active Record
# object, but sometimes you want to specialize this behavior. This can be done by overwriting
# the default accessors (using the same name as the attribute) and calling <tt>super</tt>
# to actually change things.
#
# class Song < ActiveRecord::Base
# # Uses a stored integer to hold the volume adjustment of the song
# store :settings, accessors: [:volume_adjustment]
#
# def volume_adjustment=(decibels)
# super(decibels.to_i)
# end
#
# def volume_adjustment
# super.to_i
# end
# end
module Store
extend ActiveSupport::Concern

included do
class << self
attr_accessor :local_stored_attributes
end
end

module ClassMethods
def store(store_attribute, options = {})
store_accessor(store_attribute, options[:accessors], **options.slice(:prefix, :suffix)) if options.has_key? :accessors
end

def store_accessor(store_attribute, *keys, prefix: nil, suffix: nil)
keys = keys.flatten

accessor_prefix =
case prefix
when String, Symbol
"#{prefix}_"
when TrueClass
"#{store_attribute}_"
else
""
end
accessor_suffix =
case suffix
when String, Symbol
"_#{suffix}"
when TrueClass
"_#{store_attribute}"
else
""
end

field store_attribute, type: Hash, default: {} unless has_field?(store_attribute)

define_method("#{store_attribute}=") do |value|
super(value) if value.is_a?(Hash) || value.nil?
end

_store_accessors_module.module_eval do
keys.each do |key|
accessor_key = "#{accessor_prefix}#{key}#{accessor_suffix}"

define_method("#{accessor_key}=") do |value|
write_store_attribute(store_attribute, key, value)
end

define_method(accessor_key) do
read_store_attribute(store_attribute, key)
end

define_method("#{accessor_key}_changed?") do
return false unless __send__("#{store_attribute}_changed?")
prev_store, new_store = changes[store_attribute]
prev_store&.dig(key) != new_store&.dig(key)
end

define_method("#{accessor_key}_change") do
return unless __send__("#{store_attribute}_changed?")
prev_store, new_store = changes[store_attribute]
[prev_store&.dig(key), new_store&.dig(key)]
end

define_method("#{accessor_key}_was") do
return unless __send__("#{store_attribute}_changed?")
prev_store, _new_store = changes[store_attribute]
prev_store&.dig(key)
end

# NoBrainer doesn't have `attribute_will_change!` so those methods
# can't be implemented yet.
# See https://github.com/NoBrainerORM/nobrainer/pull/190
#
# define_method("saved_change_to_#{accessor_key}?") do
# return false unless __send__("saved_change_to_#{store_attribute}?")
# prev_store, new_store = __send__("saved_change_to_#{store_attribute}")
# prev_store&.dig(key) != new_store&.dig(key)
# end

# define_method("saved_change_to_#{accessor_key}") do
# return unless __send__("saved_change_to_#{store_attribute}?")
# prev_store, new_store = __send__("saved_change_to_#{store_attribute}")
# [prev_store&.dig(key), new_store&.dig(key)]
# end

# define_method("#{accessor_key}_before_last_save") do
# return unless __send__("saved_change_to_#{store_attribute}?")
# prev_store, _new_store = __send__("saved_change_to_#{store_attribute}")
# prev_store&.dig(key)
# end
end
end

# assign new store attribute and create new hash to ensure that each class in the hierarchy
# has its own hash of stored attributes.
self.local_stored_attributes ||= {}
self.local_stored_attributes[store_attribute] ||= []
self.local_stored_attributes[store_attribute] |= keys
end

def _store_accessors_module # :nodoc:
@_store_accessors_module ||= begin
mod = Module.new
include mod
mod
end
end

def stored_attributes
parent = superclass.respond_to?(:stored_attributes) ? superclass.stored_attributes : {}
if local_stored_attributes
parent.merge!(local_stored_attributes) { |k, a, b| a | b }
end
parent
end
end

private

def read_store_attribute(store_attribute, key) # :doc:
StringKeyedHashAccessor.read(self, store_attribute, key)
end

def write_store_attribute(store_attribute, key, value) # :doc:
StringKeyedHashAccessor.write(self, store_attribute, key, value)
end

class HashAccessor # :nodoc:
def self.read(object, attribute, key)
prepare(object, attribute)
object.public_send(attribute)[key]
end

def self.write(object, attribute, key, value)
prepare(object, attribute)
if value != read(object, attribute, key)
# "#{attribute}_will_change!" is not implemented in NoBrainer. See issue #190
# object.public_send :"#{attribute}_will_change!"
object.public_send(attribute)[key] = value
end
end

def self.prepare(object, attribute)
object.public_send :"#{attribute}=", {} unless object.send(attribute)
end
end

class StringKeyedHashAccessor < HashAccessor # :nodoc:
def self.read(object, attribute, key)
super object, attribute, key.to_s
end

def self.write(object, attribute, key, value)
super object, attribute, key.to_s, value
end
end

class IndifferentCoder # :nodoc:
def initialize(attr_name, coder_or_class_name)
@coder =
if coder_or_class_name.respond_to?(:load) && coder_or_class_name.respond_to?(:dump)
coder_or_class_name
else
NoBrainer::Document::Coders::YAMLColumn.new(attr_name, coder_or_class_name || Object)
end
end

def dump(obj)
@coder.dump self.class.as_indifferent_hash(obj)
end

def load(yaml)
self.class.as_indifferent_hash(@coder.load(yaml || ""))
end

def self.as_indifferent_hash(obj)
case obj
when ActiveSupport::HashWithIndifferentAccess
obj
when Hash
obj.with_indifferent_access
else
ActiveSupport::HashWithIndifferentAccess.new
end
end
end
end
end
end
Loading

0 comments on commit 743bc61

Please sign in to comment.