From 97bf5327aa19e0a83d0a04578015a592bd51c9e6 Mon Sep 17 00:00:00 2001 From: Adam Lassek Date: Tue, 6 Aug 2024 06:29:07 -0500 Subject: [PATCH] Allow Provider Sources to choose a custom superclass (#275) The motivation for this is to allow consuming frameworks of dry-system to define their own superclass for providers, in order to add their own methods to the class. The is only used for user-defined providers with a block implementation. External providers in a group do not allow this, because their superclass is defined ahead of time when they are added to the source registry. If an external provider source wants to use a different superclass, they can define a concrete class of their own instead. The custom superclass is assumed to be a child of Dry::System::Provider::Source. In addition to `provider_source_class`, ProviderRegistrar contains `provider_source_options` as an extension point for subclasses to send custom intialization params to the source class. --- lib/dry/system/provider.rb | 5 +- lib/dry/system/provider/source.rb | 27 +++--- lib/dry/system/provider_registrar.rb | 39 +++++++- .../custom_provider_superclass_spec.rb | 90 +++++++++++++++++++ 4 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 spec/integration/container/providers/custom_provider_superclass_spec.rb diff --git a/lib/dry/system/provider.rb b/lib/dry/system/provider.rb index 6a5d0c0a..5bfc335a 100644 --- a/lib/dry/system/provider.rb +++ b/lib/dry/system/provider.rb @@ -127,7 +127,8 @@ class Provider attr_reader :source # @api private - def initialize(name:, namespace: nil, target_container:, source_class:, &block) # rubocop:disable Style/KeywordParametersOrder + # rubocop:disable Layout/LineLength, Style/KeywordParametersOrder + def initialize(name:, namespace: nil, target_container:, source_class:, source_options: {}, &block) @name = name @namespace = namespace @target_container = target_container @@ -137,11 +138,13 @@ def initialize(name:, namespace: nil, target_container:, source_class:, &block) @step_running = nil @source = source_class.new( + **source_options, provider_container: provider_container, target_container: target_container, &block ) end + # rubocop:enable Layout/LineLength, Style/KeywordParametersOrder # Runs the `prepare` lifecycle step. # diff --git a/lib/dry/system/provider/source.rb b/lib/dry/system/provider/source.rb index 42ad58b1..51e52b56 100644 --- a/lib/dry/system/provider/source.rb +++ b/lib/dry/system/provider/source.rb @@ -37,10 +37,20 @@ class << self # @see Dry::System::Provider::SourceDSL # # @api private - def for(name:, group: nil, &block) - Class.new(self) { |klass| + def for(name:, group: nil, superclass: nil, &block) + superclass ||= self + + Class.new(superclass) { |klass| klass.source_name name klass.source_group group + + name_with_group = group ? "#{group}->#{name}" : name + klass.instance_eval <<~RUBY, __FILE__, __LINE__ + 1 + def name + "#{superclass.name}[#{name_with_group}]" + end + RUBY + SourceDSL.evaluate(klass, &block) if block } end @@ -58,14 +68,6 @@ def inherited(subclass) end end - # @api private - def name - source_str = source_name - source_str = "#{source_group}->#{source_str}" if source_group - - "Dry::System::Provider::Source[#{source_str}]" - end - # @api private def to_s "#<#{name}>" @@ -117,7 +119,10 @@ def inspect # # @api public attr_reader :target_container - alias_method :target, :target_container + + # @see #target_container + # @api public + def target = target_container # @api private def initialize(provider_container:, target_container:, &block) diff --git a/lib/dry/system/provider_registrar.rb b/lib/dry/system/provider_registrar.rb index 6f078735..4e878da5 100644 --- a/lib/dry/system/provider_registrar.rb +++ b/lib/dry/system/provider_registrar.rb @@ -136,6 +136,21 @@ def provider_files }.first end + # Extension point for subclasses to customize their + # provider source superclass. Expected to be a subclass + # of Dry::System::Provider::Source + # + # @api public + # @since 1.1.0 + def provider_source_class = Dry::System::Provider::Source + + # Extension point for subclasses to customize initialization + # params for provider_source_class + # + # @api public + # @since 1.1.0 + def provider_source_options = {} + # @api private def finalize! provider_files.each do |path| @@ -196,25 +211,45 @@ def provider_paths end def build_provider(name, options:, source: nil, &block) - source_class = source || Provider::Source.for(name: name, &block) + source_class = source || Provider::Source.for( + name: name, + superclass: provider_source_class, + &block + ) + + source_options = + if source_class < provider_source_class + provider_source_options + else + {} + end Provider.new( **options, name: name, target_container: target_container, - source_class: source_class + source_class: source_class, + source_options: source_options ) end def build_provider_from_source(name, source:, group:, options:, &block) provider_source = System.provider_sources.resolve(name: source, group: group) + source_options = + if provider_source.source <= provider_source_class + provider_source_options + else + {} + end + Provider.new( **provider_source.provider_options, **options, name: name, target_container: target_container, source_class: provider_source.source, + source_options: source_options, &block ) end diff --git a/spec/integration/container/providers/custom_provider_superclass_spec.rb b/spec/integration/container/providers/custom_provider_superclass_spec.rb new file mode 100644 index 00000000..042254c1 --- /dev/null +++ b/spec/integration/container/providers/custom_provider_superclass_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +RSpec.describe "Providers / Custom provider superclass" do + let!(:custom_superclass) do + module Test + class CustomSource < Dry::System::Provider::Source + attr_reader :custom_setting + + def initialize(custom_setting:, **options, &block) + super(**options, &block) + @custom_setting = custom_setting + end + end + end + + Test::CustomSource + end + + let!(:custom_registrar) do + module Test + class CustomRegistrar < Dry::System::ProviderRegistrar + def provider_source_class = Test::CustomSource + def provider_source_options = {custom_setting: "hello"} + end + end + + Test::CustomRegistrar + end + + subject(:system) do + module Test + class Container < Dry::System::Container + configure do |config| + config.root = SPEC_ROOT.join("fixtures/app").realpath + config.provider_registrar = Test::CustomRegistrar + end + end + end + + Test::Container + end + + it "overrides the default Provider Source base class" do + system.register_provider(:test) {} + + provider_source = system.providers[:test].source + + expect(provider_source.class).to be < custom_superclass + expect(provider_source.class.name).to eq "Test::CustomSource[test]" + expect(provider_source.custom_setting).to eq "hello" + end + + context "Source class != provider_source_class" do + let!(:custom_source) do + module Test + class OtherSource < Dry::System::Provider::Source + attr_reader :options + + def initialize(**options, &block) + @options = options.except(:provider_container, :target_container) + super(**options.slice(:provider_container, :target_container), &block) + end + end + end + + Test::OtherSource + end + + specify "External source doesn't use provider_source_options" do + Dry::System.register_provider_source(:test, group: :custom, source: custom_source) + system.register_provider(:test, from: :custom) {} + + expect { + provider_source = system.providers[:test].source + expect(provider_source.class).to be < Dry::System::Provider::Source + expect(provider_source.options).to be_empty + }.to_not raise_error + end + + specify "Class-based source doesn't use provider_source_options" do + system.register_provider(:test, source: custom_source) + + expect { + provider_source = system.providers[:test].source + expect(provider_source.class).to be < Dry::System::Provider::Source + expect(provider_source.options).to be_empty + }.to_not raise_error + end + end +end