uses 1.0.0.pre.beta1

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,60 @@
1
+ require_relative "log_notifier"
2
+ require_relative "ignore_notifier"
3
+ require_relative "raise_error_notifier"
4
+
5
+ module Uses
6
+ module CircularDependency
7
+ class Analyzer
8
+ def initialize(uses_method_args)
9
+ @uses_method_args = uses_method_args
10
+ end
11
+
12
+ def analyze!
13
+ @dependency, @path_to_dependency = transitive_dependency?(@uses_method_args.klass_with_uses,@uses_method_args.klass_being_used)
14
+ end
15
+
16
+ def circular_dependency?
17
+ !!@dependency
18
+ end
19
+
20
+ def notify!
21
+ raise "You have not called analyze!" if @dependency.nil?
22
+ notifier = case @uses_method_args.uses_config.on_circular_dependency
23
+ when :warn then Uses::CircularDependency::LogNotifer.new(@uses_method_args, @path_to_dependency)
24
+ when :ignore then Uses::CircularDependency::IgnoreNotifier.new(@uses_method_args, @path_to_dependency)
25
+ when :raise_error then Uses::CircularDependency::RaiseErrorNotifier.new(@uses_method_args, @path_to_dependency)
26
+ end
27
+ notifier.notify!
28
+ end
29
+
30
+ private
31
+
32
+ def transitive_dependency?(klass_with_uses,klass_being_analyzed, path=[])
33
+ other_class_has_uses = klass_being_analyzed.respond_to?(:__uses_dependent_classes)
34
+
35
+ if other_class_has_uses
36
+ if klass_with_uses == klass_being_analyzed
37
+ [ true, path ]
38
+ else
39
+ # Want to stop searching as soon as we find something
40
+ procs_to_check_for_transitive_dependencies = klass_being_analyzed.__uses_dependent_classes.keys.map { |klass|
41
+ ->() { transitive_dependency?(klass_with_uses,klass, path + [ klass_being_analyzed ]) }
42
+ }
43
+ first_proc_to_find_a_dependency = procs_to_check_for_transitive_dependencies.detect { |p|
44
+ transitive_dependency, _ = p.()
45
+ transitive_dependency
46
+ }
47
+ if first_proc_to_find_a_dependency
48
+ _, path_to_dependency = first_proc_to_find_a_dependency.()
49
+ [ true, path_to_dependency ]
50
+ else
51
+ false
52
+ end
53
+ end
54
+ else
55
+ false
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,18 @@
1
+ module Uses
2
+ module CircularDependency
3
+ class BaseNotifier
4
+ def initialize(uses_method_args, path_to_dependency)
5
+ path = if path_to_dependency.empty?
6
+ nil
7
+ else
8
+ " via #{path_to_dependency.map(&:to_s).join(',')}"
9
+ end
10
+ @message = "#{uses_method_args.klass_being_used} and #{uses_method_args.klass_with_uses} have a circular dependency#{path}. This may cause unforseen issues, or just be generally confusing"
11
+ end
12
+
13
+ def notify!
14
+ raise "subclass must implement"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "../error"
2
+ module Uses
3
+ module CircularDependency
4
+ class Error < Uses::Error
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,10 @@
1
+ require_relative "base_notifier"
2
+ module Uses
3
+ module CircularDependency
4
+ class IgnoreNotifier < BaseNotifier
5
+ def notify!
6
+ Rails.logger.debug(@message)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,10 @@
1
+ require_relative "base_notifier"
2
+ module Uses
3
+ module CircularDependency
4
+ class LogNotifer < BaseNotifier
5
+ def notify!
6
+ Rails.logger.warn(@message)
7
+ end
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "base_notifier"
2
+ require_relative "error"
3
+ module Uses
4
+ module CircularDependency
5
+ class RaiseErrorNotifier < BaseNotifier
6
+ def notify!
7
+ raise Uses::CircularDependency::Error.new(@message)
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,38 @@
1
+ require "active_support/core_ext/object/inclusion"
2
+ module Uses
3
+ class Config
4
+ # Configure what should happen when a circular dependency is detected.
5
+ #
6
+ # :warn:: Emit a warning, but allow it (default)
7
+ # :raise_error:: Raise an exception, effectively making your app unusable until
8
+ # you resolve the circular dependencies
9
+ # :ignore:: Emit a warning at DEBUG level, effectively allowing you to ignore these issues.
10
+ attr_reader :on_circular_dependency
11
+
12
+ # The array of custom initializers. Generally you should use
13
+ # `Uses.initializers do |initializers|` to manipulate this
14
+ attr_reader :initializers
15
+
16
+ def initialize
17
+ reset!
18
+ end
19
+
20
+ def reset!
21
+ self.on_circular_dependency = :warn
22
+ @initializers = {}
23
+ end
24
+
25
+ ON_CIRCULAR_DEPENDENCY_VALUES = [
26
+ :ignore,
27
+ :raise_error,
28
+ :warn,
29
+ ]
30
+ def on_circular_dependency=(new_value)
31
+ if !new_value.in?(ON_CIRCULAR_DEPENDENCY_VALUES)
32
+ raise ArgumentError, "#{new_value} is not a valid value for on_circular_dependency. Use one of #{ON_CIRCULAR_DEPENDENCY_VALUES}"
33
+ end
34
+ @on_circular_dependency = new_value
35
+ end
36
+
37
+ end
38
+ end
data/lib/uses/error.rb ADDED
@@ -0,0 +1,4 @@
1
+ module Uses
2
+ class Error < StandardError
3
+ end
4
+ end
@@ -0,0 +1,19 @@
1
+ module Uses
2
+ module Initializer
3
+ class BaseInitializer
4
+ def initialize(uses_method_args)
5
+ @proc = self.create_proc(uses_method_args)
6
+ end
7
+
8
+ def call
9
+ @proc.()
10
+ end
11
+
12
+ private
13
+
14
+ def create_proc(uses_method_args)
15
+ raise "subclass must implement"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ require_relative "base_initializer"
2
+
3
+ class Uses::Initializer::FromInitializers < Uses::Initializer::BaseInitializer
4
+ def create_proc(uses_method_args)
5
+ uses_method_args.uses_config.initializers.fetch(uses_method_args.klass_being_used)
6
+ rescue KeyError
7
+ raise "An initializer for #{uses_method_args.klass_being_used.name} has not been defined. #{uses_method_args.klass_with_uses.name} has set initialize: to :config_initializers, which means it's assuming some other file (e.g. in config/initializers) has called Uses.initializers to set up the initialization"
8
+ end
9
+ end
@@ -0,0 +1,11 @@
1
+ require_relative "base_initializer"
2
+
3
+ class Uses::Initializer::NewNoArgs < Uses::Initializer::BaseInitializer
4
+ def create_proc(uses_method_args)
5
+ initialize_method = uses_method_args.klass_being_used.instance_method(:initialize)
6
+ if !initialize_method.arity.in?([0,-1])
7
+ raise "#{uses_method_args.klass_being_used}'s initializer has required arguments, but has been used in #{uses_method_args.klass_with_uses.class} to initializer with no arguments passed to ::new. Please use initialize: with a Proc or :config_initializers to control how the instance is created"
8
+ end
9
+ ->() { uses_method_args.klass_being_used.new }
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ require_relative "base_initializer"
2
+
3
+ class Uses::Initializer::ProcBased < Uses::Initializer::BaseInitializer
4
+ def create_proc(uses_method_args)
5
+ uses_method_args.initializer_strategy
6
+ end
7
+ end
@@ -0,0 +1,35 @@
1
+ require_relative "error"
2
+ require_relative "initializer/new_no_args"
3
+ require_relative "initializer/from_initializers"
4
+ require_relative "initializer/proc_based"
5
+
6
+ module Uses
7
+ module Initializer
8
+ def self.from_args(uses_method_args)
9
+ strategy_klass(uses_method_args).new(uses_method_args)
10
+ end
11
+
12
+ private
13
+
14
+ def self.strategy_klass(uses_method_args)
15
+ case uses_method_args.initializer_strategy
16
+ when :new_no_args then NewNoArgs
17
+ when :config_initializers then FromInitializers
18
+ when Proc then ProcBased
19
+ else
20
+ raise UnknownInitializerStrategy.new(uses_method_args.initializer_strategy)
21
+ end
22
+ end
23
+
24
+
25
+ class UnknownInitializerStrategy < Uses::Error
26
+ def initialize(strategy)
27
+ if strategy.kind_of?(Symbol)
28
+ super("initialize: received #{strategy}, which is not supported. Should be either the symbol :config_initializers, a Proc, or simply omitted")
29
+ else
30
+ super("initialize: received a #{strategy.class}, which is not supported. Should be either the symbol :config_initializers, a Proc, or simply omitted")
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,76 @@
1
+ module Uses
2
+ # Convienience methods for test to inject mocks/doubles into a class under test.
3
+ #
4
+ # An advantage of "injecting" dependencies is that you can provide alternate implementations
5
+ # for testing that simplify your tests. While Ruby doesn't strictly require that you make
6
+ # dependencies injectible, it is nice to have a bit of help in doing to so for a test.
7
+ #
8
+ # If using RSpec, use `inject_rspec_double`.
9
+ module InjectDouble
10
+ # Inject an instantiated double into subject, returing the double.
11
+ #
12
+ # subject:: the instance of the class under test where you want a double injected
13
+ # injectsion:: a hash of size 1 where the key is the class given to `uses` and
14
+ # the value is the doubled object
15
+ def inject_double(subject, injections)
16
+ if injections.size != 1
17
+ raise "expected a single key/value to inject_double, but got #{injections.size}"
18
+ end
19
+
20
+ klass = injections.first[0]
21
+ instance = injections.first[1]
22
+
23
+ subject_must_use_uses!(subject)
24
+ injected_class_must_be_class!(klass)
25
+
26
+ name = dependency_method_name!(subject, klass)
27
+
28
+ subject.__uses_dependent_instances[name] = instance
29
+
30
+ instance
31
+ end
32
+
33
+ #
34
+ # For Rspec users, you might do:
35
+ #
36
+ # dependent_service = instance_doule(DependentService)
37
+ # allow(DependentService).to receive(:new).and_return(dependent_service)
38
+ #
39
+ # The problem is that it would be nice to know if your class under test actually uses the
40
+ # dependent service, plus it's annoying to have to write two lines of code.
41
+ #
42
+ # Instead:
43
+ #
44
+ # dependent_service = instance_double(object_under_test, DependentService)
45
+ #
46
+ # If you depend in DependentService, this will replace the real instance with yours. If you do not
47
+ # it will raise an error.
48
+ def inject_rspec_double(subject, klass)
49
+ self.inject_double(subject, klass => instance_double(klass))
50
+ end
51
+
52
+ private
53
+
54
+ def subject_must_use_uses!(subject)
55
+ if !subject.class.respond_to?(:__uses_dependent_classes)
56
+ raise Uses::Error, "#{subject.class} does not include Uses::Method, so you cannot inject a double into it"
57
+ end
58
+ end
59
+
60
+ def injected_class_must_be_class!(klass)
61
+ if !klass.kind_of?(Class)
62
+ raise Uses::Error, "Pass the actual class, not a #{klass.class}."
63
+ end
64
+ end
65
+
66
+ def dependency_method_name!(subject, klass)
67
+ name = subject.class.__uses_dependent_classes[klass].to_s
68
+
69
+ if name.blank?
70
+ raise Uses::Error, "#{subject.class} does not depend on a #{klass}, so there is no reason to inject a mock"
71
+ end
72
+ name
73
+ end
74
+
75
+ end
76
+ end
@@ -0,0 +1,5 @@
1
+ require_relative "error"
2
+ module Uses
3
+ class InvalidMethodName < Uses::Error
4
+ end
5
+ end
@@ -0,0 +1,87 @@
1
+ require "active_support/concern"
2
+ require "active_support/core_ext/string/inflections"
3
+
4
+ require_relative "config"
5
+ require_relative "method_name"
6
+ require_relative "initializer"
7
+ require_relative "uses_method_args"
8
+ require_relative "circular_dependency/analyzer"
9
+
10
+ module Uses
11
+ # Provides a very basic mechanism for dependency management between classes in your service
12
+ # layer. This is done via the method `uses`.
13
+ #
14
+ # The simplest use of this module is to create a base class for all classes in your service layer:
15
+ #
16
+ # # app/services/application_service.rb
17
+ # class ApplicationService
18
+ # include Uses::Method
19
+ # end
20
+ #
21
+ # Then, all services inherit from this and have access to the uses method.
22
+ #
23
+ # Note that any class or method without RubyDoc should be treated as private/internal, and you
24
+ # should not depend on.
25
+ module Method
26
+
27
+ extend ActiveSupport::Concern
28
+
29
+ class_methods do
30
+ # Declare that the class including the Uses::Method module depends on another
31
+ # class. This will create an instance method on this class that returns a memoized
32
+ # instance of the class passed in (klass).
33
+ #
34
+ # klass:: the class of what is dependend-upon.
35
+ # as:: if given, overrides the default naming for the instance method.
36
+ # By default (when as: is omitted or set to nil), the name will
37
+ # be `klass.underscore.gsub(/\//,"_")` (see Uses::MethodName),
38
+ # so for a class named SomeClass, it would be `some_class`, however for
39
+ # a class named SomeNamespace::SomeClass, it would be `some_namespace_some_class`.
40
+ # If you set a value for `as:` that value would be used instead of this auto-generated value.
41
+ # initialize: Controls how the instance is initialized:
42
+ # :new_no_args:: create the instance with `.new` an no args. This is the default, since
43
+ # most service-layer classes should not need initializer arguments.
44
+ # :config_initializers:: Indicates that an intiailzation proc has been previously
45
+ # configured and should be used. See ::initializers above.
46
+ # a Proc:: The `Proc` is called to return the new instance. Generally you would
47
+ # only use this if your class required special initialization but is only
48
+ # used in *this* class. Keep in mind that this couples the service with
49
+ # how to iniltialize its dependent, which is not often a good thing. But
50
+ # sometimes you have to.
51
+ def uses(klass, as: nil, initialize: :new_no_args)
52
+ uses_method_args = Uses::UsesMethodArgs.new(
53
+ klass_being_used: klass,
54
+ klass_with_uses: self,
55
+ method_name_override: as,
56
+ initializer_strategy: initialize,
57
+ uses_config: Uses.config
58
+ )
59
+
60
+ name = Uses::MethodName.new(uses_method_args)
61
+ circular_dependency_analyzer = Uses::CircularDependency::Analyzer.new(uses_method_args)
62
+ initializer = Uses::Initializer.from_args(uses_method_args)
63
+
64
+ circular_dependency_analyzer.analyze!
65
+
66
+ if circular_dependency_analyzer.circular_dependency?
67
+ circular_dependency_analyzer.notify!
68
+ end
69
+
70
+ self.__uses_dependent_classes[klass] = name
71
+
72
+ define_method name.to_s do
73
+ self.__uses_dependent_instances[name.to_s] ||= initializer.()
74
+ end
75
+ private name.to_s
76
+ end
77
+
78
+ def __uses_dependent_classes
79
+ @__uses_dependent_classes ||= {}
80
+ end
81
+ end
82
+
83
+ def __uses_dependent_instances
84
+ @__uses_dependent_instances ||= {}
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,23 @@
1
+ require_relative "invalid_method_name"
2
+ module Uses
3
+ class MethodName
4
+
5
+ def self.derive_method_name(klass)
6
+ klass.name.to_s.underscore.gsub(/\//,"_")
7
+ end
8
+
9
+ def initialize(uses_method_args)
10
+ @name = if uses_method_args.method_name_override.nil?
11
+ self.class.derive_method_name(uses_method_args.klass_being_used)
12
+ else
13
+ uses_method_args.method_name_override.to_s
14
+ end
15
+ if @name !~ /^[a-z0-9_]+$/
16
+ raise Uses::InvalidMethodName.new("Cannot determine a default name for #{uses_method_args.klass_being_used} used by #{uses_method_args.klass_with_uses}. Use as: to specify the name")
17
+ end
18
+ end
19
+ def to_s
20
+ @name
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ module Uses
2
+ class UsesMethodArgs
3
+
4
+ attr_reader :klass_being_used,
5
+ :klass_with_uses,
6
+ :method_name_override,
7
+ :initializer_strategy,
8
+ :uses_config
9
+
10
+ def initialize(klass_being_used:,
11
+ klass_with_uses:,
12
+ method_name_override:,
13
+ initializer_strategy:,
14
+ uses_config:)
15
+
16
+ @klass_being_used = klass_being_used
17
+ @klass_with_uses = klass_with_uses
18
+ @method_name_override = method_name_override
19
+ @initializer_strategy = initializer_strategy
20
+ @uses_config = uses_config
21
+
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,3 @@
1
+ module Uses
2
+ VERSION="1.0.0-beta1"
3
+ end
data/lib/uses.rb ADDED
@@ -0,0 +1,49 @@
1
+ module Uses
2
+ # Yields a hash of initializer, with the intention that you insert
3
+ # the initializer for your service into this hash. The key should be the class name
4
+ # that would be given to a `uses` invocation, and the value should be a proc
5
+ # that returns an instance of that class.
6
+ #
7
+ # The reason you would do this is if your service requires special setup beyond calling
8
+ # new without arguments. For example:
9
+ #
10
+ #
11
+ # require "uses"
12
+ # Uses.initializers do |initializers|
13
+ # initializers[Aws::S3::Client] = ->(*) {
14
+ # Aws::S3::Client.new(
15
+ # access_key_id: ENV["AWS_ACCESS_KEY_ID"],
16
+ # secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"],
17
+ # region: ENV["AWS_REGION"],
18
+ # )
19
+ # }
20
+ # end
21
+ #
22
+ # # Then, in a service that uses this:
23
+ #
24
+ # class MyService
25
+ # include Uses::Method
26
+ #
27
+ # uses Aws::S3::Client, as: :s3, initialize: :config_initializers
28
+ #
29
+ # def some_method
30
+ # s3.whatever # s3 has been initialized using the Proc above
31
+ # end
32
+ # end
33
+ def self.initializers
34
+ yield(config.initializers) if block_given?
35
+ config.initializers
36
+ end
37
+
38
+ # Yields the Uses::Config instance governing this
39
+ # gem's behavior. You should call this in an intializer.
40
+ # See Uses::Config for what options exist
41
+ def self.config
42
+ @@config ||= Uses::Config.new
43
+ yield(@@config) if block_given?
44
+ @@config
45
+ end
46
+
47
+ end
48
+ require_relative "uses/version"
49
+ require_relative "uses/method"
data/uses.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ require_relative "lib/uses/version"
2
+
3
+ Gem::Specification.new do |spec|
4
+ spec.name = "uses"
5
+ spec.version = Uses::VERSION
6
+ spec.authors = ["Dave Copeland"]
7
+ spec.email = ["davec@naildrivin5.com"]
8
+ spec.summary = %q{Declare that one classes uses an instance of another to help your code be a bit more sustainable}
9
+ spec.homepage = "https://github.com/sustainable-rails/uses"
10
+ spec.license = "Hippocratic"
11
+
12
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
13
+
14
+ spec.metadata["homepage_uri"] = spec.homepage
15
+ spec.metadata["source_code_uri"] = "https://github.com/sustainable-rails/uses"
16
+ spec.metadata["changelog_uri"] = "https://github.com/sustainable-rails/uses/releases"
17
+
18
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
19
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
20
+ end
21
+ spec.bindir = "exe"
22
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
23
+ spec.require_paths = ["lib"]
24
+
25
+ spec.add_dependency("activesupport")
26
+ spec.add_development_dependency("rspec")
27
+ spec.add_development_dependency("rspec_junit_formatter")
28
+ spec.add_development_dependency("confidence-check")
29
+ end