diff --git a/Gemfile b/Gemfile index d89959c..3afd56b 100644 --- a/Gemfile +++ b/Gemfile @@ -2,7 +2,9 @@ source 'https://rubygems.org' -gem 'esse', '~> 0.2.4' +git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } + +gem 'esse', '~> 0.3.0' gem 'sqlite3', '~> 1.7.3' gem 'activerecord', '~> 5.2' gem 'esse-rspec', '~> 0.0.6' diff --git a/Gemfile.lock b/Gemfile.lock index cc50584..70b867d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,9 +1,9 @@ PATH remote: . specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -39,7 +39,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) @@ -122,7 +122,7 @@ GEM standard-performance (1.0.1) lint_roller (~> 1.0) rubocop-performance (~> 1.16.0) - thor (1.3.0) + thor (1.3.1) thread_safe (0.3.6) tzinfo (1.2.11) thread_safe (~> 0.1) @@ -142,7 +142,7 @@ DEPENDENCIES awesome_print dotenv elasticsearch (~> 7.17, >= 7.17.10) - esse (~> 0.2.4) + esse (~> 0.3.0) esse-active_record! esse-rspec (~> 0.0.6) pry diff --git a/README.md b/README.md index 76d2656..4c4b835 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,7 @@ end ### Indexing Callbacks -The `index_callbacks` callback can be used to automaitcally index or delete documents after commit on create/update/destroy events. +The `index_callback` callback can be used to automaitcally index or delete documents after commit on create/update/destroy events. ```ruby class UsersIndex < Esse::Index @@ -173,9 +173,9 @@ class User < ApplicationRecord # Using a index and repository as argument. Note that the index name is used instead of the # of the constant name. it's so because index and model depends on each other should result in # circular dependencies issues. - index_callbacks 'users_index:user' + index_callback 'users_index:user' # Using a block to direct a different object to be indexed - index_callbacks('organizations') { user.organization } # The `_index` suffix and repo name is optional on the index name + index_callback('organizations') { user.organization } # The `_index` suffix and repo name is optional on the index name end ``` @@ -194,7 +194,7 @@ or by some specific list of index or index's repository ```ruby Esse::ActiveRecord::Hooks.disable!(UsersIndex.repo) Esse::ActiveRecord::Hooks.enable!(UsersIndex.repo) -Esse::ActiveRecord::Hooks.without_indexing(AccountsIndex UsersIndex.repo, ) do +Esse::ActiveRecord::Hooks.without_indexing(AccountsIndex, UsersIndex.repo) do 10.times { User.create! } end ``` diff --git a/ci/Gemfile.rails-5.2.lock b/ci/Gemfile.rails-5.2.lock index 1be0d7d..d407f29 100644 --- a/ci/Gemfile.rails-5.2.lock +++ b/ci/Gemfile.rails-5.2.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -39,7 +39,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) diff --git a/ci/Gemfile.rails-6.0.lock b/ci/Gemfile.rails-6.0.lock index c1ddce3..ec3274b 100644 --- a/ci/Gemfile.rails-6.0.lock +++ b/ci/Gemfile.rails-6.0.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -37,7 +37,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) diff --git a/ci/Gemfile.rails-6.1.lock b/ci/Gemfile.rails-6.1.lock index abb1757..0eb93bc 100644 --- a/ci/Gemfile.rails-6.1.lock +++ b/ci/Gemfile.rails-6.1.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -37,7 +37,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) diff --git a/ci/Gemfile.rails-7.0.lock b/ci/Gemfile.rails-7.0.lock index c1ddce3..ec3274b 100644 --- a/ci/Gemfile.rails-7.0.lock +++ b/ci/Gemfile.rails-7.0.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -37,7 +37,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) diff --git a/ci/Gemfile.rails-7.1.lock b/ci/Gemfile.rails-7.1.lock index 90103a0..269a7c4 100644 --- a/ci/Gemfile.rails-7.1.lock +++ b/ci/Gemfile.rails-7.1.lock @@ -1,9 +1,9 @@ PATH remote: .. specs: - esse-active_record (0.2.1) + esse-active_record (0.3.0) activerecord (>= 4.2, < 8) - esse (>= 0.2.3) + esse (>= 0.3.0) GEM remote: https://rubygems.org/ @@ -47,7 +47,7 @@ GEM elasticsearch-transport (7.17.10) faraday (>= 1, < 3) multi_json - esse (0.2.6) + esse (0.3.0) multi_json thor (>= 0.19) esse-rspec (0.0.6) diff --git a/esse-active_record.gemspec b/esse-active_record.gemspec index 2bc5e4d..8eeb045 100644 --- a/esse-active_record.gemspec +++ b/esse-active_record.gemspec @@ -29,7 +29,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ['lib'] - spec.add_dependency 'esse', '>= 0.2.3' + spec.add_dependency 'esse', '>= 0.3.0' spec.add_dependency 'activerecord', '>= 4.2', '< 8' spec.add_development_dependency 'awesome_print' spec.add_development_dependency 'dotenv' diff --git a/lib/esse/active_record.rb b/lib/esse/active_record.rb index d45d81c..60288f2 100644 --- a/lib/esse/active_record.rb +++ b/lib/esse/active_record.rb @@ -3,6 +3,7 @@ require 'esse' require 'active_record' require_relative 'active_record/version' +require_relative 'active_record/callbacks' require_relative 'active_record/model' require_relative 'active_record/hooks' require_relative 'active_record/collection' diff --git a/lib/esse/active_record/callbacks.rb b/lib/esse/active_record/callbacks.rb new file mode 100644 index 0000000..9890341 --- /dev/null +++ b/lib/esse/active_record/callbacks.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Esse + module ActiveRecord + class Callback + attr_reader :repo, :options, :block_result + + def initialize(repo:, block_result: nil, **kwargs) + @repo = repo + @options = kwargs + @block_result = block_result + end + + def call(model) + raise NotImplementedError, 'You must implement #call method' + end + end + + module Callbacks + class << self + def to_h + @callbacks || {}.freeze + end + + def register_callback(identifier, operation, callback_class) + unless callback_class < Esse::ActiveRecord::Callback + raise ArgumentError, 'callback_class must be a subclass of Esse::ActiveRecord::Callback' + end + + key = :"#{identifier}_on_#{operation}" + + @callbacks = @callbacks ? @callbacks.dup : {} + if @callbacks.key?(key) + raise ArgumentError, "callback #{identifier} for #{operation} operation already registered" + end + + @callbacks[key] = callback_class + ensure + @callbacks&.freeze + end + + def registered?(identifier, operation) + return false unless @callbacks + + @callbacks.key?(:"#{identifier}_on_#{operation}") + end + + def fetch!(identifier, operation) + key = :"#{identifier}_on_#{operation}" + if registered?(identifier, operation) + [key, @callbacks[key]] + else + raise ArgumentError, "callback #{identifier} for #{operation} operation not registered" + end + end + end + end + end +end + +require_relative 'callbacks/indexing_on_create' +require_relative 'callbacks/indexing_on_update' +require_relative 'callbacks/indexing_on_destroy' +require_relative 'callbacks/update_lazy_attribute' diff --git a/lib/esse/active_record/callbacks/indexing_on_create.rb b/lib/esse/active_record/callbacks/indexing_on_create.rb new file mode 100644 index 0000000..19b3a0c --- /dev/null +++ b/lib/esse/active_record/callbacks/indexing_on_create.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Esse::ActiveRecord + module Callbacks + class IndexingOnCreate < Callback + def call(model) + record = block_result || model + document = repo.serialize(record) + repo.index.index(document, **options) if document + true + end + end + + register_callback(:indexing, :create, IndexingOnCreate) + end +end diff --git a/lib/esse/active_record/callbacks/indexing_on_destroy.rb b/lib/esse/active_record/callbacks/indexing_on_destroy.rb new file mode 100644 index 0000000..d092b9f --- /dev/null +++ b/lib/esse/active_record/callbacks/indexing_on_destroy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Esse::ActiveRecord + module Callbacks + class IndexingOnDestroy < Callback + def call(model) + record = block_result || model + document = repo.serialize(record) + repo.index.delete(document, **options) if document + true + rescue Esse::Transport::NotFoundError + true + end + end + + register_callback(:indexing, :destroy, IndexingOnDestroy) + end +end diff --git a/lib/esse/active_record/callbacks/indexing_on_update.rb b/lib/esse/active_record/callbacks/indexing_on_update.rb new file mode 100644 index 0000000..ea280eb --- /dev/null +++ b/lib/esse/active_record/callbacks/indexing_on_update.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Esse::ActiveRecord + module Callbacks + class IndexingOnUpdate < Callback + def call(model) + record = block_result || model + + document = repo.serialize(record) + return true unless document + + repo.index.index(document, **options) + return true unless document.routing + + prev_record = model.class.new(model.attributes.merge(model.previous_changes.transform_values(&:first))).tap(&:readonly!) + prev_document = repo.serialize(prev_record) + + return true unless prev_document + return true if [prev_document.id, prev_document.routing].include?(nil) + return true if prev_document.routing == document.routing + return true if prev_document.id != document.id + + begin + repo.index.delete(prev_document, **options) + rescue Esse::Transport::NotFoundError + end + + true + end + end + + register_callback(:indexing, :update, IndexingOnUpdate) + end +end diff --git a/lib/esse/active_record/callbacks/update_lazy_attribute.rb b/lib/esse/active_record/callbacks/update_lazy_attribute.rb new file mode 100644 index 0000000..76ec0d1 --- /dev/null +++ b/lib/esse/active_record/callbacks/update_lazy_attribute.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Esse::ActiveRecord + module Callbacks + class UpdateLazyAttribute < Callback + attr_reader :attribute_name + + def initialize(attribute_name:, **kwargs, &block) + @attribute_name = attribute_name + super(**kwargs, &block) + end + + def call(model) + related_ids = Array(block_result || model.id) + return true if related_ids.empty? + + repo.update_documents_attribute(attribute_name, *related_ids, **options) + + true + end + end + + register_callback(:update_lazy_attribute, :create, UpdateLazyAttribute) + register_callback(:update_lazy_attribute, :update, UpdateLazyAttribute) + register_callback(:update_lazy_attribute, :destroy, UpdateLazyAttribute) + end +end diff --git a/lib/esse/active_record/hooks.rb b/lib/esse/active_record/hooks.rb index 1b0e124..7f9fb58 100644 --- a/lib/esse/active_record/hooks.rb +++ b/lib/esse/active_record/hooks.rb @@ -82,7 +82,7 @@ def disable_model!(model_class, *repos) def ensure_registered_model_class!(model_class) return if registered_model_class?(model_class) - raise ArgumentError, "Model class #{model_class} is not registered. The model should inherit from Esse::ActiveRecord::Model and have a `index_callbacks' callback defined" + raise ArgumentError, "Model class #{model_class} is not registered. The model should inherit from Esse::ActiveRecord::Model and have a `index_callback' callback defined" end # Check if the given model is enabled for indexing. If no repository is specified, all repositories will be checked. @@ -147,7 +147,7 @@ def all_repos # Returns a list of all repositories for the given model # @return [Array] def model_repos(model_class) - expand_index_repos(*model_class.esse_index_repos.keys) + expand_index_repos(*model_class.esse_callbacks.keys) end # Returns a list of all repositories for the given model diff --git a/lib/esse/active_record/model.rb b/lib/esse/active_record/model.rb index eae263c..b484f7c 100644 --- a/lib/esse/active_record/model.rb +++ b/lib/esse/active_record/model.rb @@ -5,91 +5,56 @@ module ActiveRecord module Model extend ActiveSupport::Concern - def self.inherited(subclass) - super - subclass.esse_index_repos = esse_index_repos.dup - end - module ClassMethods - attr_reader :esse_index_repos + extend Esse::Deprecations::Deprecate - # Define callback for create/update/delete elasticsearch index document after model commit. - # - # @param [String] index_repo_name The path of index and repository name. - # For example a index with a single repository named `users` is `users`. And a index with - # multiple repositories named `animals` and `dog` as the repository name is `animals/dog`. - # For namespace, use `/` as the separator. - # @raise [ArgumentError] when the repo and events are already registered - # @raise [ArgumentError] when the specified index have multiple repos - def index_callbacks(index_repo_name, on: %i[create update destroy], **options, &block) - @esse_index_repos ||= {} + def esse_callbacks + @esse_callbacks ||= {}.freeze + end - operation_name = :index - if esse_index_repos.dig(index_repo_name, operation_name) - raise ArgumentError, format('index repository %p already registered %s operation', name: index_repo_name, op: operation_name) - end + def esse_callback(index_repo_name, operation_name, on: %i[create update destroy], **options, &block) + @esse_callbacks = esse_callbacks.dup + if_enabled = -> { Esse::ActiveRecord::Hooks.enabled?(index_repo_name) && Esse::ActiveRecord::Hooks.enabled_for_model?(self.class, index_repo_name) } - esse_index_repos[index_repo_name] ||= {} - esse_index_repos[index_repo_name][operation_name] = { - record: block || -> { self }, - options: options, - } + Array(on).each do |event| + identifier, klass = Esse::ActiveRecord::Callbacks.fetch!(operation_name, event) - Esse::ActiveRecord::Hooks.register_model(self) + if @esse_callbacks.dig(index_repo_name, identifier) + raise ArgumentError, format('index repository %p already registered %s operation', name: index_repo_name, op: operation_name) + end + + @esse_callbacks[index_repo_name] ||= {} + @esse_callbacks[index_repo_name][identifier] = [klass, options, block] - if_enabled = -> { Esse::ActiveRecord::Hooks.enabled?(index_repo_name) && Esse::ActiveRecord::Hooks.enabled_for_model?(self.class, index_repo_name) } - (on & %i[create]).each do |event| after_commit(on: event, if: if_enabled) do - opts = self.class.esse_index_repos.fetch(index_repo_name).fetch(operation_name) - record = opts.fetch(:record) - record = instance_exec(&record) if record.respond_to?(:call) - repo = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name) - document = repo.serialize(record) - repo.index.index(document, **opts[:options]) if document - true + klass, options, block = self.class.esse_callbacks.fetch(index_repo_name).fetch(identifier) + options[:repo] = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name) + options[:block_result] = instance_exec(&block) if block.respond_to?(:call) + instance = klass.new(**options) + instance.call(self) end end - (on & %i[update]).each do |event| - after_commit(on: event, if: if_enabled) do - opts = self.class.esse_index_repos.fetch(index_repo_name).fetch(operation_name) - record = opts.fetch(:record) - record = instance_exec(&record) if record.respond_to?(:call) - repo = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name) - document = repo.serialize(record) - next true unless document - - repo.index.index(document, **opts[:options]) - next true unless document.routing - - prev_record = self.class.new(attributes.merge(previous_changes.transform_values(&:first))).tap(&:readonly!) - prev_document = repo.serialize(prev_record) - next true unless prev_document - next true if [prev_document.id, prev_document.routing].include?(nil) - next true if prev_document.routing == document.routing - next true if prev_document.id != document.id + Esse::ActiveRecord::Hooks.register_model(self) + ensure + @esse_callbacks&.each_value { |v| v.freeze }&.freeze + end - begin - repo.index.delete(prev_document, **opts[:options]) - rescue Esse::Transport::NotFoundError - end + # Define callback for create/update/delete elasticsearch index document after model commit. + # + # @param [String] index_repo_name The path of index and repository name. + # For example a index with a single repository named `users` is `users`. And a index with + # multiple repositories named `animals` and `dog` as the repository name is `animals/dog`. + # For namespace, use `/` as the separator. + # @raise [ArgumentError] when the repo and events are already registered + # @raise [ArgumentError] when the specified index have multiple repos + def index_callback(index_repo_name, on: %i[create update destroy], **options, &block) + esse_callback(index_repo_name, :indexing, on: on, **options, &block) + end - true - end - end - (on & %i[destroy]).each do |event| - after_commit(on: event, if: if_enabled) do - opts = self.class.esse_index_repos.fetch(index_repo_name).fetch(operation_name) - record = opts.fetch(:record) - record = instance_exec(&record) if record.respond_to?(:call) - repo = Esse::ActiveRecord::Hooks.resolve_index_repository(index_repo_name) - document = repo.serialize(record) - repo.index.delete(document, **opts[:options]) if document - true - rescue Esse::Transport::NotFoundError - true - end - end + def update_lazy_attribute_callback(index_repo_name, attribute_name, on: %i[create update destroy], **options, &block) + options[:attribute_name] = attribute_name + esse_callback(index_repo_name, :update_lazy_attribute, on: on, **options, &block) end # Disable indexing for the block execution on model level @@ -101,6 +66,16 @@ def without_indexing(*repos) yield end end + + def index_callbacks(*args, **options, &block) + index_callback(*args, **options, &block) + end + deprecate :index_callbacks, :index_callback, 2024, 12 + + def esse_index_repos + esse_callbacks + end + deprecate :esse_index_repos, :esse_callbacks, 2024, 12 end end end diff --git a/lib/esse/active_record/version.rb b/lib/esse/active_record/version.rb index e07e50e..d05e0ab 100644 --- a/lib/esse/active_record/version.rb +++ b/lib/esse/active_record/version.rb @@ -2,6 +2,6 @@ module Esse module ActiveRecord - VERSION = '0.2.1' + VERSION = '0.3.0' end end diff --git a/spec/esse/active_record/callback_spec.rb b/spec/esse/active_record/callback_spec.rb new file mode 100644 index 0000000..0b63470 --- /dev/null +++ b/spec/esse/active_record/callback_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Callback do + let(:callback_class) do + Class.new(described_class) + end + let(:repo) { double } + + it 'raises an error if #call is not implemented' do + expect { + callback_class.new(repo: repo).call(nil) + }.to raise_error(NotImplementedError, 'You must implement #call method') + end + + it 'has options' do + callback = callback_class.new(repo: repo, foo: 'bar') + expect(callback.options).to eq(foo: 'bar') + end + + it 'has a block result' do + callback = callback_class.new(repo: repo, block_result: 'result') + expect(callback.block_result).to eq('result') + end +end diff --git a/spec/esse/active_record/callbacks/update_lazy_attribute_spec.rb b/spec/esse/active_record/callbacks/update_lazy_attribute_spec.rb new file mode 100644 index 0000000..de7a872 --- /dev/null +++ b/spec/esse/active_record/callbacks/update_lazy_attribute_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Callbacks::UpdateLazyAttribute do + let(:repo) { instance_double(Esse::Repository) } + + describe '.initialize' do + it 'sets attribute_name' do + callback = described_class.new(repo: repo, attribute_name: :foo) + expect(callback.attribute_name).to eq(:foo) + end + + it 'sets options' do + callback = described_class.new(repo: repo, attribute_name: :foo, foo: :bar) + expect(callback.options).to eq(foo: :bar) + end + end + + describe '.call' do + let(:ok_response) { { 'result' => 'indexed' } } + let(:county_class) do + Class.new(County) do + include Esse::ActiveRecord::Model + update_lazy_attribute_callback 'states:state', :total_counties do + state_id + end + end + end + + before do + stub_cluster_info + stub_esse_index(:states) do + repository :state, const: true do + document do |state, **| + { + _id: state.id, + name: state.name, + } + end + lazy_document_attribute :total_counties do |docs| + ::County.where(state_id: docs.map(&:id)).group(:state_id).count + end + end + end + end + + after do + clean_db + end + + it 'bulk update the state :total_counties attribute when the county is created' do + state = create_record(State, name: 'Illinois') + county = build_record(county_class, name: 'Cook', state: state) + expect(StatesIndex::State).to receive(:update_documents_attribute).with(:total_counties, state.id, **{}).and_call_original + expect(StatesIndex).to esse_receive_request(:bulk).with( + index: StatesIndex.index_name, + body: [ + { update: { _id: state.id, data: { doc: { total_counties: 1 } } } } + ] + ).and_return(ok_response) + + county.save! + end + end +end diff --git a/spec/esse/active_record/callbacks_spec.rb b/spec/esse/active_record/callbacks_spec.rb new file mode 100644 index 0000000..3dd59e7 --- /dev/null +++ b/spec/esse/active_record/callbacks_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Callbacks do + before do + @__callbacks = described_class.instance_variable_get(:@callbacks) + described_class.instance_variable_set(:@callbacks, nil) + end + + after do + described_class.instance_variable_set(:@callbacks, @__callbacks) # rubocop:disable RSpec/InstanceVariable + end + + describe '.to_h' do + it 'returns an empty hash' do + expect(described_class.to_h).to eq({}) + end + + it 'returns a frozen hash' do + expect(described_class.to_h).to be_frozen + end + end + + describe '.register_callback' do + it 'registers a callback' do + expect { + described_class.register_callback(:external, :create, Class.new(Esse::ActiveRecord::Callback)) + }.to change { described_class.to_h.size }.by(1) + expect(described_class.to_h).to be_frozen + end + + it 'raises an error if the callback is already registered' do + described_class.register_callback(:external, :create, Class.new(Esse::ActiveRecord::Callback)) + expect { + described_class.register_callback(:external, :create, Class.new(Esse::ActiveRecord::Callback)) + }.to raise_error(ArgumentError, 'callback external for create operation already registered') + expect(described_class.to_h).to be_frozen + end + end + + describe '.registered?' do + it 'returns false if the callback is not registered' do + expect(described_class.registered?(:external, :create)).to be(false) + end + + it 'returns true if the callback is registered' do + described_class.register_callback(:external, :create, Class.new(Esse::ActiveRecord::Callback)) + expect(described_class.registered?(:external, :create)).to be(true) + end + end + + describe '.fetch!' do + it 'raises an error if the callback is not registered' do + expect { + described_class.fetch!(:external, :create) + }.to raise_error(ArgumentError, 'callback external for create operation not registered') + end + + it 'returns the callback class' do + klass = Class.new(Esse::ActiveRecord::Callback) + described_class.register_callback(:external, :create, klass) + expect(described_class.fetch!(:external, :create)).to eq([:external_on_create, klass]) + end + end +end diff --git a/spec/esse/active_record/hooks_spec.rb b/spec/esse/active_record/hooks_spec.rb index a105768..720e16d 100644 --- a/spec/esse/active_record/hooks_spec.rb +++ b/spec/esse/active_record/hooks_spec.rb @@ -6,7 +6,7 @@ include Esse::ActiveRecord::Model Esse::ActiveRecord::Hooks.register_model(self) - instance_variable_set(:@esse_index_repos, { + instance_variable_set(:@esse_callbacks, { AnimalsIndex::Cat => {}, AnimalsIndex::Dog => {}, }) @@ -18,7 +18,7 @@ include Esse::ActiveRecord::Model Esse::ActiveRecord::Hooks.register_model(self) - instance_variable_set(:@esse_index_repos, { + instance_variable_set(:@esse_callbacks, { UsersIndex::User => {}, }) end diff --git a/spec/esse/active_record/model/deprecations_for_esse_index_repos_spec.rb b/spec/esse/active_record/model/deprecations_for_esse_index_repos_spec.rb new file mode 100644 index 0000000..7ff2307 --- /dev/null +++ b/spec/esse/active_record/model/deprecations_for_esse_index_repos_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Model, '.esse_index_repos' do + specify do + Gem::Deprecate.skip_during do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states_index' + end + expect(model_class.esse_index_repos.keys).to include('states_index') + end + end +end diff --git a/spec/esse/active_record/model/deprecations_for_index_callbacks_spec.rb b/spec/esse/active_record/model/deprecations_for_index_callbacks_spec.rb new file mode 100644 index 0000000..b7722a4 --- /dev/null +++ b/spec/esse/active_record/model/deprecations_for_index_callbacks_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Model, '.index_callbacks' do + specify do + Gem::Deprecate.skip_during do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callbacks 'states_index', on: %i[create] + end + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + end +end diff --git a/spec/esse/active_record/model/esse_callback_spec.rb b/spec/esse/active_record/model/esse_callback_spec.rb new file mode 100644 index 0000000..57ed9ac --- /dev/null +++ b/spec/esse/active_record/model/esse_callback_spec.rb @@ -0,0 +1,222 @@ +require 'spec_helper' + +class DummyCallbackRepo + def self.add(*vals) + Thread.current[:dummy_callback_repo] ||= Set.new + Thread.current[:dummy_callback_repo].add(*vals) + end + + def self.all + (Thread.current[:dummy_callback_repo] || []).to_a + end + + def self.clear + Thread.current[:dummy_callback_repo] = nil + end +end + +class DumpTempCallback < Esse::ActiveRecord::Callback + def call(model) + DummyCallbackRepo.add [model, options, block_result] + end +end + +class DumpTempCallbackOnCreate < DumpTempCallback; end + +class DumpTempCallbackOnUpdate < DumpTempCallback; end + +class DumpTempCallbackOnDestroy < DumpTempCallback; end + +RSpec.describe Esse::ActiveRecord::Model, '.esse_callback' do + let(:model_class) do + Class.new(State) do + include Esse::ActiveRecord::Model + end + end + + before do + DummyCallbackRepo.clear + Thread.current[Esse::ActiveRecord::Hooks::STORE_STATE_KEY] = nil + @__callbacks = Esse::ActiveRecord::Callbacks.instance_variable_get(:@callbacks) + Esse::ActiveRecord::Callbacks.register_callback(:temp, :create, DumpTempCallbackOnCreate) + Esse::ActiveRecord::Callbacks.register_callback(:temp, :update, DumpTempCallbackOnUpdate) + Esse::ActiveRecord::Callbacks.register_callback(:temp, :destroy, DumpTempCallbackOnDestroy) + @__hooks_models = Esse::ActiveRecord::Hooks.models.dup + Esse::ActiveRecord::Hooks.models.clear + stub_cluster_info + + stub_esse_index(:states) do + repository :state, const: true do + document do |state, **| + { + _id: state.id, + name: state.name, + } + end + end + end + end + + after do + Esse::ActiveRecord::Hooks.instance_variable_set(:@models, @__hooks_models) # rubocop:disable RSpec/InstanceVariable + Esse::ActiveRecord::Callbacks.instance_variable_set(:@callbacks, @__callbacks) # rubocop:disable RSpec/InstanceVariable + clean_db + end + + describe '.esse_callbacks' do + it 'returns an empty hash' do + expect(model_class.esse_callbacks).to eq({}) + end + + it 'returns a frozen hash' do + expect(model_class.esse_callbacks).to be_frozen + end + end + + context 'when on :create' do + it 'raises an error when the callback is not registered' do + expect { + model_class.esse_callback 'states:state', :missing_callback, on: %i[create] + }.to raise_error(ArgumentError).with_message(/callback missing_callback for create operation not registered/) + end + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.esse_callback 'states:state', :temp, on: %i[create] + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.esse_callback('states:state', :temp, on: %i[create], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + temp_on_create: contain_exactly(DumpTempCallbackOnCreate, {custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + + it 'calls the callback with the model instance' do + model_class.esse_callback('states:state', :temp, on: %i[create]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + expect(DummyCallbackRepo.all).to include([model, {}, nil]) + end + + it 'does not call the callback when the hooks are globally disabled' do + model_class.esse_callback('states:state', :temp, on: %i[create]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + Esse::ActiveRecord::Hooks.without_indexing do + model.save + end + expect(DummyCallbackRepo.all).to be_empty + end + + it 'does not call the callback when the hooks are disabled for the model' do + model_class.esse_callback('states:state', :temp, on: %i[create]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model_class.without_indexing do + model.save + end + expect(DummyCallbackRepo.all).to be_empty + end + end + + context 'when on :update' do + it 'raises an error when the callback is not registered' do + expect { + model_class.esse_callback 'states:state', :missing_callback, on: %i[update] + }.to raise_error(ArgumentError).with_message(/callback missing_callback for update operation not registered/) + end + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.esse_callback 'states:state', :temp, on: %i[update] + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.esse_callback('states:state', :temp, on: %i[update], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + temp_on_update: contain_exactly(DumpTempCallbackOnUpdate, {custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + + it 'calls the callback with the model instance' do + model_class.esse_callback('states:state', :temp, on: %i[update]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + model.update(name: 'New Illinois') + expect(DummyCallbackRepo.all).to include([model, {}, nil]) + end + + it 'does not call the callback when the hooks are globally disabled' do + model_class.esse_callback('states:state', :temp, on: %i[update]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + Esse::ActiveRecord::Hooks.without_indexing do + model.update(name: 'New Illinois') + end + expect(DummyCallbackRepo.all).to be_empty + end + + it 'does not call the callback when the hooks are disabled for the model' do + model_class.esse_callback('states:state', :temp, on: %i[update]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + model_class.without_indexing do + model.update(name: 'New Illinois') + end + expect(DummyCallbackRepo.all).to be_empty + end + end + + context 'when on :destroy' do + it 'raises an error when the callback is not registered' do + expect { + model_class.esse_callback 'states:state', :missing_callback, on: %i[destroy] + }.to raise_error(ArgumentError).with_message(/callback missing_callback for destroy operation not registered/) + end + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.esse_callback 'states:state', :temp, on: %i[destroy] + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.esse_callback('states:state', :temp, on: %i[destroy], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + temp_on_destroy: contain_exactly(DumpTempCallbackOnDestroy, {custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + + it 'calls the callback with the model instance' do + model_class.esse_callback('states:state', :temp, on: %i[destroy]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + model.destroy + expect(DummyCallbackRepo.all).to include([model, {}, nil]) + end + + it 'does not call the callback when the hooks are globally disabled' do + model_class.esse_callback('states:state', :temp, on: %i[destroy]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + Esse::ActiveRecord::Hooks.without_indexing do + model.destroy + end + expect(DummyCallbackRepo.all).to be_empty + end + + it 'does not call the callback when the hooks are disabled for the model' do + model_class.esse_callback('states:state', :temp, on: %i[destroy]) + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + model.save + model_class.without_indexing do + model.destroy + end + expect(DummyCallbackRepo.all).to be_empty + end + end +end diff --git a/spec/esse/active_record/model/index_callback_spec.rb b/spec/esse/active_record/model/index_callback_spec.rb new file mode 100644 index 0000000..8a58324 --- /dev/null +++ b/spec/esse/active_record/model/index_callback_spec.rb @@ -0,0 +1,405 @@ +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Model, '.index_callback' do + let(:backend_proxy) { double } + + before do + Thread.current[Esse::ActiveRecord::Hooks::STORE_STATE_KEY] = nil + @models_value_backup = Esse::ActiveRecord::Hooks.models.dup + Esse::ActiveRecord::Hooks.models.clear + stub_cluster_info + + stub_esse_index(:states) do + repository :state, const: true do + document do |state, **| + { + _id: state.id, + name: state.name, + } + end + end + end + + stub_esse_index(:geographies) do + repository :state, const: true do + document do |state, **| + { + _id: state.id, + name: state.name, + type: 'state', + } + end + end + + repository :county, const: true do + document do |county, **| + { + _id: county.id, + name: county.name, + type: 'county', + routing: county.state_id || 1, + }.tap do |doc| + doc[:state] = { id: county.state.id, name: county.state.name } if county.state + end + end + end + end + end + + after do + Esse::ActiveRecord::Hooks.instance_variable_set(:@models, @models_value_backup) # rubocop:disable RSpec/InstanceVariable + clean_db + end + + describe '.index_callback' do + context 'when on :create' do + let(:index_ok_response) { { 'result' => 'indexed' } } + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states_index', on: %i[create] + end + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'index the model on create' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states_index', on: %i[create] + end + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + + expect(StatesIndex).to receive(:index).and_call_original + expect(StatesIndex).to esse_receive_request(:index).with( + id: model.id, + index: StatesIndex.index_name, + body: {name: 'Illinois'}, + ).and_return(index_ok_response) + + model.save + end + + it 'index the associated model using the block definition' do + model_class = Class.new(County) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[create] do + state + end + end + state = build_record(State, name: 'Illinois', id: SecureRandom.uuid) + county = build_record(model_class, name: 'Cook', state: state) + + expect(GeographiesIndex).to receive(:index).and_call_original + expect(GeographiesIndex).to esse_receive_request(:index).with( + id: state.id, + index: GeographiesIndex.index_name, + body: {name: 'Illinois', type: 'state'}, + ).and_return(index_ok_response) + + county.save + end + + it 'does not index when the hooks are globally disabled' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[create] + end + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + + expect(GeographiesIndex).not_to receive(:index) + Esse::ActiveRecord::Hooks.without_indexing do + model.save + end + end + + it 'does not index when the hooks are disabled for the model' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[create] + end + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + expect(GeographiesIndex).not_to receive(:index) + model_class.without_indexing do + model.save + end + end + + it 'allows to select which indices will not execute indexing callbacks' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states', on: %i[create] + index_callback 'geographies:state', on: %i[create] + end + model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) + expect(GeographiesIndex).not_to receive(:index) + expect(StatesIndex).to receive(:index).and_call_original + expect(StatesIndex).to esse_receive_request(:index).with( + id: model.id, + index: StatesIndex.index_name, + body: {name: 'Illinois'}, + ).and_return(index_ok_response) + model_class.without_indexing(GeographiesIndex) do + model.save + end + end + end + + context 'when on :update' do + let(:index_ok_response) { { 'result' => 'indexed' } } + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states_index', on: %i[update] + end + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'index the model on update' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states_index', on: %i[update] + end + model = create_record(model_class, name: 'Illinois') + + expect(StatesIndex).to receive(:index).and_call_original + expect(StatesIndex).to esse_receive_request(:index).with( + id: model.id, + index: StatesIndex.index_name, + body: {name: 'Illinois'}, + ).and_return(index_ok_response) + + model.touch + end + + it 'index the associated model using the block definition' do + model_class = Class.new(County) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[update] do + state + end + end + state = create_record(State, name: 'Illinois') + county = create_record(model_class, name: 'Cook', state: state) + + expect(GeographiesIndex).to receive(:index).and_call_original + expect(GeographiesIndex).to esse_receive_request(:index).with( + id: state.id, + index: GeographiesIndex.index_name, + body: {name: 'Illinois', type: 'state'}, + ).and_return(index_ok_response) + + county.touch + end + + it 'does not index when the hooks are globally disabled' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[update] + end + model = create_record(model_class, name: 'Illinois') + + expect(GeographiesIndex).not_to receive(:index) + Esse::ActiveRecord::Hooks.without_indexing do + model.touch + end + end + + it 'does not index when the hooks are disabled for the model' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[update] + end + model = create_record(model_class, name: 'Illinois') + expect(GeographiesIndex).not_to receive(:index) + model_class.without_indexing do + model.touch + end + end + + it 'allows to select which indices will not execute indexing callbacks' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states', on: %i[update] + index_callback 'geographies:state', on: %i[update] + end + model = create_record(model_class, name: 'Illinois') + expect(GeographiesIndex).not_to receive(:index) + expect(StatesIndex).to receive(:index).and_call_original + expect(StatesIndex).to esse_receive_request(:index).with( + id: model.id, + index: StatesIndex.index_name, + body: {name: 'Illinois'}, + ).and_return(index_ok_response) + model_class.without_indexing(GeographiesIndex) do + model.touch + end + end + end + + context 'when on :update with a the document that has a routing key' do + let(:index_ok_response) { { 'result' => 'indexed' } } + let(:model_class) do + Class.new(County) do + include Esse::ActiveRecord::Model + index_callback 'geographies:county', on: %i[update] + end + end + let(:il) { create_record(State, name: 'Illinois') } + let(:ny) { create_record(State, name: 'New York') } + let(:county) { create_record(model_class, name: 'Cook', state: il) } + + it 'indexes the document in new routing and deletes the document from previous routing' do + expect(GeographiesIndex).to receive(:index).and_call_original + expect(GeographiesIndex).to esse_receive_request(:index).with( + id: county.id, + index: GeographiesIndex.index_name, + routing: ny.id, + body: {name: 'Cook', type: 'county', state: { id: ny.id, name: ny.name }}, + ).and_return(index_ok_response) + + expect(GeographiesIndex).to receive(:delete).and_call_original + expect(GeographiesIndex).to esse_receive_request(:delete).with( + id: county.id, + index: GeographiesIndex.index_name, + routing: il.id, + ).and_return('result' => 'deleted') + + county.update(state: ny) + end + + it 'does not delete the document when the routing key is not changed' do + expect(GeographiesIndex).to receive(:index).and_call_original + expect(GeographiesIndex).to esse_receive_request(:index).with( + id: county.id, + index: GeographiesIndex.index_name, + routing: il.id, + body: {name: 'Cook County', type: 'county', state: { id: il.id, name: il.name }}, + ).and_return(index_ok_response) + + expect(GeographiesIndex).not_to receive(:delete) + + county.update(name: 'Cook County') + end + + it 'does not raise error when the document does not exist' do + expect(GeographiesIndex).to receive(:index).and_call_original + expect(GeographiesIndex).to esse_receive_request(:index).with( + id: county.id, + index: GeographiesIndex.index_name, + routing: ny.id, + body: {name: 'Cook', type: 'county', state: { id: ny.id, name: ny.name }}, + ).and_return(index_ok_response) + + expect(GeographiesIndex).to receive(:delete).and_call_original + expect(GeographiesIndex).to esse_receive_request(:delete).with( + id: county.id, + index: GeographiesIndex.index_name, + routing: il.id, + ).and_raise_http_status(404, { 'error' => { 'type' => 'not_found' } }) + + county.update(state: ny) + end + end + + context 'when on destroy' do + let(:delete_ok_response) { { 'result' => 'deleted' } } + + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states', on: %i[destroy] + end + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'removes the document on destroy' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states', on: %i[destroy] + end + model = create_record(model_class, name: 'Illinois') + expect(StatesIndex).to receive(:delete).and_call_original + expect(StatesIndex).to esse_receive_request(:delete).with( + id: model.id, + ).and_return(delete_ok_response) + model.destroy + end + + it 'does not raise error when the document does not exist' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[destroy] + end + model = create_record(model_class, name: 'Illinois') + expect(GeographiesIndex).to receive(:delete).and_call_original + expect(GeographiesIndex).to esse_receive_request(:delete).with( + id: model.id, + ).and_raise_http_status(404, { 'error' => { 'type' => 'not_found' } }) + expect { model.destroy }.not_to raise_error + end + + it 'removes the associated model using the block definition' do + model_class = Class.new(County) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[destroy] do + state + end + end + state = create_record(State, name: 'Illinois') + county = create_record(model_class, name: 'Cook', state: state) + expect(GeographiesIndex).to receive(:delete).and_call_original + expect(GeographiesIndex).to esse_receive_request(:delete).with( + id: state.id, + ).and_return(delete_ok_response) + county.destroy + end + + it 'does not perform delete request when the hooks are globally disabled' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[destroy] + end + model = create_record(model_class, name: 'Illinois') + + expect(GeographiesIndex).not_to receive(:delete) + Esse::ActiveRecord::Hooks.without_indexing do + model.destroy + end + end + + it 'does not perform delete request when the hooks are disabled for the model' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'geographies:state', on: %i[destroy] + end + model = create_record(model_class, name: 'Illinois') + + expect(GeographiesIndex).not_to receive(:delete) + model_class.without_indexing do + model.destroy + end + end + + it 'allows to select which indices will NOT perform :delete request during callbacks' do + model_class = Class.new(State) do + include Esse::ActiveRecord::Model + index_callback 'states:state', on: %i[destroy] + index_callback 'geographies:state', on: %i[destroy] + end + model = create_record(model_class, name: 'Illinois') + + expect(GeographiesIndex).not_to receive(:delete) + expect(StatesIndex).to receive(:delete).and_call_original + expect(StatesIndex).to esse_receive_request(:delete).with( + id: model.id, + ).and_return(delete_ok_response) + + model_class.without_indexing(GeographiesIndex) do + model.destroy + end + end + end + end +end diff --git a/spec/esse/active_record/model/update_lazy_attribute_callback_spec.rb b/spec/esse/active_record/model/update_lazy_attribute_callback_spec.rb new file mode 100644 index 0000000..8a5dc9d --- /dev/null +++ b/spec/esse/active_record/model/update_lazy_attribute_callback_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +RSpec.describe Esse::ActiveRecord::Model, '.update_lazy_attribute_callback' do + let(:model_class) do + Class.new(State) do + include Esse::ActiveRecord::Model + end + end + + before do + Thread.current[Esse::ActiveRecord::Hooks::STORE_STATE_KEY] = nil + + @__hooks_models = Esse::ActiveRecord::Hooks.models.dup + Esse::ActiveRecord::Hooks.models.clear + stub_cluster_info + + stub_esse_index(:states) do + repository :state, const: true do + document do |state, **| + { + _id: state.id, + name: state.name, + } + end + end + end + end + + after do + Esse::ActiveRecord::Hooks.instance_variable_set(:@models, @__hooks_models) # rubocop:disable RSpec/InstanceVariable + clean_db + end + + it 'register the callback with multiple events' do + model_class.update_lazy_attribute_callback('states:state', :field, custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + update_lazy_attribute_on_create: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + update_lazy_attribute_on_update: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + update_lazy_attribute_on_destroy: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + + context 'when on :create' do + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.update_lazy_attribute_callback 'states:state', :field, on: %i[create] + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.update_lazy_attribute_callback('states:state', :field, on: %i[create], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + update_lazy_attribute_on_create: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + end + + context 'when on :update' do + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.update_lazy_attribute_callback('states:state', :field, on: %i[update]) + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.update_lazy_attribute_callback('states:state', :field, on: %i[update], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + update_lazy_attribute_on_update: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + end + + context 'when on :destroy' do + it 'register the model class into Esse::ActiveRecord::Hooks.models' do + model_class.update_lazy_attribute_callback('states:state', :field, on: %i[destroy]) + expect(Esse::ActiveRecord::Hooks.models).to include(model_class) + end + + it 'register the callback with block definition and custom options' do + model_class.update_lazy_attribute_callback('states:state', :field, on: %i[destroy], custom: 'value') { :ok } + expect(model_class.esse_callbacks).to a_hash_including( + 'states:state' => a_hash_including( + update_lazy_attribute_on_destroy: contain_exactly(Esse::ActiveRecord::Callbacks::UpdateLazyAttribute, { attribute_name: :field, custom: 'value'}, an_instance_of(Proc)), + ) + ) + end + end +end diff --git a/spec/esse/active_record/model_spec.rb b/spec/esse/active_record/model_spec.rb index ca69b70..164a00c 100644 --- a/spec/esse/active_record/model_spec.rb +++ b/spec/esse/active_record/model_spec.rb @@ -1,405 +1,17 @@ require 'spec_helper' RSpec.describe Esse::ActiveRecord::Model do - let(:backend_proxy) { double } - - before do - Thread.current[Esse::ActiveRecord::Hooks::STORE_STATE_KEY] = nil - @models_value_backup = Esse::ActiveRecord::Hooks.models.dup - Esse::ActiveRecord::Hooks.models.clear - stub_cluster_info - - stub_esse_index(:states) do - repository :state, const: true do - document do |state, **| - { - _id: state.id, - name: state.name, - } - end - end - end - - stub_esse_index(:geographies) do - repository :state, const: true do - document do |state, **| - { - _id: state.id, - name: state.name, - type: 'state', - } - end - end - - repository :county, const: true do - document do |county, **| - { - _id: county.id, - name: county.name, - type: 'county', - routing: county.state_id || 1, - }.tap do |doc| - doc[:state] = { id: county.state.id, name: county.state.name } if county.state - end - end - end + let(:model) do + Class.new(State) do + include Esse::ActiveRecord::Model end end - after do - Esse::ActiveRecord::Hooks.instance_variable_set(:@models, @models_value_backup) # rubocop:disable RSpec/InstanceVariable - clean_db + it 'responds to .index_callback' do + expect(model).to respond_to(:index_callback) end - describe '.index_callbacks' do - context 'when on :create' do - let(:index_ok_response) { { 'result' => 'indexed' } } - - it 'register the model class into Esse::ActiveRecord::Hooks.models' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states_index', on: %i[create] - end - expect(Esse::ActiveRecord::Hooks.models).to include(model_class) - end - - it 'index the model on create' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states_index', on: %i[create] - end - model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) - - expect(StatesIndex).to receive(:index).and_call_original - expect(StatesIndex).to esse_receive_request(:index).with( - id: model.id, - index: StatesIndex.index_name, - body: {name: 'Illinois'}, - ).and_return(index_ok_response) - - model.save - end - - it 'index the associated model using the block definition' do - model_class = Class.new(County) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[create] do - state - end - end - state = build_record(State, name: 'Illinois', id: SecureRandom.uuid) - county = build_record(model_class, name: 'Cook', state: state) - - expect(GeographiesIndex).to receive(:index).and_call_original - expect(GeographiesIndex).to esse_receive_request(:index).with( - id: state.id, - index: GeographiesIndex.index_name, - body: {name: 'Illinois', type: 'state'}, - ).and_return(index_ok_response) - - county.save - end - - it 'does not index when the hooks are globally disabled' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[create] - end - model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) - - expect(GeographiesIndex).not_to receive(:index) - Esse::ActiveRecord::Hooks.without_indexing do - model.save - end - end - - it 'does not index when the hooks are disabled for the model' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[create] - end - model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) - expect(GeographiesIndex).not_to receive(:index) - model_class.without_indexing do - model.save - end - end - - it 'allows to select which indices will not execute indexing callbacks' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states', on: %i[create] - index_callbacks 'geographies:state', on: %i[create] - end - model = build_record(model_class, name: 'Illinois', id: SecureRandom.uuid) - expect(GeographiesIndex).not_to receive(:index) - expect(StatesIndex).to receive(:index).and_call_original - expect(StatesIndex).to esse_receive_request(:index).with( - id: model.id, - index: StatesIndex.index_name, - body: {name: 'Illinois'}, - ).and_return(index_ok_response) - model_class.without_indexing(GeographiesIndex) do - model.save - end - end - end - - context 'when on :update' do - let(:index_ok_response) { { 'result' => 'indexed' } } - - it 'register the model class into Esse::ActiveRecord::Hooks.models' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states_index', on: %i[update] - end - expect(Esse::ActiveRecord::Hooks.models).to include(model_class) - end - - it 'index the model on update' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states_index', on: %i[update] - end - model = create_record(model_class, name: 'Illinois') - - expect(StatesIndex).to receive(:index).and_call_original - expect(StatesIndex).to esse_receive_request(:index).with( - id: model.id, - index: StatesIndex.index_name, - body: {name: 'Illinois'}, - ).and_return(index_ok_response) - - model.touch - end - - it 'index the associated model using the block definition' do - model_class = Class.new(County) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[update] do - state - end - end - state = create_record(State, name: 'Illinois') - county = create_record(model_class, name: 'Cook', state: state) - - expect(GeographiesIndex).to receive(:index).and_call_original - expect(GeographiesIndex).to esse_receive_request(:index).with( - id: state.id, - index: GeographiesIndex.index_name, - body: {name: 'Illinois', type: 'state'}, - ).and_return(index_ok_response) - - county.touch - end - - it 'does not index when the hooks are globally disabled' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[update] - end - model = create_record(model_class, name: 'Illinois') - - expect(GeographiesIndex).not_to receive(:index) - Esse::ActiveRecord::Hooks.without_indexing do - model.touch - end - end - - it 'does not index when the hooks are disabled for the model' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[update] - end - model = create_record(model_class, name: 'Illinois') - expect(GeographiesIndex).not_to receive(:index) - model_class.without_indexing do - model.touch - end - end - - it 'allows to select which indices will not execute indexing callbacks' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states', on: %i[update] - index_callbacks 'geographies:state', on: %i[update] - end - model = create_record(model_class, name: 'Illinois') - expect(GeographiesIndex).not_to receive(:index) - expect(StatesIndex).to receive(:index).and_call_original - expect(StatesIndex).to esse_receive_request(:index).with( - id: model.id, - index: StatesIndex.index_name, - body: {name: 'Illinois'}, - ).and_return(index_ok_response) - model_class.without_indexing(GeographiesIndex) do - model.touch - end - end - end - - context 'when on :update with a the document that has a routing key' do - let(:index_ok_response) { { 'result' => 'indexed' } } - let(:model_class) do - Class.new(County) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:county', on: %i[update] - end - end - let(:il) { create_record(State, name: 'Illinois') } - let(:ny) { create_record(State, name: 'New York') } - let(:county) { create_record(model_class, name: 'Cook', state: il) } - - it 'indexes the document in new routing and deletes the document from previous routing' do - expect(GeographiesIndex).to receive(:index).and_call_original - expect(GeographiesIndex).to esse_receive_request(:index).with( - id: county.id, - index: GeographiesIndex.index_name, - routing: ny.id, - body: {name: 'Cook', type: 'county', state: { id: ny.id, name: ny.name }}, - ).and_return(index_ok_response) - - expect(GeographiesIndex).to receive(:delete).and_call_original - expect(GeographiesIndex).to esse_receive_request(:delete).with( - id: county.id, - index: GeographiesIndex.index_name, - routing: il.id, - ).and_return('result' => 'deleted') - - county.update(state: ny) - end - - it 'does not delete the document when the routing key is not changed' do - expect(GeographiesIndex).to receive(:index).and_call_original - expect(GeographiesIndex).to esse_receive_request(:index).with( - id: county.id, - index: GeographiesIndex.index_name, - routing: il.id, - body: {name: 'Cook County', type: 'county', state: { id: il.id, name: il.name }}, - ).and_return(index_ok_response) - - expect(GeographiesIndex).not_to receive(:delete) - - county.update(name: 'Cook County') - end - - it 'does not raise error when the document does not exist' do - expect(GeographiesIndex).to receive(:index).and_call_original - expect(GeographiesIndex).to esse_receive_request(:index).with( - id: county.id, - index: GeographiesIndex.index_name, - routing: ny.id, - body: {name: 'Cook', type: 'county', state: { id: ny.id, name: ny.name }}, - ).and_return(index_ok_response) - - expect(GeographiesIndex).to receive(:delete).and_call_original - expect(GeographiesIndex).to esse_receive_request(:delete).with( - id: county.id, - index: GeographiesIndex.index_name, - routing: il.id, - ).and_raise_http_status(404, { 'error' => { 'type' => 'not_found' } }) - - county.update(state: ny) - end - end - - context 'when on destroy' do - let(:delete_ok_response) { { 'result' => 'deleted' } } - - it 'register the model class into Esse::ActiveRecord::Hooks.models' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states', on: %i[destroy] - end - expect(Esse::ActiveRecord::Hooks.models).to include(model_class) - end - - it 'removes the document on destroy' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states', on: %i[destroy] - end - model = create_record(model_class, name: 'Illinois') - expect(StatesIndex).to receive(:delete).and_call_original - expect(StatesIndex).to esse_receive_request(:delete).with( - id: model.id, - ).and_return(delete_ok_response) - model.destroy - end - - it 'does not raise error when the document does not exist' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[destroy] - end - model = create_record(model_class, name: 'Illinois') - expect(GeographiesIndex).to receive(:delete).and_call_original - expect(GeographiesIndex).to esse_receive_request(:delete).with( - id: model.id, - ).and_raise_http_status(404, { 'error' => { 'type' => 'not_found' } }) - expect { model.destroy }.not_to raise_error - end - - it 'removes the associated model using the block definition' do - model_class = Class.new(County) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[destroy] do - state - end - end - state = create_record(State, name: 'Illinois') - county = create_record(model_class, name: 'Cook', state: state) - expect(GeographiesIndex).to receive(:delete).and_call_original - expect(GeographiesIndex).to esse_receive_request(:delete).with( - id: state.id, - ).and_return(delete_ok_response) - county.destroy - end - - it 'does not perform delete request when the hooks are globally disabled' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[destroy] - end - model = create_record(model_class, name: 'Illinois') - - expect(GeographiesIndex).not_to receive(:delete) - Esse::ActiveRecord::Hooks.without_indexing do - model.destroy - end - end - - it 'does not perform delete request when the hooks are disabled for the model' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'geographies:state', on: %i[destroy] - end - model = create_record(model_class, name: 'Illinois') - - expect(GeographiesIndex).not_to receive(:delete) - model_class.without_indexing do - model.destroy - end - end - - it 'allows to select which indices will NOT perform :delete request during callbacks' do - model_class = Class.new(State) do - include Esse::ActiveRecord::Model - index_callbacks 'states:state', on: %i[destroy] - index_callbacks 'geographies:state', on: %i[destroy] - end - model = create_record(model_class, name: 'Illinois') - - expect(GeographiesIndex).not_to receive(:delete) - expect(StatesIndex).to receive(:delete).and_call_original - expect(StatesIndex).to esse_receive_request(:delete).with( - id: model.id, - ).and_return(delete_ok_response) - - model_class.without_indexing(GeographiesIndex) do - model.destroy - end - end - end + it 'responds to .esse_callback' do + expect(model).to respond_to(:esse_callback) end end