dry-system 0.19.1 → 0.22.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.
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/deprecations"
4
+ require "dry/system/errors"
5
+ require_relative "namespace"
6
+
7
+ module Dry
8
+ module System
9
+ module Config
10
+ # The configured namespaces for a ComponentDir
11
+ #
12
+ # @see Config::ComponentDir#namespaces
13
+ #
14
+ # @api private
15
+ class Namespaces
16
+ # @api private
17
+ attr_reader :namespaces
18
+
19
+ # @api private
20
+ def initialize
21
+ @namespaces = {}
22
+ end
23
+
24
+ # @api private
25
+ def initialize_copy(source)
26
+ super
27
+ @namespaces = source.namespaces.dup
28
+ end
29
+
30
+ # Returns the namespace configured for the path, or nil if no such namespace has
31
+ # been configured
32
+ #
33
+ # @return [Namespace, nil] the namespace, if configured
34
+ #
35
+ # @api public
36
+ def namespace(path)
37
+ namespaces[path]
38
+ end
39
+ alias_method :[], :namespace
40
+
41
+ # Returns the namespace configured for the root path, or nil if the root namespace
42
+ # has not been configured
43
+ #
44
+ # @return [Namespace, nil] the root namespace, if configured
45
+ #
46
+ # @api public
47
+ def root(**options)
48
+ if options.any?
49
+ Dry::Core::Deprecations.announce(
50
+ "Dry::System::Config::Namespaces#root (with arguments)",
51
+ "Use `#add_root(key: nil, const: nil)` instead",
52
+ tag: "dry-system",
53
+ uplevel: 1
54
+ )
55
+
56
+ add_root(**options)
57
+ return
58
+ end
59
+
60
+ namespaces[Namespace::ROOT_PATH]
61
+ end
62
+
63
+ # rubocop:disable Layout/LineLength
64
+
65
+ # Adds a component dir namespace
66
+ #
67
+ # A namespace encompasses a given sub-directory of the component dir, and
68
+ # determines (1) the leading segments of its components' registered identifiers,
69
+ # and (2) the expected constant namespace of their class constants.
70
+ #
71
+ # A namespace for a path can only be added once.
72
+ #
73
+ # @example Adding a namespace with top-level identifiers
74
+ # # Components defined within admin/ (e.g. admin/my_component.rb) will be:
75
+ # #
76
+ # # - Registered with top-level identifiers ("my_component")
77
+ # # - Expected to have constants in `Admin`, matching the namespace's path (Admin::MyComponent)
78
+ #
79
+ # namespaces.add "admin", key: nil
80
+ #
81
+ # @example Adding a namespace with top-level class constants
82
+ # # Components defined within adapters/ (e.g. adapters/my_adapter.rb) will be:
83
+ # #
84
+ # # - Registered with leading identifiers matching the namespace's path ("adapters.my_adapter")
85
+ # # - Expected to have top-level constants (::MyAdapter)
86
+ #
87
+ # namespaces.add "adapters", const: nil
88
+ #
89
+ # @example Adding a namespace with distinct identifiers and class constants
90
+ # # Components defined within `bananas/` (e.g. bananas/banana_split.rb) will be:
91
+ # #
92
+ # # - Registered with the given leading identifier ("desserts.banana_split")
93
+ # # - Expected to have constants within the given namespace (EatMe::Now::BananaSplit)
94
+ #
95
+ # namespaces.add "bananas", key: "desserts", const: "eat_me/now"
96
+ #
97
+ # @param path [String] the path to the sub-directory of source files to which this
98
+ # namespace should apply, relative to the component dir
99
+ # @param key [String, nil] the leading namespace to apply to the container keys
100
+ # for the components. Set `nil` for the keys to be top-level.
101
+ # @param const [String, nil] the Ruby constant namespace to expect for constants
102
+ # defined within the components. This should be provided in underscored string
103
+ # form, e.g. "hello_there/world" for a Ruby constant of `HelloThere::World`. Set
104
+ # `nil` for the constants to be top-level.
105
+ #
106
+ # @return [Namespace] the added namespace
107
+ #
108
+ # @see Namespace
109
+ #
110
+ # @api public
111
+ def add(path, key: path, const: path)
112
+ raise NamespaceAlreadyAddedError, path if namespaces.key?(path)
113
+
114
+ namespaces[path] = Namespace.new(path: path, key: key, const: const)
115
+ end
116
+
117
+ # rubocop:enable Layout/LineLength
118
+
119
+ # Adds a root component dir namespace
120
+ #
121
+ # @see #add
122
+ #
123
+ # @api public
124
+ def add_root(key: nil, const: nil)
125
+ add(Namespace::ROOT_PATH, key: key, const: const)
126
+ end
127
+
128
+ # Deletes the configured namespace for the given path and returns the namespace
129
+ #
130
+ # If no namespace was previously configured for the given path, returns nil
131
+ #
132
+ # @param path [String] the path for the namespace
133
+ #
134
+ # @return [Namespace, nil]
135
+ #
136
+ # @api public
137
+ def delete(path)
138
+ namespaces.delete(path)
139
+ end
140
+
141
+ # Deletes the configured root namespace and returns the namespace
142
+ #
143
+ # If no root namespace was previously configured, returns nil
144
+ #
145
+ # @return [Namespace, nil]
146
+ #
147
+ # @api public
148
+ def delete_root
149
+ delete(Namespace::ROOT_PATH)
150
+ end
151
+
152
+ # Returns the paths of the configured namespaces
153
+ #
154
+ # @return [Array<String,nil>] the namespace paths, with nil representing the root
155
+ # namespace
156
+ #
157
+ # @api public
158
+ def paths
159
+ namespaces.keys
160
+ end
161
+
162
+ # Returns the count of configured namespaces
163
+ #
164
+ # @return [Integer]
165
+ #
166
+ # @api public
167
+ def length
168
+ namespaces.length
169
+ end
170
+ alias_method :size, :length
171
+
172
+ # Returns true if there are no configured namespaces
173
+ #
174
+ # @return [Boolean]
175
+ #
176
+ # @api public
177
+ def empty?
178
+ namespaces.empty?
179
+ end
180
+
181
+ # Returns the configured namespaces as an array
182
+ #
183
+ # Adds a default root namespace to the end of the array if one was not added
184
+ # explicitly. This fallback ensures that all components in the component dir can
185
+ # be loaded.
186
+ #
187
+ # @return [Array<Namespace>] the namespaces
188
+ #
189
+ # @api public
190
+ def to_a
191
+ namespaces.values.tap do |arr|
192
+ arr << Namespace.default_root unless arr.any?(&:root?)
193
+ end
194
+ end
195
+
196
+ # Calls the given block once for each configured namespace, passing the namespace
197
+ # as an argument.
198
+ #
199
+ # @yieldparam namespace [Namespace] the yielded namespace
200
+ #
201
+ # @api public
202
+ def each(&block)
203
+ to_a.each(&block)
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
@@ -8,7 +8,7 @@ module Dry
8
8
 
9
9
  RB_EXT = ".rb"
10
10
  RB_GLOB = "*.rb"
11
- PATH_SEPARATOR = "/"
11
+ PATH_SEPARATOR = File::SEPARATOR
12
12
  DEFAULT_SEPARATOR = "."
13
13
  WORD_REGEX = /\w+/.freeze
14
14
  end
@@ -7,16 +7,19 @@ 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
- require "dry/system/errors"
14
- require "dry/system/booter"
15
14
  require "dry/system/auto_registrar"
16
- require "dry/system/manual_registrar"
17
- require "dry/system/importer"
15
+ require "dry/system/booter"
18
16
  require "dry/system/component"
19
17
  require "dry/system/constants"
18
+ require "dry/system/errors"
19
+ require "dry/system/identifier"
20
+ require "dry/system/importer"
21
+ require "dry/system/indirect_component"
22
+ require "dry/system/manual_registrar"
20
23
  require "dry/system/plugins"
21
24
 
22
25
  require_relative "component_dir"
@@ -48,7 +51,7 @@ module Dry
48
51
  #
49
52
  # Every container needs to be configured with following settings:
50
53
  #
51
- # * `:name` - a unique container identifier
54
+ # * `:name` - a unique container name
52
55
  # * `:root` - a system root directory (defaults to `pwd`)
53
56
  #
54
57
  # @example
@@ -73,17 +76,17 @@ module Dry
73
76
  extend Dry::System::Plugins
74
77
 
75
78
  setting :name
76
- setting(:root, Pathname.pwd.freeze) { |path| Pathname(path) }
77
- setting :system_dir, "system"
78
- setting :bootable_dirs, ["system/boot"]
79
- setting :registrations_dir, "container"
80
- setting :component_dirs, Config::ComponentDirs.new, cloneable: true
81
- setting :inflector, Dry::Inflector.new
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)
79
+ setting :root, default: Pathname.pwd.freeze, constructor: -> path { Pathname(path) }
80
+ setting :system_dir, default: "system"
81
+ setting :bootable_dirs, default: ["system/boot"]
82
+ setting :registrations_dir, default: "container"
83
+ setting :component_dirs, default: Config::ComponentDirs.new, cloneable: true
84
+ setting :inflector, default: Dry::Inflector.new
85
+ setting :booter, default: Dry::System::Booter
86
+ setting :auto_registrar, default: Dry::System::AutoRegistrar
87
+ setting :manual_registrar, default: Dry::System::ManualRegistrar
88
+ setting :importer, default: Dry::System::Importer
89
+ setting :components, default: {}, reader: true, constructor: :dup.to_proc
87
90
 
88
91
  class << self
89
92
  def strategies(value = nil)
@@ -101,8 +104,8 @@ module Dry
101
104
  # @see https://dry-rb.org/gems/dry-configurable
102
105
  #
103
106
  # @api public
104
- def setting(name, *args, &block)
105
- super(name, *args, &block)
107
+ def setting(name, default = Dry::Core::Constants::Undefined, **options, &block)
108
+ super(name, default, **options, &block)
106
109
  # TODO: dry-configurable needs a public API for this
107
110
  config._settings << _settings[name]
108
111
  self
@@ -233,7 +236,7 @@ module Dry
233
236
  # persistence.register(:db, DB.new)
234
237
  # end
235
238
  #
236
- # @param name [Symbol] a unique identifier for a bootable component
239
+ # @param name [Symbol] a unique name for a bootable component
237
240
  #
238
241
  # @see Lifecycle
239
242
  #
@@ -261,17 +264,15 @@ module Dry
261
264
  deprecate :finalize, :boot
262
265
 
263
266
  # @api private
264
- def boot_external(identifier, from:, key: nil, namespace: nil, &block)
267
+ def boot_external(name, from:, key: nil, namespace: nil, &block)
265
268
  System.providers[from].component(
266
- identifier, key: key, namespace: namespace, finalize: block, container: self
269
+ name, key: key, namespace: namespace, finalize: block, container: self
267
270
  )
268
271
  end
269
272
 
270
273
  # @api private
271
- def boot_local(identifier, namespace: nil, &block)
272
- Components::Bootable.new(
273
- identifier, container: self, namespace: namespace, &block
274
- )
274
+ def boot_local(name, namespace: nil, &block)
275
+ Components::Bootable.new(name, container: self, namespace: namespace, &block)
275
276
  end
276
277
 
277
278
  # Return if a container was finalized
@@ -490,7 +491,7 @@ module Dry
490
491
  #
491
492
  # @!method registered?(key)
492
493
  # Whether a +key+ is registered (doesn't trigger loading)
493
- # @param [String,Symbol] key Identifier
494
+ # @param [String,Symbol] key The key
494
495
  # @return [Boolean]
495
496
  # @api public
496
497
  #
@@ -580,17 +581,17 @@ module Dry
580
581
  def load_component(key)
581
582
  return self if registered?(key)
582
583
 
583
- component = component(key)
584
-
585
- if component.bootable?
586
- booter.start(component)
584
+ if (bootable_component = booter.find_component(key))
585
+ booter.start(bootable_component)
587
586
  return self
588
587
  end
589
588
 
589
+ component = find_component(key)
590
+
590
591
  booter.boot_dependency(component)
591
592
  return self if registered?(key)
592
593
 
593
- if component.file_exists?
594
+ if component.loadable?
594
595
  load_local_component(component)
595
596
  elsif manual_registrar.file_exists?(component)
596
597
  manual_registrar.(component)
@@ -614,25 +615,21 @@ module Dry
614
615
 
615
616
  container = importer[import_namespace]
616
617
 
617
- container.load_component(identifier.dequalified(import_namespace).key)
618
+ container.load_component(identifier.namespaced(from: import_namespace, to: nil).key)
618
619
 
619
620
  importer.(import_namespace, container)
620
621
  end
621
622
 
622
- def component(identifier)
623
- if (bootable_component = booter.find_component(identifier))
624
- return bootable_component
625
- end
626
-
623
+ def find_component(key)
627
624
  # Find the first matching component from within the configured component dirs.
628
- # If no matching component is found, return a plain component instance with no
629
- # associated file path. This fallback is important because the component may
630
- # still be loadable via the manual registrar or an imported container.
625
+ # If no matching component is found, return a null component; this fallback is
626
+ # important because the component may still be loadable via the manual registrar
627
+ # or an imported container.
631
628
  component_dirs.detect { |dir|
632
- if (component = dir.component_for_identifier(identifier))
629
+ if (component = dir.component_for_key(key))
633
630
  break component
634
631
  end
635
- } || Component.new(identifier)
632
+ } || IndirectComponent.new(Identifier.new(key, separator: config.namespace_separator))
636
633
  end
637
634
  end
638
635
 
@@ -11,13 +11,13 @@ module Dry
11
11
  end
12
12
  end
13
13
 
14
- # Error raised when the container tries to load a component with missing
15
- # file
16
- #
17
- # @api public
18
- FileNotFoundError = Class.new(StandardError) do
19
- def initialize(component)
20
- super("could not resolve require file for component '#{component.identifier}'")
14
+ # Error raised when a namespace for a component dir is added to configuration more
15
+ # than once
16
+ NamespaceAlreadyAddedError = Class.new(StandardError) do
17
+ def initialize(path)
18
+ path_label = path ? "path #{path.inspect}" : "root path"
19
+
20
+ super("Namespace for #{path_label} already added")
21
21
  end
22
22
  end
23
23
 
@@ -27,7 +27,7 @@ module Dry
27
27
  ComponentFileMismatchError = Class.new(StandardError) do
28
28
  def initialize(component)
29
29
  super(<<-STR)
30
- Bootable component '#{component.identifier}' not found
30
+ Bootable component '#{component.name}' not found
31
31
  STR
32
32
  end
33
33
  end
@@ -43,26 +43,17 @@ module Dry
43
43
  end
44
44
  end
45
45
 
46
- # Error raised when component's identifier is not valid
46
+ # Error raised when component's name is not valid
47
47
  #
48
48
  # @api public
49
- InvalidComponentIdentifierError = Class.new(ArgumentError) do
49
+ InvalidComponentNameError = Class.new(ArgumentError) do
50
50
  def initialize(name)
51
51
  super(
52
- "component identifier +#{name}+ is invalid or boot file is missing"
52
+ "component +#{name}+ is invalid or boot file is missing"
53
53
  )
54
54
  end
55
55
  end
56
56
 
57
- # Error raised when component's identifier for booting is not a symbol
58
- #
59
- # @api public
60
- InvalidComponentIdentifierTypeError = Class.new(ArgumentError) do
61
- def initialize(name)
62
- super("component identifier #{name.inspect} must be a symbol")
63
- end
64
- end
65
-
66
57
  # Error raised when trying to stop a component that hasn't started yet
67
58
  #
68
59
  # @api public
@@ -13,42 +13,29 @@ module Dry
13
13
  #
14
14
  # @api public
15
15
  class Identifier
16
- include Dry::Equalizer(:identifier, :namespace, :separator)
16
+ include Dry::Equalizer(:key, :separator)
17
17
 
18
- # @return [String] the identifier string
18
+ # @return [String] the identifier's string key
19
19
  # @api public
20
- attr_reader :identifier
21
-
22
- # @return [String, nil] the namespace for the component
23
- # @api public
24
- attr_reader :namespace
20
+ attr_reader :key
25
21
 
26
22
  # @return [String] the configured namespace separator
27
23
  # @api public
28
24
  attr_reader :separator
29
25
 
30
26
  # @api private
31
- def initialize(identifier, namespace: nil, separator: DEFAULT_SEPARATOR)
32
- @identifier = identifier.to_s
33
- @namespace = namespace
27
+ def initialize(key, separator: DEFAULT_SEPARATOR)
28
+ @key = key.to_s
34
29
  @separator = separator
35
30
  end
36
31
 
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
32
  # @!method to_s
46
- # Returns the identifier string
33
+ # Returns the identifier string key
47
34
  #
48
35
  # @return [String]
49
- # @see #identifier
36
+ # @see #key
50
37
  # @api public
51
- alias_method :to_s, :identifier
38
+ alias_method :to_s, :key
52
39
 
53
40
  # Returns the root namespace segment of the identifier string, as a symbol
54
41
  #
@@ -62,31 +49,11 @@ module Dry
62
49
  segments.first.to_sym
63
50
  end
64
51
 
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"
52
+ # Returns true if the given leading namespaces are a leading part of the
53
+ # identifier's key
72
54
  #
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
55
+ # Also returns true if nil is given (technically, from nothing everything is
56
+ # wrought)
90
57
  #
91
58
  # @example
92
59
  # identifier.key # => "articles.operations.create"
@@ -94,63 +61,74 @@ module Dry
94
61
  # identifier.start_with?("articles.operations") # => true
95
62
  # identifier.start_with?("articles") # => true
96
63
  # identifier.start_with?("article") # => false
64
+ # identifier.start_with?(nil) # => true
97
65
  #
98
66
  # @param leading_namespaces [String] the one or more leading namespaces to check
99
67
  # @return [Boolean]
100
68
  # @api public
101
69
  def start_with?(leading_namespaces)
102
- identifier.start_with?("#{leading_namespaces}#{separator}")
70
+ leading_namespaces.nil? ||
71
+ key.start_with?("#{leading_namespaces}#{separator}") ||
72
+ key.eql?(leading_namespaces)
103
73
  end
104
74
 
105
- # Returns a copy of the identifier with the given leading namespaces removed from
106
- # the identifier string.
107
- #
108
- # Additional options may be provided, which are passed to #initialize when
109
- # constructing the new copy of the identifier
110
- #
111
- # @param leading_namespace [String] the one or more leading namespaces to remove
112
- # @param options [Hash] additional options for initialization
75
+ # Returns the key with its segments separated by the given separator
113
76
  #
114
- # @return [Dry::System::Identifier] the copy of the identifier
77
+ # @example
78
+ # identifier.key # => "articles.operations.create"
79
+ # identifier.key_with_separator("/") # => "articles/operations/create"
115
80
  #
116
- # @see #initialize
81
+ # @return [String] the key using the separator
117
82
  # @api private
118
- def dequalified(leading_namespaces, **options)
119
- new_identifier = identifier.gsub(
120
- /^#{Regexp.escape(leading_namespaces)}#{Regexp.escape(separator)}/,
121
- EMPTY_STRING
122
- )
123
-
124
- return self if new_identifier == identifier
125
-
126
- self.class.new(
127
- new_identifier,
128
- namespace: namespace,
129
- separator: separator,
130
- **options
131
- )
83
+ def key_with_separator(separator)
84
+ segments.join(separator)
132
85
  end
133
86
 
134
- # Returns a copy of the identifier with the given options applied
87
+ # Returns a copy of the identifier with the key's leading namespace(s) replaced
88
+ #
89
+ # @example Changing a namespace
90
+ # identifier.key # => "articles.operations.create"
91
+ # identifier.namespaced(from: "articles", to: "posts").key # => "posts.commands.create"
92
+ #
93
+ # @example Removing a namespace
94
+ # identifier.key # => "articles.operations.create"
95
+ # identifier.namespaced(from: "articles", to: nil).key # => "operations.create"
135
96
  #
136
- # @param namespace [String, nil] a new namespace to be used
97
+ # @example Adding a namespace
98
+ # identifier.key # => "articles.operations.create"
99
+ # identifier.namespaced(from: nil, to: "admin").key # => "admin.articles.operations.create"
100
+ #
101
+ # @param from [String, nil] the leading namespace(s) to replace
102
+ # @param to [String, nil] the replacement for the leading namespace
137
103
  #
138
104
  # @return [Dry::System::Identifier] the copy of the identifier
139
105
  #
140
106
  # @see #initialize
141
107
  # @api private
142
- def with(namespace:)
143
- self.class.new(
144
- identifier,
145
- namespace: namespace,
146
- separator: separator
147
- )
108
+ def namespaced(from:, to:)
109
+ return self if from == to
110
+
111
+ separated_to = "#{to}#{separator}" if to
112
+
113
+ new_key =
114
+ if from.nil?
115
+ "#{separated_to}#{key}"
116
+ else
117
+ key.sub(
118
+ /^#{Regexp.escape(from.to_s)}#{Regexp.escape(separator)}/,
119
+ separated_to || EMPTY_STRING
120
+ )
121
+ end
122
+
123
+ return self if new_key == key
124
+
125
+ self.class.new(new_key, separator: separator)
148
126
  end
149
127
 
150
128
  private
151
129
 
152
130
  def segments
153
- @segments ||= identifier.split(separator)
131
+ @segments ||= key.split(separator)
154
132
  end
155
133
  end
156
134
  end