diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0560e13..e5349c3 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: "3.1" + ruby-version: "3.3" bundler-cache: true - name: Run Linter run: bundle exec ci-helper RubocopLint @@ -43,7 +43,7 @@ jobs: strategy: fail-fast: false matrix: - ruby: ["2.7", "3.0"] + ruby: ["3.1", "3.2"] experimental: [false] include: - ruby: head diff --git a/.rubocop.yml b/.rubocop.yml index e6b375e..c1a4a51 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -3,7 +3,7 @@ inherit_gem: AllCops: DisplayCopNames: true - TargetRubyVersion: 3.2 + TargetRubyVersion: 3.1 Naming/MethodParameterName: AllowedNames: ["x", "y", "z"] diff --git a/Gemfile b/Gemfile index be40a33..6e80bcd 100644 --- a/Gemfile +++ b/Gemfile @@ -6,12 +6,13 @@ gemspec gem "bundler-audit" gem "ci-helper" -gem "dry-initializer" gem "pry" +gem "qonfig" gem "rake" gem "rspec" gem "rubocop-config-umbrellio" gem "simplecov" gem "simplecov-lcov" + +gem "dry-initializer" gem "smart_initializer" -gem "qonfig", "0.28.0" diff --git a/Gemfile.lock b/Gemfile.lock index e0123aa..39a63fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,13 +2,11 @@ PATH remote: . specs: resol (1.0.0) - dry-initializer (~> 3.1) - smart_initializer (~> 0.7) GEM remote: https://rubygems.org/ specs: - activesupport (8.0.1) + activesupport (7.2.2.1) base64 benchmark (>= 0.3) bigdecimal @@ -20,7 +18,6 @@ GEM minitest (>= 5.1) securerandom (>= 0.3) tzinfo (~> 2.0, >= 2.0.5) - uri (>= 0.13.1) ast (2.4.2) base64 (0.2.0) benchmark (0.4.0) @@ -134,7 +131,6 @@ GEM umbrellio-sequel-plugins (0.17.0) sequel unicode-display_width (2.6.0) - uri (1.0.2) PLATFORMS arm64-darwin-21 @@ -149,7 +145,7 @@ DEPENDENCIES ci-helper dry-initializer pry - qonfig (= 0.28.0) + qonfig rake resol! rspec diff --git a/lib/resol/initializers.rb b/lib/resol/initializers.rb index 0548c0e..0deff38 100644 --- a/lib/resol/initializers.rb +++ b/lib/resol/initializers.rb @@ -20,6 +20,8 @@ def apply!(service_class, initializer_name) else raise ArgumentError, "unknown initializer #{initializer_name}" end + + self.applied_classes << service_class.name end private @@ -28,7 +30,7 @@ def apply!(service_class, initializer_name) def validate_state!(service_class) applied_parent = service_class - return if service_class.ancestors.none? { |klass| klass.name.start_with?(MOD_MATCH_REGEX) } + return if service_class.ancestors.none? { |klass| klass.inspect.start_with?(MOD_MATCH_REGEX) } loop do applied_parent = applied_parent.superclass or break @@ -36,8 +38,16 @@ def validate_state!(service_class) break if applied_classes.include?(applied_parent.name) end - err_message = "#{applied_parent.name} or his superclasses manually include initializer dsl" - raise ArgumentError, err_message + if applied_parent.nil? + error!("use ::use_initializer! method on desired service class") + end + + err_message = "#{applied_parent.name} or his superclasses already used initialize lib" + error!(err_message) + end + + def error!(message) + raise ArgumentError, message end end end diff --git a/lib/resol/plugins.rb b/lib/resol/plugins.rb index 396273e..a7c8b12 100644 --- a/lib/resol/plugins.rb +++ b/lib/resol/plugins.rb @@ -6,11 +6,13 @@ module Resol module Plugins PLUGINS_PATH = Pathname("resol/plugins") class Manager - def initialize + def initialize(target_class = nil) self.plugins = [] + self.target_class = target_class || Resol::Service end def plugin(plugin_name) + plugin_name = plugin_name.to_s return if plugins.include?(plugin_name) plugin_module = find_plugin_module(plugin_name) @@ -27,21 +29,21 @@ def plugin(plugin_name) private - attr_accessor :plugins + attr_accessor :plugins, :target_class def find_plugin_module(plugin_name) require PLUGINS_PATH.join(plugin_name) - Plugins.const_get(classify_plugin_name(plugin_name)) + resolve_module(classify_plugin_name(plugin_name)) rescue LoadError, NameError => e - raise "Failed to load plugin '#{plugin_name}': #{e.message}" + raise ArgumentError, "Failed to load plugin '#{plugin_name}': #{e.message}" end - def classify_plugin_name(string) - string.split(/_|-/).map!(&:capitalize).join + def resolve_module(module_name) + Plugins.const_get(module_name) end - def target_class - Resol::Service + def classify_plugin_name(string) + string.split(/_|-/).map!(&:capitalize).join end end end diff --git a/lib/resol/plugins/dummy.rb b/lib/resol/plugins/dummy.rb new file mode 100644 index 0000000..03fe03f --- /dev/null +++ b/lib/resol/plugins/dummy.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Resol + module Plugins + module Dummy; end + end +end diff --git a/lib/resol/plugins/return_in_service.rb b/lib/resol/plugins/return_in_service.rb index be375a2..8a3d37e 100644 --- a/lib/resol/plugins/return_in_service.rb +++ b/lib/resol/plugins/return_in_service.rb @@ -11,7 +11,9 @@ def handle_catch(_service) end def call_service(service) - service.call.tap { |res| return unless res.is_a?(Service::Result) } + service.call.tap do |res| + return Resol::Service::NOT_EXITED unless res.is_a?(Service::Result) + end end end diff --git a/lib/resol/result.rb b/lib/resol/result.rb index 0fe8a48..0d1facd 100644 --- a/lib/resol/result.rb +++ b/lib/resol/result.rb @@ -3,7 +3,9 @@ module Resol class UnwrapError < StandardError; end + # rubocop:disable Lint/EmptyClass class Result; end + # rubocop:enable Lint/EmptyClass class Success < Result def initialize(value) diff --git a/lib/resol/service.rb b/lib/resol/service.rb index 7974bfb..07b8f37 100644 --- a/lib/resol/service.rb +++ b/lib/resol/service.rb @@ -27,26 +27,17 @@ def message end end - module ChildMethodRestriction - def plugin(*) - raise NoMethodError - end - - def manager - raise NoMethodError - end - end - include Resol::Builder include Resol::Callbacks - Result = Struct.new(:data) NOT_EXITED = Object.new.freeze + BASE_CLASS = self + + Result = Struct.new(:data) class << self def inherited(klass) klass.const_set(:Failure, Class.new(klass::Failure)) - klass.extend(ChildMethodRestriction) super end @@ -55,11 +46,15 @@ def use_initializer!(initializer_lib) end def plugin(...) + if self::BASE_CLASS != self + raise ArgumentError, "can load plugins only on base Resol::Service" + end + manager.plugin(...) end - def call(*, **) - service = build(*, **) + def call(...) + service = build(...) result = handle_catch(service) do service.instance_variable_set(:@__performing__, true) @@ -130,8 +125,8 @@ def check_performing end end - def proceed_return(data) - throw(self, data) + def proceed_return(service, data) + throw(service, data) end end end diff --git a/resol.gemspec b/resol.gemspec index c25335e..46b1418 100644 --- a/resol.gemspec +++ b/resol.gemspec @@ -13,10 +13,7 @@ Gem::Specification.new do |spec| spec.homepage = "https://github.com/umbrellio/resol" spec.license = "MIT" - spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0") + spec.required_ruby_version = Gem::Requirement.new(">= 3.1.0") spec.files = `git ls-files -z`.split("\x0").reject { |f| f.include?("spec") } spec.require_paths = ["lib"] - - spec.add_dependency "dry-initializer", "~> 3.1" - spec.add_dependency "smart_initializer", "~> 0.7" end diff --git a/spec/configuration_spec.rb b/spec/configuration_spec.rb index fb6badb..020ff9f 100644 --- a/spec/configuration_spec.rb +++ b/spec/configuration_spec.rb @@ -1,4 +1,27 @@ # frozen_string_literal: true RSpec.describe Resol::Configuration do + before { allow(described_class).to receive(:smart_not_loaded?).and_return(const_not_loaded?) } + + let(:const_not_loaded?) { true } + + it "#smart_config returns nil" do + expect(described_class.smart_config).to eq(nil) + end + + context "with loaded const" do + let(:const_not_loaded?) { false } + + it "returns config" do + expect(described_class.smart_config).to eq(SmartCore::Initializer::Configuration.config) + end + end + + context "with original method" do + before { allow(described_class).to receive(:smart_not_loaded?).and_call_original } + + it "returns smartcore config" do + expect(described_class.smart_config).to eq(SmartCore::Initializer::Configuration.config) + end + end end diff --git a/spec/initializers_spec.rb b/spec/initializers_spec.rb index 6f82e86..84a3a8c 100644 --- a/spec/initializers_spec.rb +++ b/spec/initializers_spec.rb @@ -1,4 +1,83 @@ # frozen_string_literal: true RSpec.describe Resol::Initializers do + def apply_initializer(forced_service_class = nil) + described_class.apply!(forced_service_class || service_class, initializer_name) + end + + before { stub_const("InitializerTestClass", service_class) } + + let(:service_class) do + Class.new(Resol::Service) do + def call + success! + end + end + end + + let(:initializer_name) { :dry } + let(:dry_modules) do + ["Dry::Initializer::Mixin::Root", start_with("Dry::Initializer::Mixin::Local")] + end + + it "properly extend service class with initializer" do + apply_initializer + expect(service_class.ancestors.map(&:to_s)).to include(*dry_modules) + end + + context "with unknown initializer lib" do + let(:initializer_name) { :kek } + + specify do + expect { apply_initializer }.to raise_error(ArgumentError, "unknown initializer kek") + end + end + + context "with already prepared parent" do + before { stub_const("SecondChildService", second_child_service) } + + let(:service_class) do + Class.new(ReturnEngineService) do + def call + success! + end + end + end + + let(:second_child_service) do + Class.new(InitializerTestClass) + end + + let(:error_message) do + "ReturnEngineService or his superclasses already used initialize lib" + end + + specify do + expect { apply_initializer(SecondChildService) }.to raise_error(ArgumentError, error_message) + end + end + + context "with manually extended service" do + before { stub_const("SecondChildService", second_child_service) } + + let(:service_class) do + Class.new(Resol::Service) do + extend Dry::Initializer + + def call + success! + end + end + end + + let(:second_child_service) do + Class.new(InitializerTestClass) + end + + let(:error_message) { "use ::use_initializer! method on desired service class" } + + specify do + expect { apply_initializer(SecondChildService) }.to raise_error(ArgumentError, error_message) + end + end end diff --git a/spec/manager_spec.rb b/spec/manager_spec.rb new file mode 100644 index 0000000..f56b11e --- /dev/null +++ b/spec/manager_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +RSpec.describe Resol::Plugins::Manager do + let(:manager) { described_class.new(service_double) } + + let(:service_double) do + class_double( + Resol::Service, :DummyService, { prepend: true, singleton_class: singleton_double } + ) + end + + let(:singleton_double) { double(prepend: true) } + + it "skips all prepends" do + manager.plugin(:dummy) + + expect(service_double).not_to receive(:prepend) + expect(singleton_double).not_to receive(:prepend) + end + + context "when uses same plugin few times" do + before { allow(manager).to receive(:resolve_module).and_return(Resol::Plugins::Dummy) } + + before { manager.plugin(:dummy) } + + let(:manager_plugins) { manager.instance_variable_get(:@plugins) } + + it "doesn't load plugin second time" do + manager.plugin(:dummy) + + expect(manager).to have_received(:resolve_module).once + expect(manager_plugins).to eq(["dummy"]) + end + end + + context "when can't require plugin" do + specify do + expect { manager.plugin(:not_existed_plugin) }.to raise_error do |error| + expect(error).to be_an_instance_of(ArgumentError) + expect(error.message).to include("Failed to load plugin 'not_existed_plugin': ") + end + end + end + + context "when can't resolve module" do + before { allow(manager).to receive(:resolve_module).and_raise(NameError, "msg") } + + specify do + expect { manager.plugin(:dummy) }.to raise_error do |error| + expect(error).to be_an_instance_of(ArgumentError) + expect(error.message).to include("Failed to load plugin 'dummy': msg") + end + end + end +end diff --git a/spec/resol_spec.rb b/spec/resol_spec.rb index 46f84fe..d419f6d 100644 --- a/spec/resol_spec.rb +++ b/spec/resol_spec.rb @@ -4,4 +4,18 @@ it "has a version number" do expect(Resol::VERSION).not_to be nil end + + describe "#config" do + specify do + expect(described_class.config).to eq(Resol::Configuration) + end + end + + describe "#configure" do + specify do + Resol.configure do |config| + expect(config.smart_config).to eq(SmartCore::Initializer::Configuration.config) + end + end + end end diff --git a/spec/result_spec.rb b/spec/result_spec.rb index 1589dc2..73920c1 100644 --- a/spec/result_spec.rb +++ b/spec/result_spec.rb @@ -4,41 +4,78 @@ describe Resol::Success do let(:result) { Resol::Success(:success_value) } - it { expect(result.success?).to be_truthy } - it { expect(result.failure?).to be_falsey } - it { expect(result.value_or(:other_value)).to eq(:success_value) } - it { expect(result.value!).to eq(:success_value) } - it { expect(result.error).to be_nil } - it { expect { result.or { raise "Some Error" } }.not_to raise_error } - - it do + specify { expect(result.success?).to be_truthy } + specify { expect(result.failure?).to be_falsey } + specify { expect(result.value_or(:other_value)).to eq(:success_value) } + specify { expect(result.value!).to eq(:success_value) } + specify { expect(result.error).to be_nil } + specify { expect { result.or { raise "Some Error" } }.not_to raise_error } + + specify do success_proc = instance_double(Proc) failure_proc = instance_double(Proc) allow(success_proc).to receive(:call).and_return("result") expect(result.either(success_proc, failure_proc)).to eq("result") end + + specify do + final = result.bind(&:to_s) + expect(final).to eq("success_value") + end + + specify do + final = result.bind do |val| + Resol::Success("success").bind do |to_exclude| + Resol::Success(val.to_s.gsub(to_exclude, "")) + end + end + + expect(final.success?).to eq(true) + expect(final.value!).to eq("_value") + end + + specify do + final = result.fmap(&:to_s) + + expect(final.success?).to eq(true) + expect(final.value!).to eq("success_value") + end end describe Resol::Failure do let(:result) { Resol::Failure(:failure_value) } - it { expect(result.success?).to be_falsey } - it { expect(result.failure?).to be_truthy } - it { expect(result.value_or(:other_value)).to eq(:other_value) } - it { expect(result.error).to eq(:failure_value) } - it { expect { result.or { raise "Some Error" } }.to raise_error("Some Error") } + specify { expect(result.success?).to be_falsey } + specify { expect(result.failure?).to be_truthy } + specify { expect(result.value_or(:other_value)).to eq(:other_value) } + specify { expect(result.error).to eq(:failure_value) } + specify { expect { result.or { raise "Some Error" } }.to raise_error("Some Error") } - it do + specify do expect { result.value! }.to raise_error(Resol::UnwrapError, "Failure result :failure_value") end - it do + specify do success_proc = instance_double(Proc) failure_proc = instance_double(Proc) allow(failure_proc).to receive(:call).and_return("result") expect(result.either(success_proc, failure_proc)).to eq("result") end + + specify do + final = result.bind(&:to_s) + + expect(final.failure?).to eq(true) + expect(final.error).to eq(:failure_value) + end + + specify do + final = result.fmap { |val| val + 1 } + + expect(final.failure?).to eq(true) + expect(final.error).to eq(:failure_value) + end end end diff --git a/spec/service_spec.rb b/spec/service_spec.rb index 37f75dc..314b665 100644 --- a/spec/service_spec.rb +++ b/spec/service_spec.rb @@ -99,6 +99,87 @@ def call end end +class PluginSuccessService < ReturnEngineService + def call + success!(:success_result) + end +end + +class PluginFailureService < ReturnEngineService + def call + fail!(:failure_result, { data: 123 }) + end +end + +class PluginEmptyService < ReturnEngineService + def call + "some_string" + end +end + +class PluginAbstractService < ReturnEngineService +end + +class PluginInheritedService < PluginAbstractService + def call + success!(:success_result) + end +end + +class PluginServiceWithCall < ReturnEngineService + def call + success!(:success_result) + end +end + +class PluginSubService < PluginServiceWithCall +end + +class PluginServiceWithCallbacks < ReturnEngineService + before_call :define_instance_var + + def call + success!(@some_var) + end + + private + + def define_instance_var + @some_var = "some_value" + end +end + +class PluginSubServiceWithCallbacks < PluginServiceWithCallbacks + before_call :set_other_value + + private + + def set_other_value + @some_var += "_postfix" + end +end + +class PluginServiceWithTransaction < ReturnEngineService + def call + DB.transaction { return success! } + end +end + +class PluginServiceWithFailInTransaction < ReturnEngineService + def call + DB.transaction { fail!(:failed) } + end +end + +class PluginHackyService < ReturnEngineService + param :count + + def call + return success! unless count.zero? + PluginHackyService.build(count + 1).call + end +end + RSpec.describe Resol::Service do context "with Catch return engine" do it "returns a success result" do @@ -164,41 +245,48 @@ def call end end - context "with Return return engine" do + context "with Return on success plugin" do it "returns a success result" do - expect(SuccessService.call!).to eq(:success_result) + expect(PluginSuccessService.call!).to eq(:success_result) end it "raises a failure result error" do - expect { FailureService.call! }.to raise_error do |error| - expect(error).to be_a(FailureService::Failure) + expect { PluginFailureService.call! }.to raise_error do |error| + expect(error).to be_a(PluginFailureService::Failure) expect(error.code).to eq(:failure_result) expect(error.data).to eq(data: 123) end end it "raises an InvalidCommandImplementation error" do - expect { EmptyService.call! }.to raise_error do |error| - expect(error).to be_a(EmptyService::InvalidCommandImplementation) + expect { PluginEmptyService.call! }.to raise_error do |error| + expect(error).to be_a(PluginEmptyService::InvalidCommandImplementation) expect(error.message).to eq( - "No `#success!` or `#fail!` called in `#call` method in EmptyService.", + "No `#success!` or `#fail!` called in `#call` method in PluginEmptyService.", ) end end it "properly works with inherited services" do - expect(InheritedService.call!).to eq(:success_result) - expect(SubService.call!).to eq(:success_result) + expect(PluginInheritedService.call!).to eq(:success_result) + expect(PluginSubService.call!).to eq(:success_result) end it "properly executes callbacks" do - expect(SubServiceWithCallbacks.call!).to eq("some_value_postfix") - expect(ServiceWithCallbacks.call!).to eq("some_value") + expect(PluginSubServiceWithCallbacks.call!).to eq("some_value_postfix") + expect(PluginServiceWithCallbacks.call!).to eq("some_value") + end + + it "doesn't rollback transaction" do + result = PluginServiceWithTransaction.call + expect(result.success?).to eq(true) + expect(result.value!).to eq(nil) + expect(DB.rollbacked).to eq(false) end context "when service failed" do it "rollbacks transaction" do - result = ServiceWithFailInTransaction.call + result = PluginServiceWithFailInTransaction.call expect(result.failure?).to eq(true) result.or do |error| expect(error.code).to eq(:failed) @@ -210,14 +298,35 @@ def call context "when using instance #call" do it "raises error" do - expect { SuccessService.build.call }.to raise_error(Resol::Service::InvalidCommandCall) + expect do + PluginSuccessService.build.call + end.to raise_error(Resol::Service::InvalidCommandCall) end end context "when using instance #call inside other service" do it "raises error" do - expect { HackyService.call!(0) }.to raise_error(Resol::Service::InvalidCommandCall) + expect { PluginHackyService.call!(0) }.to raise_error(Resol::Service::InvalidCommandCall) end end end + + context "when install plugin on the child service class" do + let(:child_service) { Class.new(Resol::Service) } + + let(:error_message) { "can load plugins only on base Resol::Service" } + + it "raises error" do + expect { child_service.plugin(:dump) }.to raise_error(ArgumentError, error_message) + end + end + + context "when access plugin manager more then one time" do + let(:first_manager) { Resol::Service.send(:manager) } + let(:second_manager) { Resol::Service.send(:manager) } + + it "memoize manager instance" do + expect(first_manager).to eq(second_manager) + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c6af524..95c209a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -28,10 +28,28 @@ require "smart_core/initializer" require "dry/initializer" +require "resol/plugins/dummy" + class SmartService < Resol::Service use_initializer! :smartcore end +class ReturnEngineService < Resol::Service + BASE_CLASS = self + + use_initializer! :dry + + class << self + private + + def manager + @manager ||= Resol::Plugins::Manager.new(self) + end + end +end + +ReturnEngineService.plugin(:return_in_service) + RSpec.configure do |config| config.example_status_persistence_file_path = ".rspec_status" config.disable_monkey_patching! @@ -39,4 +57,10 @@ class SmartService < Resol::Service config.order = :random Kernel.srand config.seed + + config.around do |ex| + applied_classes = Resol::Initializers.send(:applied_classes).dup + ex.call + Resol::Initializers.send(:applied_classes=, applied_classes) + end end