uses 1.0.0.pre.beta1

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