From e18489d290b7f03b42b4668cb26298f27763218d Mon Sep 17 00:00:00 2001 From: Guillaume Hain Date: Sat, 16 Oct 2021 14:19:55 +0200 Subject: [PATCH] Implements the `store_accessor` method (Closes #280) --- .ruby_version | 1 + lib/no_brainer/document.rb | 2 +- lib/no_brainer/document/store.rb | 281 +++++++++++++++++++++++++++++++ spec/integration/store_spec.rb | 20 +++ spec/support/models.rb | 57 +++++++ 5 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 .ruby_version create mode 100644 lib/no_brainer/document/store.rb create mode 100644 spec/integration/store_spec.rb diff --git a/.ruby_version b/.ruby_version new file mode 100644 index 0000000000..e6b7b62339 --- /dev/null +++ b/.ruby_version @@ -0,0 +1 @@ +2.7 \ No newline at end of file diff --git a/lib/no_brainer/document.rb b/lib/no_brainer/document.rb index bf24de6906..3973fd2cc7 100644 --- a/lib/no_brainer/document.rb +++ b/lib/no_brainer/document.rb @@ -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 diff --git a/lib/no_brainer/document/store.rb b/lib/no_brainer/document/store.rb new file mode 100644 index 0000000000..3788e5412d --- /dev/null +++ b/lib/no_brainer/document/store.rb @@ -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 super + # 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 diff --git a/spec/integration/store_spec.rb b/spec/integration/store_spec.rb new file mode 100644 index 0000000000..5841d82a57 --- /dev/null +++ b/spec/integration/store_spec.rb @@ -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 diff --git a/spec/support/models.rb b/spec/support/models.rb index d21045a20d..c280ec1cbe 100644 --- a/spec/support/models.rb +++ b/spec/support/models.rb @@ -1,3 +1,5 @@ +require 'yaml' + module ModelsHelper def load_simple_document define_class :SimpleDocument do @@ -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