Skip to content

Commit

Permalink
Merge pull request NoBrainerORM#282 from NoBrainerORM/features/store_…
Browse files Browse the repository at this point in the history
…accessor-support

Implements the `store_accessor` method (Closes NoBrainerORM#280)
  • Loading branch information
zedtux authored Oct 17, 2021
2 parents 9fdb567 + 9717c43 commit 01ddaa6
Show file tree
Hide file tree
Showing 7 changed files with 728 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
304 changes: 304 additions & 0 deletions lib/no_brainer/document/store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
# frozen_string_literal: true

require 'active_support/core_ext/hash/indifferent_access'

module NoBrainer
module Document
# Store gives you a way for storing hashes in a single field with accessors
# to the Hash keys.
# It is a portage of the ActiveRecord::Store which make gems using it
# compatible with NoBrainer.
#
# 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+).
#
# 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: The {.store}[rdoc-ref:rdoc-ref:ClassMethods#store] method is here
# for compatibility reason, but you should 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.
#
# Examples:
#
# class User
# include NoBrainer::Document
#
# 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 NoBrainer Document 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
# include NoBrainer::Document
#
# # 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]
if NoBrainer.rails4?
(prev_store && prev_store[key.to_s]) != (new_store && new_store[key.to_s])
else
(prev_store && prev_store.dig(key)) != (new_store && new_store.dig(key))
end
end

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

define_method("#{accessor_key}_was") do
return unless __send__("#{store_attribute}_changed?")
prev_store, _new_store = changes[store_attribute]
if NoBrainer.rails4?
(prev_store && prev_store[key.to_s])
else
(prev_store && prev_store.dig(key))
end
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
4 changes: 4 additions & 0 deletions lib/nobrainer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ def jruby?
RUBY_PLATFORM == 'java'
end

def rails4?
Gem.loaded_specs['activesupport'].version >= Gem::Version.new('4.0.0')
end

def rails5?
Gem.loaded_specs['activesupport'].version >= Gem::Version.new('5.0.0.beta')
end
Expand Down
Loading

0 comments on commit 01ddaa6

Please sign in to comment.