dry-system 0.19.1 → 0.22.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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