dry-system 0.18.2 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,11 +7,11 @@ require "dry-configurable"
7
7
  require "dry-container"
8
8
  require "dry/inflector"
9
9
 
10
+ require "dry/core/constants"
10
11
  require "dry/core/deprecations"
11
12
 
12
13
  require "dry/system"
13
14
  require "dry/system/errors"
14
- require "dry/system/loader"
15
15
  require "dry/system/booter"
16
16
  require "dry/system/auto_registrar"
17
17
  require "dry/system/manual_registrar"
@@ -20,6 +20,9 @@ require "dry/system/component"
20
20
  require "dry/system/constants"
21
21
  require "dry/system/plugins"
22
22
 
23
+ require_relative "component_dir"
24
+ require_relative "config/component_dirs"
25
+
23
26
  module Dry
24
27
  module System
25
28
  # Abstract container class to inherit from
@@ -61,7 +64,7 @@ module Dry
61
64
  # end
62
65
  #
63
66
  # # this will configure $LOAD_PATH to include your `lib` dir
64
- # load_paths!('lib')
67
+ # add_dirs_to_load_paths!('lib')
65
68
  # end
66
69
  #
67
70
  # @api public
@@ -71,19 +74,17 @@ module Dry
71
74
  extend Dry::System::Plugins
72
75
 
73
76
  setting :name
74
- setting :default_namespace
75
- setting(:root, Pathname.pwd.freeze) { |path| Pathname(path) }
76
- setting :system_dir, "system"
77
- setting :bootable_dirs, ["system/boot"]
78
- setting :registrations_dir, "container"
79
- setting :auto_register, []
80
- setting :inflector, Dry::Inflector.new
81
- setting :loader, Dry::System::Loader
82
- setting :booter, Dry::System::Booter
83
- setting :auto_registrar, Dry::System::AutoRegistrar
84
- setting :manual_registrar, Dry::System::ManualRegistrar
85
- setting :importer, Dry::System::Importer
86
- setting(:components, {}, reader: true, &:dup)
77
+ setting :root, default: Pathname.pwd.freeze, constructor: -> path { Pathname(path) }
78
+ setting :system_dir, default: "system"
79
+ setting :bootable_dirs, default: ["system/boot"]
80
+ setting :registrations_dir, default: "container"
81
+ setting :component_dirs, default: Config::ComponentDirs.new, cloneable: true
82
+ setting :inflector, default: Dry::Inflector.new
83
+ setting :booter, default: Dry::System::Booter
84
+ setting :auto_registrar, default: Dry::System::AutoRegistrar
85
+ setting :manual_registrar, default: Dry::System::ManualRegistrar
86
+ setting :importer, default: Dry::System::Importer
87
+ setting :components, default: {}, reader: true, constructor: :dup.to_proc
87
88
 
88
89
  class << self
89
90
  def strategies(value = nil)
@@ -101,13 +102,12 @@ module Dry
101
102
  # @see https://dry-rb.org/gems/dry-configurable
102
103
  #
103
104
  # @api public
104
- def setting(name, *args, &block)
105
- super(name, *args, &block)
105
+ def setting(name, default = Dry::Core::Constants::Undefined, **options, &block)
106
+ super(name, default, **options, &block)
106
107
  # TODO: dry-configurable needs a public API for this
107
108
  config._settings << _settings[name]
108
109
  self
109
110
  end
110
- ruby2_keywords(:setting) if respond_to?(:ruby2_keywords, true)
111
111
 
112
112
  # Configures the container
113
113
  #
@@ -126,7 +126,6 @@ module Dry
126
126
  def configure(&block)
127
127
  hooks[:before_configure].each { |hook| instance_eval(&hook) }
128
128
  super(&block)
129
- load_paths!(config.system_dir)
130
129
  hooks[:after_configure].each { |hook| instance_eval(&hook) }
131
130
  self
132
131
  end
@@ -385,7 +384,7 @@ module Dry
385
384
  self
386
385
  end
387
386
 
388
- # Sets load paths relative to the container's root dir
387
+ # Adds the directories (relative to the container's root) to the Ruby load path
389
388
  #
390
389
  # @example
391
390
  # class MyApp < Dry::System::Container
@@ -393,7 +392,7 @@ module Dry
393
392
  # # ...
394
393
  # end
395
394
  #
396
- # load_paths!('lib')
395
+ # add_to_load_path!('lib')
397
396
  # end
398
397
  #
399
398
  # @param [Array<String>] dirs
@@ -401,12 +400,9 @@ module Dry
401
400
  # @return [self]
402
401
  #
403
402
  # @api public
404
- def load_paths!(*dirs)
405
- dirs.map(&root.method(:join)).each do |path|
406
- next if load_paths.include?(path)
407
-
408
- load_paths << path
409
- $LOAD_PATH.unshift(path.to_s)
403
+ def add_to_load_path!(*dirs)
404
+ dirs.reverse.map(&root.method(:join)).each do |path|
405
+ $LOAD_PATH.prepend(path.to_s) unless $LOAD_PATH.include?(path.to_s)
410
406
  end
411
407
  self
412
408
  end
@@ -417,47 +413,6 @@ module Dry
417
413
  self
418
414
  end
419
415
 
420
- # Auto-registers components from the provided directory
421
- #
422
- # Typically you want to configure auto_register directories, and it will
423
- # work automatically. Use this method in cases where you want to have an
424
- # explicit way where some components are auto-registered, or if you want
425
- # to exclude some components from being auto-registered
426
- #
427
- # @example
428
- # class MyApp < Dry::System::Container
429
- # configure do |config|
430
- # # ...
431
- # end
432
- #
433
- # # with a dir
434
- # auto_register!('lib/core')
435
- #
436
- # # with a dir and a custom registration block
437
- # auto_register!('lib/core') do |config|
438
- # config.instance do |component|
439
- # # custom way of initializing a component
440
- # end
441
- #
442
- # config.exclude do |component|
443
- # # return true to exclude component from auto-registration
444
- # end
445
- # end
446
- # end
447
- #
448
- # @param [String] dir The dir name relative to the root dir
449
- #
450
- # @yield AutoRegistrar::Configuration
451
- # @see AutoRegistrar::Configuration
452
- #
453
- # @return [self]
454
- #
455
- # @api public
456
- def auto_register!(dir, &block)
457
- auto_registrar.(dir, &block)
458
- self
459
- end
460
-
461
416
  # Builds injector for this container
462
417
  #
463
418
  # An injector is a useful mixin which injects dependencies into
@@ -526,8 +481,8 @@ module Dry
526
481
  end
527
482
 
528
483
  # @api public
529
- def resolve(key, &block)
530
- load_component(key, &block) unless finalized?
484
+ def resolve(key)
485
+ load_component(key) unless finalized?
531
486
 
532
487
  super
533
488
  end
@@ -558,8 +513,8 @@ module Dry
558
513
  end
559
514
 
560
515
  # @api private
561
- def load_paths
562
- @load_paths ||= []
516
+ def component_dirs
517
+ config.component_dirs.to_a.map { |dir| ComponentDir.new(config: dir, container: self) }
563
518
  end
564
519
 
565
520
  # @api private
@@ -595,67 +550,6 @@ module Dry
595
550
  @importer ||= config.importer.new(self)
596
551
  end
597
552
 
598
- # @api private
599
- def component(identifier, **options)
600
- if (component = booter.components.detect { |c| c.identifier == identifier })
601
- component
602
- else
603
- Component.new(
604
- identifier,
605
- loader: config.loader,
606
- namespace: config.default_namespace,
607
- separator: config.namespace_separator,
608
- inflector: config.inflector,
609
- **options
610
- )
611
- end
612
- end
613
-
614
- # @api private
615
- def require_component(component)
616
- return if registered?(component.identifier)
617
-
618
- raise FileNotFoundError, component unless component.file_exists?(load_paths)
619
-
620
- require_path(component.path)
621
-
622
- yield
623
- end
624
-
625
- # Allows subclasses to use a different strategy for required files.
626
- #
627
- # E.g. apps that use `ActiveSupport::Dependencies::Loadable#require_dependency`
628
- # will override this method to allow container managed dependencies to be reloaded
629
- # for non-finalized containers.
630
- #
631
- # @api private
632
- def require_path(path)
633
- require path
634
- end
635
-
636
- # @api private
637
- def load_component(key, &block)
638
- return self if registered?(key)
639
-
640
- component(key).tap do |component|
641
- if component.bootable?
642
- booter.start(component)
643
- else
644
- root_key = component.root_key
645
-
646
- if (root_bootable = component(root_key)).bootable?
647
- booter.start(root_bootable)
648
- elsif importer.key?(root_key)
649
- load_imported_component(component.namespaced(root_key))
650
- end
651
-
652
- load_local_component(component, &block) unless registered?(key)
653
- end
654
- end
655
-
656
- self
657
- end
658
-
659
553
  # @api private
660
554
  def after(event, &block)
661
555
  hooks[:"after_#{event}"] << block
@@ -672,46 +566,87 @@ module Dry
672
566
 
673
567
  # @api private
674
568
  def inherited(klass)
675
- new_hooks = Container.hooks.dup
676
-
677
569
  hooks.each do |event, blocks|
678
- new_hooks[event].concat(blocks)
679
- new_hooks[event].concat(klass.hooks[event])
570
+ klass.hooks[event].concat blocks.dup
680
571
  end
681
572
 
682
- klass.instance_variable_set(:@hooks, new_hooks)
683
573
  klass.instance_variable_set(:@__finalized__, false)
574
+
684
575
  super
685
576
  end
686
577
 
687
- private
578
+ protected
688
579
 
689
580
  # @api private
690
- def load_local_component(component, default_namespace_fallback = false, &block)
691
- if booter.bootable?(component) || component.file_exists?(load_paths)
692
- booter.boot_dependency(component) unless finalized?
581
+ def load_component(key)
582
+ return self if registered?(key)
693
583
 
694
- require_component(component) do
695
- register(component.identifier) { component.instance }
696
- end
697
- elsif !default_namespace_fallback
698
- load_local_component(component.prepend(config.default_namespace), true, &block)
584
+ component = component(key)
585
+
586
+ if component.bootable?
587
+ booter.start(component)
588
+ return self
589
+ end
590
+
591
+ booter.boot_dependency(component)
592
+ return self if registered?(key)
593
+
594
+ if component.file_exists?
595
+ load_local_component(component)
699
596
  elsif manual_registrar.file_exists?(component)
700
597
  manual_registrar.(component)
701
- elsif block_given?
702
- yield
703
- else
704
- raise ComponentLoadError, component
598
+ elsif importer.key?(component.identifier.root_key)
599
+ load_imported_component(component.identifier)
705
600
  end
601
+
602
+ self
706
603
  end
707
604
 
708
- # @api private
709
- def load_imported_component(component)
710
- container = importer[component.namespace]
711
- container.load_component(component.identifier)
712
- importer.(component.namespace, container)
605
+ private
606
+
607
+ def load_local_component(component)
608
+ if component.auto_register?
609
+ register(component.identifier, memoize: component.memoize?) { component.instance }
610
+ end
611
+ end
612
+
613
+ def load_imported_component(identifier)
614
+ import_namespace = identifier.root_key
615
+
616
+ container = importer[import_namespace]
617
+
618
+ container.load_component(identifier.dequalified(import_namespace).key)
619
+
620
+ importer.(import_namespace, container)
621
+ end
622
+
623
+ def component(identifier)
624
+ if (bootable_component = booter.find_component(identifier))
625
+ return bootable_component
626
+ end
627
+
628
+ # Find the first matching component from within the configured component dirs.
629
+ # If no matching component is found, return a plain component instance with no
630
+ # associated file path. This fallback is important because the component may
631
+ # still be loadable via the manual registrar or an imported container.
632
+ component_dirs.detect { |dir|
633
+ if (component = dir.component_for_identifier(identifier))
634
+ break component
635
+ end
636
+ } || Component.new(identifier)
713
637
  end
714
638
  end
639
+
640
+ # Default hooks
641
+ after :configure do
642
+ # Add appropriately configured component dirs to the load path
643
+ #
644
+ # Do this in a single pass to preserve ordering (i.e. earliest dirs win)
645
+ paths = config.component_dirs.to_a.each_with_object([]) { |dir, arr|
646
+ arr << dir.path if dir.add_to_load_path
647
+ }
648
+ add_to_load_path!(*paths)
649
+ end
715
650
  end
716
651
  end
717
652
  end
@@ -2,13 +2,22 @@
2
2
 
3
3
  module Dry
4
4
  module System
5
+ # Error raised when a component dir is added to configuration more than once
6
+ #
7
+ # @api public
8
+ ComponentDirAlreadyAddedError = Class.new(StandardError) do
9
+ def initialize(dir)
10
+ super("Component directory #{dir.inspect} already added")
11
+ end
12
+ end
13
+
5
14
  # Error raised when the container tries to load a component with missing
6
15
  # file
7
16
  #
8
17
  # @api public
9
18
  FileNotFoundError = Class.new(StandardError) do
10
19
  def initialize(component)
11
- super("could not resolve require file for #{component.identifier}")
20
+ super("could not resolve require file for component '#{component.identifier}'")
12
21
  end
13
22
  end
14
23
 
@@ -18,20 +27,11 @@ module Dry
18
27
  ComponentFileMismatchError = Class.new(StandardError) do
19
28
  def initialize(component)
20
29
  super(<<-STR)
21
- Bootable component #{component.identifier.inspect} not found
30
+ Bootable component '#{component.identifier}' not found
22
31
  STR
23
32
  end
24
33
  end
25
34
 
26
- # Error raised when a resolved component couldn't be found
27
- #
28
- # @api public
29
- ComponentLoadError = Class.new(StandardError) do
30
- def initialize(component)
31
- super("could not load component #{component.inspect}")
32
- end
33
- end
34
-
35
35
  # Error raised when resolved component couldn't be loaded
36
36
  #
37
37
  # @api public
@@ -81,8 +81,17 @@ module Dry
81
81
  end
82
82
  end
83
83
 
84
- ComponentsDirMissing = Class.new(StandardError)
84
+ # Error raised when a configured component directory could not be found
85
+ #
86
+ # @api public
87
+ ComponentDirNotFoundError = Class.new(StandardError) do
88
+ def initialize(dir)
89
+ super("Component dir '#{dir}' not found")
90
+ end
91
+ end
92
+
85
93
  DuplicatedComponentKeyError = Class.new(ArgumentError)
94
+
86
95
  InvalidSettingsError = Class.new(ArgumentError) do
87
96
  # @api private
88
97
  def initialize(attributes)
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/equalizer"
4
+ require_relative "constants"
5
+
6
+ module Dry
7
+ module System
8
+ # An identifier representing a component to be registered.
9
+ #
10
+ # Components are eventually registered in the container using plain string
11
+ # identifiers, available as the `identifier` or `key` attribute here. Additional
12
+ # methods are provided to make it easier to evaluate or manipulate these identifiers.
13
+ #
14
+ # @api public
15
+ class Identifier
16
+ include Dry::Equalizer(:identifier, :namespace, :separator)
17
+
18
+ # @return [String] the identifier string
19
+ # @api public
20
+ attr_reader :identifier
21
+
22
+ # @return [String, nil] the namespace for the component
23
+ # @api public
24
+ attr_reader :namespace
25
+
26
+ # @return [String] the configured namespace separator
27
+ # @api public
28
+ attr_reader :separator
29
+
30
+ # @api private
31
+ def initialize(identifier, namespace: nil, separator: DEFAULT_SEPARATOR)
32
+ @identifier = identifier.to_s
33
+ @namespace = namespace
34
+ @separator = separator
35
+ end
36
+
37
+ # @!method key
38
+ # Returns the identifier string
39
+ #
40
+ # @return [String]
41
+ # @see #identifier
42
+ # @api public
43
+ alias_method :key, :identifier
44
+
45
+ # @!method to_s
46
+ # Returns the identifier string
47
+ #
48
+ # @return [String]
49
+ # @see #identifier
50
+ # @api public
51
+ alias_method :to_s, :identifier
52
+
53
+ # Returns the root namespace segment of the identifier string, as a symbol
54
+ #
55
+ # @example
56
+ # identifier.key # => "articles.operations.create"
57
+ # identifier.root_key # => :articles
58
+ #
59
+ # @return [Symbol] the root key
60
+ # @api public
61
+ def root_key
62
+ segments.first.to_sym
63
+ end
64
+
65
+ # Returns a path-delimited representation of the identifier, with the namespace
66
+ # incorporated. This path is intended for usage when requiring the component's
67
+ # source file.
68
+ #
69
+ # @example
70
+ # identifier.key # => "articles.operations.create"
71
+ # identifier.namespace # => "admin"
72
+ #
73
+ # identifier.path # => "admin/articles/operations/create"
74
+ #
75
+ # @return [String] the path
76
+ # @api public
77
+ def path
78
+ @require_path ||= identifier.gsub(separator, PATH_SEPARATOR).yield_self { |path|
79
+ if namespace
80
+ namespace_path = namespace.to_s.gsub(separator, PATH_SEPARATOR)
81
+ "#{namespace_path}#{PATH_SEPARATOR}#{path}"
82
+ else
83
+ path
84
+ end
85
+ }
86
+ end
87
+
88
+ # Returns true if the given namespace prefix is part of the identifier's leading
89
+ # namespaces
90
+ #
91
+ # @example
92
+ # identifier.key # => "articles.operations.create"
93
+ #
94
+ # identifier.start_with?("articles.operations") # => true
95
+ # identifier.start_with?("articles") # => true
96
+ # identifier.start_with?("article") # => false
97
+ #
98
+ # @param leading_namespaces [String] the one or more leading namespaces to check
99
+ # @return [Boolean]
100
+ # @api public
101
+ def start_with?(leading_namespaces)
102
+ identifier.start_with?("#{leading_namespaces}#{separator}") ||
103
+ identifier.eql?(leading_namespaces)
104
+ end
105
+
106
+ # Returns a copy of the identifier with the given leading namespaces removed from
107
+ # the identifier string.
108
+ #
109
+ # Additional options may be provided, which are passed to #initialize when
110
+ # constructing the new copy of the identifier
111
+ #
112
+ # @param leading_namespace [String] the one or more leading namespaces to remove
113
+ # @param options [Hash] additional options for initialization
114
+ #
115
+ # @return [Dry::System::Identifier] the copy of the identifier
116
+ #
117
+ # @see #initialize
118
+ # @api private
119
+ def dequalified(leading_namespaces, **options)
120
+ new_identifier = identifier.gsub(
121
+ /^#{Regexp.escape(leading_namespaces)}#{Regexp.escape(separator)}/,
122
+ EMPTY_STRING
123
+ )
124
+
125
+ return self if new_identifier == identifier
126
+
127
+ self.class.new(
128
+ new_identifier,
129
+ namespace: namespace,
130
+ separator: separator,
131
+ **options
132
+ )
133
+ end
134
+
135
+ # Returns a copy of the identifier with the given options applied
136
+ #
137
+ # @param namespace [String, nil] a new namespace to be used
138
+ #
139
+ # @return [Dry::System::Identifier] the copy of the identifier
140
+ #
141
+ # @see #initialize
142
+ # @api private
143
+ def with(namespace:)
144
+ self.class.new(
145
+ identifier,
146
+ namespace: namespace,
147
+ separator: separator
148
+ )
149
+ end
150
+
151
+ private
152
+
153
+ def segments
154
+ @segments ||= identifier.split(separator)
155
+ end
156
+ end
157
+ end
158
+ end