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 16, 2021
1 parent 9fdb567 commit e18489d
Show file tree
Hide file tree
Showing 5 changed files with 360 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: 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
281 changes: 281 additions & 0 deletions lib/no_brainer/document/store.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
# 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 = {})
serialize store_attribute, IndifferentCoder.new(store_attribute, options[:coder])
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

_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 attribute_changed?(store_attribute)
prev_store, new_store = changes[store_attribute]
prev_store&.dig(key) != new_store&.dig(key)
end

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

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

define_method("saved_change_to_#{accessor_key}?") do
return false unless saved_change_to_attribute?(store_attribute)
prev_store, new_store = saved_change_to_attribute(store_attribute)
prev_store&.dig(key) != new_store&.dig(key)
end

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

define_method("#{accessor_key}_before_last_save") do
return unless saved_change_to_attribute?(store_attribute)
prev_store, _new_store = saved_change_to_attribute(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:
accessor = store_accessor_for(store_attribute)
accessor.read(self, store_attribute, key)
end

def write_store_attribute(store_attribute, key, value) # :doc:
accessor = store_accessor_for(store_attribute)
accessor.write(self, store_attribute, key, value)
end

def store_accessor_for(store_attribute)
type_for_attribute(store_attribute).accessor
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)
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
20 changes: 20 additions & 0 deletions spec/integration/store_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

require 'spec_helper'

describe 'store' do
before { load_store_accessor_document }
let(:john) do
AdminUser.create!(
name: 'John Doe', color: 'black', remember_login: true,
height: 'tall', is_a_good_guy: true,
parent_name: 'Quinn', partner_name: 'Dallas',
partner_birthday: '1997-11-1'
)
end

it 'reads the store attributes through accessors' do
expect(john.color).to eql('black')
expect(john.homepage).to be_nil
end
end
57 changes: 57 additions & 0 deletions spec/support/models.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
require 'yaml'

module ModelsHelper
def load_simple_document
define_class :SimpleDocument do
Expand Down Expand Up @@ -99,5 +101,60 @@ def load_polymorphic_models
end
end

def load_store_accessor_document
define_class :AdminUser do
include NoBrainer::Document

field :name
field :parent_name
field :partner_name
field :partner_birthday

store :params, accessors: [ :token ], coder: YAML
store :settings, accessors: [ :color, :homepage ]
store_accessor :settings, :favorite_food
store :parent, accessors: [:birthday, :name], prefix: true
store :spouse, accessors: [:birthday], prefix: :partner
store_accessor :spouse, :name, prefix: :partner
store :configs, accessors: [ :secret_question ]
store :configs, accessors: [ :two_factor_auth ], suffix: true
store_accessor :configs, :login_retry, suffix: :config
store :preferences, accessors: [ :remember_login ]
store :json_data, accessors: [ :height, :weight ], coder: Coder.new
store :json_data_empty, accessors: [ :is_a_good_guy ], coder: Coder.new

def phone_number
read_store_attribute(:settings, :phone_number).gsub(/(\d{3})(\d{3})(\d{4})/, '(\1) \2-\3')
end

def phone_number=(value)
write_store_attribute(:settings, :phone_number, value && value.gsub(/[^\d]/, ""))
end

def color
super || "red"
end

def color=(value)
value = "blue" unless %w(black red green blue).include?(value)
super
end
end

define_class :Coder do
def initialize(default = {})
@default = default
end

def dump(o)
ActiveSupport::JSON.encode(o || @default)
end

def load(s)
s.present? ? ActiveSupport::JSON.decode(s) : @default.clone
end
end
end

RSpec.configure { |config| config.include self }
end

0 comments on commit e18489d

Please sign in to comment.