├── .rspec ├── VERSION ├── .ruby-gemset ├── .ruby-version ├── .document ├── spec ├── fixtures │ ├── car.rb │ ├── truck.rb │ ├── mini_van.rb │ ├── move_action_with_implicit_default_strategy.rb │ ├── move_action │ │ └── mini_van_strategy.rb │ ├── vehicle.rb │ ├── move_action_with_strategy_matcher │ │ ├── simple_strategy.rb │ │ ├── strategy_base.rb │ │ └── mini_van_strategy.rb │ ├── move_action_with_implicit_default_strategy │ │ └── default_strategy.rb │ ├── move_action.rb │ └── move_action_with_strategy_matcher.rb ├── spec_helper.rb └── lib │ └── strategic_spec.rb ├── .coveralls.yml ├── strategic-example.png ├── .travis.yml ├── Gemfile ├── TODO.md ├── .gitignore ├── LICENSE.txt ├── .github └── workflows │ └── ruby.yml ├── CHANGELOG.md ├── lib ├── strategic │ └── strategy.rb └── strategic.rb ├── Rakefile ├── strategic.gemspec ├── Gemfile.lock └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.2.0 2 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | strategic 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.0.2 2 | -------------------------------------------------------------------------------- /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /spec/fixtures/car.rb: -------------------------------------------------------------------------------- 1 | require_relative 'vehicle' 2 | 3 | class Car < Vehicle 4 | end 5 | -------------------------------------------------------------------------------- /.coveralls.yml: -------------------------------------------------------------------------------- 1 | service_name: travis-pro 2 | repo_token: 9X9eWfSz9GeFgWyhQ0NccLlr4A8xm7vKn 3 | -------------------------------------------------------------------------------- /spec/fixtures/truck.rb: -------------------------------------------------------------------------------- 1 | require_relative 'vehicle' 2 | 3 | class Truck < Vehicle 4 | end 5 | -------------------------------------------------------------------------------- /spec/fixtures/mini_van.rb: -------------------------------------------------------------------------------- 1 | require_relative 'vehicle' 2 | 3 | class MiniVan < Vehicle 4 | end 5 | -------------------------------------------------------------------------------- /strategic-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/AndyObtiva/strategic/HEAD/strategic-example.png -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_implicit_default_strategy.rb: -------------------------------------------------------------------------------- 1 | require 'strategic' 2 | 3 | class MoveActionWithImplicitDefaultStrategy 4 | include Strategic 5 | end 6 | -------------------------------------------------------------------------------- /spec/fixtures/move_action/mini_van_strategy.rb: -------------------------------------------------------------------------------- 1 | class MoveAction::MiniVanStrategy 2 | include Strategic::Strategy 3 | 4 | def move 5 | context.position += 9 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/vehicle.rb: -------------------------------------------------------------------------------- 1 | class Vehicle 2 | attr_reader :make, :model, :position 3 | 4 | def initialize(make:, model:, position: 0) 5 | @position = position 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_strategy_matcher/simple_strategy.rb: -------------------------------------------------------------------------------- 1 | require_relative 'strategy_base' 2 | 3 | class MoveActionWithStrategyMatcher::SimpleStrategy < MoveActionWithStrategyMatcher::StrategyBase 4 | end 5 | -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_strategy_matcher/strategy_base.rb: -------------------------------------------------------------------------------- 1 | class MoveActionWithStrategyMatcher::StrategyBase 2 | include Strategic::Strategy 3 | 4 | # default implementation 5 | def move 6 | context.position += 1 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | rvm: 3 | - 2.7.1 4 | - 2.6.6 5 | - 2.5.8 6 | - 2.4.10 7 | after_script: 8 | ruby -e "$(curl -s https://undercover-ci.com/uploader.rb)" -- --repo AndyObtiva/strategic --commit $TRAVIS_COMMIT --lcov coverage/lcov/strategic.lcov 9 | -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_implicit_default_strategy/default_strategy.rb: -------------------------------------------------------------------------------- 1 | class MoveActionWithImplicitDefaultStrategy 2 | class DefaultStrategy 3 | include Strategic::Strategy 4 | 5 | def move 6 | context.position += 10 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_strategy_matcher/mini_van_strategy.rb: -------------------------------------------------------------------------------- 1 | require_relative 'strategy_base' 2 | 3 | class MoveActionWithStrategyMatcher::MiniVanStrategy < MoveActionWithStrategyMatcher::StrategyBase 4 | strategy_exclusion 'm' 5 | 6 | def move 7 | context.position += 9 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/fixtures/move_action.rb: -------------------------------------------------------------------------------- 1 | require 'strategic' 2 | 3 | class MoveAction 4 | include Strategic 5 | 6 | NON_CLASS_CONSTANT = 23 # tests that it is excluded when discovring strategies 7 | 8 | class CarStrategy 9 | include Strategic::Strategy 10 | 11 | strategy_alias 'sedan' 12 | 13 | def move 14 | context.position += 10 15 | end 16 | end 17 | 18 | attr_accessor :position 19 | 20 | def initialize(position) 21 | @position = position 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'coveralls' 2 | Coveralls.wear! do 3 | add_filter(/^\/spec\//) 4 | end 5 | 6 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 7 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 8 | 9 | require 'rspec' 10 | 11 | # Requires supporting files with custom matchers and macros, etc, 12 | # in ./support/ and its subdirectories. 13 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].each {|f| require f} 14 | 15 | RSpec.configure do |config| 16 | 17 | end 18 | 19 | require 'puts_debuggerer' 20 | require 'strategic' 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | # Add dependencies required to use your gem here. 3 | # Example: 4 | # gem 'activesupport', '>= 2.3.5' 5 | 6 | # Add dependencies to develop your gem here. 7 | # Include everything needed to run rake, tests, features, etc. 8 | group :development do 9 | gem 'rspec', '~> 3.5.0' 10 | gem 'rspec-mocks', '~> 3.5.0' 11 | gem 'rdoc', '>= 3.12' 12 | gem 'bundler', '>= 1.0' 13 | gem 'jeweler', '~> 2.3.0' 14 | gem 'simplecov', '~> 0.16.1', require: false 15 | gem 'coveralls', '~> 0.8.23', require: false 16 | gem 'puts_debuggerer', '>= 0.8.1' 17 | gem 'rake-tui', '> 0' 18 | end 19 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - Consider providing the option to set a strategy as a block 4 | - Add `#strategy_name` method on `Strategic::Strategy` classes 5 | - Customize `strategy_name` attribute name (e.g. `sort_strategy_name`) 6 | - Support defining DefaultStrategy inside the Strategic class body (right now, it is expected to be an external class in an external file) 7 | - Support multiple strategies (with multiple strategy name attributes/columns) in Version 2.0 8 | - Configuration option of `Strategic::rails_auto_strategic = true` to indicate whether to include module mixins automatically by convention for Strategic and Strategic::Strategy in Rails applications (thus software engineer only has to create model_name/xyz_strategy.rb files and that enables strategies automatically assuming a strategy_name attribute or column on model) 9 | - Utilize `super_module` gem (once it is updated to not replay class methods automatically) 10 | - Support `strategy_owner` or `owner` as alias to context 11 | - Scaffold a strategy or a parent super class for multiple strategies 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # rcov generated 2 | coverage 3 | coverage.data 4 | 5 | # rdoc generated 6 | rdoc 7 | 8 | # yard generated 9 | doc 10 | .yardoc 11 | 12 | # bundler 13 | .bundle 14 | 15 | # jeweler generated 16 | pkg 17 | 18 | # Have editor/IDE/OS specific files you need to ignore? Consider using a global gitignore: 19 | # 20 | # * Create a file at ~/.gitignore 21 | # * Include files you want ignored 22 | # * Run: git config --global core.excludesfile ~/.gitignore 23 | # 24 | # After doing this, these files will be ignored in all your git projects, 25 | # saving you from having to 'pollute' every project you touch with them 26 | # 27 | # Not sure what to needs to be ignored for particular editors/OSes? Here's some ideas to get you started. (Remember, remove the leading # of the line) 28 | # 29 | # For MacOS: 30 | # 31 | #.DS_Store 32 | 33 | # For TextMate 34 | #*.tmproj 35 | #tmtags 36 | 37 | # For emacs: 38 | #*~ 39 | #\#* 40 | #.\#* 41 | 42 | # For vim: 43 | #*.swp 44 | 45 | # For redcar: 46 | #.redcar 47 | 48 | # For rubinius: 49 | #*.rbc 50 | 51 | # Gladiator (Glimmer Editor) 52 | .gladiator 53 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020-2021 Andy Maleh 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: rspec 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | jobs: 17 | test: 18 | 19 | runs-on: ubuntu-latest 20 | strategy: 21 | matrix: 22 | ruby-version: ['2.6', '2.7', '3.0'] 23 | 24 | steps: 25 | - uses: actions/checkout@v2 26 | - name: Set up Ruby 27 | # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby, 28 | # change this to (see https://github.com/ruby/setup-ruby#versioning): 29 | # uses: ruby/setup-ruby@v1 30 | uses: ruby/setup-ruby@473e4d8fe5dd94ee328fdfca9f8c9c7afc9dae5e 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 34 | - name: Run tests 35 | run: bundle exec rake 36 | -------------------------------------------------------------------------------- /spec/fixtures/move_action_with_strategy_matcher.rb: -------------------------------------------------------------------------------- 1 | require 'strategic' 2 | 3 | class MoveActionWithStrategyMatcher 4 | NON_CLASS_CONSTANT = 23 # tests that it is excluded when discovring strategies 5 | 6 | # fakes that a Rails ActiveRecord already has strategy_name column 7 | attr_reader :strategy_name 8 | class << self 9 | def column_names 10 | ['strategy_name'] 11 | end 12 | 13 | def after_initialize(method_symbol = nil) 14 | if method_symbol.nil? 15 | @after_initialize 16 | else 17 | @after_initialize = method_symbol 18 | end 19 | end 20 | end 21 | 22 | include Strategic 23 | 24 | default_strategy 'simple' 25 | 26 | strategy_matcher do |string_or_class_or_object, strategy_class| 27 | class_name = self.name 28 | strategy_name = class_name.split('::').last.sub(/Strategy$/, '').gsub(/([A-Z])/) {|letter| "_#{letter.downcase}"}[1..-1] 29 | strategy_name_length = strategy_name.length 30 | possible_keywords = strategy_name_length.times.map {|n| strategy_name.chars.combination(strategy_name_length - n).to_a}.reduce(:+).map(&:join) 31 | possible_keywords.include?(string_or_class_or_object) 32 | end 33 | 34 | class CarStrategy 35 | include Strategic::Strategy 36 | 37 | strategy_alias 'sedan' 38 | strategy_alias 'mini' 39 | 40 | strategy_matcher do |string_or_class_or_object, strategy_class| 41 | class_name = self.name 42 | strategy_name = class_name.split('::').last.sub(/Strategy$/, '').gsub(/([A-Z])/) {|letter| "_#{letter.downcase}"}[1..-1] 43 | strategy_name.capitalize == string_or_class_or_object 44 | end 45 | 46 | def move 47 | context.position += 10 48 | end 49 | end 50 | 51 | attr_accessor :position 52 | 53 | def initialize(position) 54 | @position = position 55 | end 56 | 57 | # simulate Rails []= method 58 | def []=(key, value) 59 | instance_variable_set("@#{key}", value) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 1.2.0 4 | 5 | - `default_strategy` default value will be `'default'`, assuming a `model_namespace/default_strategy.rb` file with `DefaultStrategy` class 6 | - `new_with_default_strategy` class method (instantiating with `'default'` strategy if not configured or `default_strategy` class method value if configured) 7 | 8 | ## 1.1.0 9 | 10 | - Generate `strategy_name` attribute on `Strategic` class if it does not already exist like in the case of a Rails migration column 11 | - Automatically set `strategy_name` attribute when setting `strategy` attribute (either `strategy_name` attribute in Ruby or column in Rails) 12 | - Load `strategy` attribute from `strategy_name` attribute on `after_initialize` in Rails 13 | 14 | ## 1.0.1 15 | 16 | - Fix error "undefined method `new' for Strategic::Strategy:Module" that occurs when setting an empty string strategy (must return nil or default strategy) 17 | - Fix issue with `ancestors` method not available on all constants (only ones that are classes/modules) 18 | 19 | ## 1.0.0 20 | 21 | - Improve design to better match the authentic Gang of Four Strategy Pattern with `Strategic::Strategy` module, removing the need for inheritance. 22 | - `#strategy=`/`#strategy` enable setting/getting strategy on model 23 | - `#context` enables getting strategic model instance on strategy just as per the GOF Design Pattern 24 | - `default_strategy` class body method to set default strategy 25 | - Filter strategies by ones ending with `Strategy` in class name 26 | 27 | ## 0.9.1 28 | 29 | - `strategy_name` returns parsed strategy name of current strategy class 30 | - `strategy_matcher` ignores a strategy if it found another strategy already matching by strategy_alias 31 | 32 | ## 0.9.0 33 | 34 | - `strategy_matcher` block support that enables any strategy to specify a custom matcher (or the superclass of all strategies instead) 35 | - `strategy_exclusion` class method support that enables any strategy to specify exclusions from the custom `strategy_matcher` 36 | - `strategy_alias` class method support that enables any strategy to specify extra aliases (used by superclass's `strategy_class_for` method) 37 | 38 | ## 0.8.0 39 | 40 | - Initial version with `strategy_class_for`, `new_strategy`, `strategies`, and `strategy_names` 41 | -------------------------------------------------------------------------------- /lib/strategic/strategy.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020-2021 Andy Maleh 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | module Strategic 23 | module Strategy 24 | def self.included(klass) 25 | klass.extend(ClassMethods) 26 | end 27 | 28 | module ClassMethods 29 | def strategy_alias(alias_string_or_class_or_object) 30 | strategy_aliases << alias_string_or_class_or_object 31 | end 32 | 33 | def strategy_aliases 34 | @strategy_aliases ||= [] 35 | end 36 | 37 | def strategy_exclusion(exclusion_string_or_class_or_object) 38 | strategy_exclusions << exclusion_string_or_class_or_object 39 | end 40 | 41 | def strategy_exclusions 42 | @strategy_exclusions ||= [] 43 | end 44 | 45 | def strategy_matcher(&matcher_block) 46 | if block_given? 47 | @strategy_matcher = matcher_block 48 | else 49 | @strategy_matcher 50 | end 51 | end 52 | 53 | def strategy_name 54 | Strategic.underscore(name.split(':').last).sub(/_strategy$/, '') 55 | end 56 | end 57 | 58 | attr_reader :context 59 | 60 | def initialize(context) 61 | @context = context 62 | end 63 | 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | 3 | require 'rubygems' 4 | require 'bundler' 5 | begin 6 | Bundler.setup(:default, :development) 7 | rescue Bundler::BundlerError => e 8 | $stderr.puts e.message 9 | $stderr.puts "Run `bundle install` to install missing gems" 10 | exit e.status_code 11 | end 12 | require 'rake' 13 | 14 | require 'jeweler' 15 | Jeweler::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options 17 | gem.name = "strategic" 18 | gem.homepage = "http://github.com/AndyObtiva/strategic" 19 | gem.license = "MIT" 20 | gem.summary = %Q{Painless Strategy Pattern for Ruby and Rails} 21 | gem.description = <<-MULTI 22 | if/case conditionals can get really hairy in highly sophisticated business domains. 23 | Domain model inheritance can help remedy the problem, but you don't want to dump all 24 | logic variations in the same domain models. 25 | Strategy Pattern solves that problem by externalizing logic variations to 26 | separate classes outside the domain models. 27 | One difficulty with implementing Strategy Pattern is making domain models aware 28 | of newly added strategies without touching their code (Open/Closed Principle). 29 | Strategic solves that problem by supporting Strategy Pattern with automatic discovery 30 | of strategies and ability fetch the right strategy without conditionals. 31 | This allows you to make any domain model "strategic" by simply following a convention 32 | in the directory/namespace structure you create your strategies under so that the domain 33 | model automatically discovers all available strategies. 34 | MULTI 35 | gem.email = "andy.am@gmail.com" 36 | gem.authors = ["Andy Maleh"] 37 | gem.files = Dir['lib/**/*.rb'] 38 | # dependencies defined in Gemfile 39 | end 40 | Jeweler::RubygemsDotOrgTasks.new 41 | 42 | require 'rspec/core' 43 | require 'rspec/core/rake_task' 44 | RSpec::Core::RakeTask.new(:spec) do |spec| 45 | spec.pattern = FileList['spec/**/*_spec.rb'] 46 | end 47 | 48 | desc "Code coverage detail" 49 | task :simplecov do 50 | ENV['COVERAGE'] = "true" 51 | Rake::Task['spec'].execute 52 | end 53 | 54 | task :default => :spec 55 | 56 | require 'rdoc/task' 57 | Rake::RDocTask.new do |rdoc| 58 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 59 | 60 | rdoc.rdoc_dir = 'rdoc' 61 | rdoc.title = "strategic #{version}" 62 | rdoc.rdoc_files.include('README*') 63 | rdoc.rdoc_files.include('lib/**/*.rb') 64 | end 65 | 66 | require 'coveralls/rake/task' 67 | Coveralls::RakeTask.new 68 | task :spec_with_coveralls => [:spec] do 69 | ENV['TRAVIS'] = 'true' 70 | ENV['CI'] = 'true' if ENV['CI'].nil? 71 | Rake::Task['coveralls:push'].invoke 72 | end 73 | -------------------------------------------------------------------------------- /strategic.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by jeweler 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Jeweler::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: strategic 1.2.0 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "strategic".freeze 9 | s.version = "1.2.0" 10 | 11 | s.required_rubygems_version = Gem::Requirement.new(">= 0".freeze) if s.respond_to? :required_rubygems_version= 12 | s.require_paths = ["lib".freeze] 13 | s.authors = ["Andy Maleh".freeze] 14 | s.date = "2022-01-23" 15 | s.description = "if/case conditionals can get really hairy in highly sophisticated business domains.\nDomain model inheritance can help remedy the problem, but you don't want to dump all\nlogic variations in the same domain models.\nStrategy Pattern solves that problem by externalizing logic variations to\nseparate classes outside the domain models.\nOne difficulty with implementing Strategy Pattern is making domain models aware\nof newly added strategies without touching their code (Open/Closed Principle).\nStrategic solves that problem by supporting Strategy Pattern with automatic discovery\nof strategies and ability fetch the right strategy without conditionals.\nThis allows you to make any domain model \"strategic\" by simply following a convention\nin the directory/namespace structure you create your strategies under so that the domain\nmodel automatically discovers all available strategies.\n".freeze 16 | s.email = "andy.am@gmail.com".freeze 17 | s.extra_rdoc_files = [ 18 | "CHANGELOG.md", 19 | "LICENSE.txt", 20 | "README.md" 21 | ] 22 | s.files = [ 23 | "lib/strategic.rb", 24 | "lib/strategic/strategy.rb" 25 | ] 26 | s.homepage = "http://github.com/AndyObtiva/strategic".freeze 27 | s.licenses = ["MIT".freeze] 28 | s.rubygems_version = "3.3.1".freeze 29 | s.summary = "Painless Strategy Pattern for Ruby and Rails".freeze 30 | 31 | if s.respond_to? :specification_version then 32 | s.specification_version = 4 33 | end 34 | 35 | if s.respond_to? :add_runtime_dependency then 36 | s.add_development_dependency(%q.freeze, ["~> 3.5.0"]) 37 | s.add_development_dependency(%q.freeze, ["~> 3.5.0"]) 38 | s.add_development_dependency(%q.freeze, [">= 3.12"]) 39 | s.add_development_dependency(%q.freeze, [">= 1.0"]) 40 | s.add_development_dependency(%q.freeze, ["~> 2.3.0"]) 41 | s.add_development_dependency(%q.freeze, ["~> 0.16.1"]) 42 | s.add_development_dependency(%q.freeze, ["~> 0.8.23"]) 43 | s.add_development_dependency(%q.freeze, [">= 0.8.1"]) 44 | s.add_development_dependency(%q.freeze, ["> 0"]) 45 | else 46 | s.add_dependency(%q.freeze, ["~> 3.5.0"]) 47 | s.add_dependency(%q.freeze, ["~> 3.5.0"]) 48 | s.add_dependency(%q.freeze, [">= 3.12"]) 49 | s.add_dependency(%q.freeze, [">= 1.0"]) 50 | s.add_dependency(%q.freeze, ["~> 2.3.0"]) 51 | s.add_dependency(%q.freeze, ["~> 0.16.1"]) 52 | s.add_dependency(%q.freeze, ["~> 0.8.23"]) 53 | s.add_dependency(%q.freeze, [">= 0.8.1"]) 54 | s.add_dependency(%q.freeze, ["> 0"]) 55 | end 56 | end 57 | 58 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | addressable (2.4.0) 5 | awesome_print (1.9.2) 6 | builder (3.2.4) 7 | coveralls (0.8.23) 8 | json (>= 1.8, < 3) 9 | simplecov (~> 0.16.1) 10 | term-ansicolor (~> 1.3) 11 | thor (>= 0.19.4, < 2.0) 12 | tins (~> 1.6) 13 | descendants_tracker (0.0.4) 14 | thread_safe (~> 0.3, >= 0.3.1) 15 | diff-lcs (1.5.0) 16 | docile (1.4.0) 17 | faraday (0.9.2) 18 | multipart-post (>= 1.2, < 3) 19 | git (1.10.2) 20 | rchardet (~> 1.8) 21 | github_api (0.16.0) 22 | addressable (~> 2.4.0) 23 | descendants_tracker (~> 0.0.4) 24 | faraday (~> 0.8, < 0.10) 25 | hashie (>= 3.4) 26 | mime-types (>= 1.16, < 3.0) 27 | oauth2 (~> 1.0) 28 | hashie (5.0.0) 29 | highline (2.0.3) 30 | jeweler (2.3.9) 31 | builder 32 | bundler 33 | git (>= 1.2.5) 34 | github_api (~> 0.16.0) 35 | highline (>= 1.6.15) 36 | nokogiri (>= 1.5.10) 37 | psych 38 | rake 39 | rdoc 40 | semver2 41 | json (2.6.1) 42 | jwt (2.3.0) 43 | mime-types (2.99.3) 44 | mini_portile2 (2.7.1) 45 | multi_json (1.15.0) 46 | multi_xml (0.6.0) 47 | multipart-post (2.1.1) 48 | nokogiri (1.13.1) 49 | mini_portile2 (~> 2.7.0) 50 | racc (~> 1.4) 51 | oauth2 (1.4.7) 52 | faraday (>= 0.8, < 2.0) 53 | jwt (>= 1.0, < 3.0) 54 | multi_json (~> 1.3) 55 | multi_xml (~> 0.5) 56 | rack (>= 1.2, < 3) 57 | pastel (0.8.0) 58 | tty-color (~> 0.5) 59 | psych (4.0.3) 60 | stringio 61 | puts_debuggerer (0.13.2) 62 | awesome_print (~> 1.9.2) 63 | racc (1.6.0) 64 | rack (2.2.3) 65 | rake (13.0.6) 66 | rake-tui (0.2.3) 67 | tty-prompt 68 | rchardet (1.8.0) 69 | rdoc (6.4.0) 70 | psych (>= 4.0.0) 71 | rspec (3.5.0) 72 | rspec-core (~> 3.5.0) 73 | rspec-expectations (~> 3.5.0) 74 | rspec-mocks (~> 3.5.0) 75 | rspec-core (3.5.4) 76 | rspec-support (~> 3.5.0) 77 | rspec-expectations (3.5.0) 78 | diff-lcs (>= 1.2.0, < 2.0) 79 | rspec-support (~> 3.5.0) 80 | rspec-mocks (3.5.0) 81 | diff-lcs (>= 1.2.0, < 2.0) 82 | rspec-support (~> 3.5.0) 83 | rspec-support (3.5.0) 84 | semver2 (3.4.2) 85 | simplecov (0.16.1) 86 | docile (~> 1.1) 87 | json (>= 1.8, < 3) 88 | simplecov-html (~> 0.10.0) 89 | simplecov-html (0.10.2) 90 | stringio (3.0.1) 91 | sync (0.5.0) 92 | term-ansicolor (1.7.1) 93 | tins (~> 1.0) 94 | thor (1.2.1) 95 | thread_safe (0.3.6) 96 | tins (1.31.0) 97 | sync 98 | tty-color (0.6.0) 99 | tty-cursor (0.7.1) 100 | tty-prompt (0.23.1) 101 | pastel (~> 0.8) 102 | tty-reader (~> 0.8) 103 | tty-reader (0.9.0) 104 | tty-cursor (~> 0.7) 105 | tty-screen (~> 0.8) 106 | wisper (~> 2.0) 107 | tty-screen (0.8.1) 108 | wisper (2.0.1) 109 | 110 | PLATFORMS 111 | ruby 112 | 113 | DEPENDENCIES 114 | bundler (>= 1.0) 115 | coveralls (~> 0.8.23) 116 | jeweler (~> 2.3.0) 117 | puts_debuggerer (>= 0.8.1) 118 | rake-tui (> 0) 119 | rdoc (>= 3.12) 120 | rspec (~> 3.5.0) 121 | rspec-mocks (~> 3.5.0) 122 | simplecov (~> 0.16.1) 123 | 124 | BUNDLED WITH 125 | 2.3.5 126 | -------------------------------------------------------------------------------- /lib/strategic.rb: -------------------------------------------------------------------------------- 1 | # Copyright (c) 2020-2021 Andy Maleh 2 | # 3 | # Permission is hereby granted, free of charge, to any person obtaining 4 | # a copy of this software and associated documentation files (the 5 | # "Software"), to deal in the Software without restriction, including 6 | # without limitation the rights to use, copy, modify, merge, publish, 7 | # distribute, sublicense, and/or sell copies of the Software, and to 8 | # permit persons to whom the Software is furnished to do so, subject to 9 | # the following conditions: 10 | # 11 | # The above copyright notice and this permission notice shall be 12 | # included in all copies or substantial portions of the Software. 13 | # 14 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | # EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | # MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | # NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | # LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | # OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | 22 | module Strategic 23 | def self.included(klass) 24 | klass.extend(ClassMethods) 25 | klass.require_strategies 26 | rails_mode = klass.respond_to?(:column_names) && klass.column_names.include?('strategy_name') 27 | if rails_mode 28 | klass.include(ExtraRailsMethods) 29 | klass.after_initialize :reload_strategy 30 | else 31 | klass.include(ExtraRubyMethods) 32 | end 33 | klass.default_strategy 'default' 34 | end 35 | 36 | module ExtraRailsMethods 37 | def strategy_name=(string) 38 | self['strategy_name'] = string 39 | strategy_class = self.class.strategy_class_for(string) 40 | @strategy = strategy_class&.new(self) 41 | end 42 | end 43 | 44 | module ExtraRubyMethods 45 | attr_reader :strategy_name 46 | 47 | def strategy_name=(string) 48 | @strategy_name = string 49 | strategy_class = self.class.strategy_class_for(string) 50 | @strategy = strategy_class&.new(self) 51 | end 52 | end 53 | 54 | module ClassMethods 55 | def strategy_matcher(&matcher_block) 56 | if matcher_block.nil? 57 | @strategy_matcher 58 | else 59 | @strategy_matcher = matcher_block 60 | end 61 | end 62 | 63 | def default_strategy(string_or_class_or_object = nil) 64 | if string_or_class_or_object.nil? 65 | @default_strategy 66 | else 67 | @default_strategy = strategy_class_for(string_or_class_or_object) 68 | end 69 | end 70 | 71 | def strategy_matcher_for_any_strategy? 72 | !!(strategy_matcher || strategies.any?(&:strategy_matcher)) 73 | end 74 | 75 | def require_strategies 76 | klass_path = caller[1].split(':').first 77 | strategy_path = File.expand_path(File.join(klass_path, '..', Strategic.underscore(self.name), '**', '*.rb')) 78 | Dir.glob(strategy_path) do |strategy| 79 | Object.const_defined?(:Rails) ? require_dependency(strategy) : require(strategy) 80 | end 81 | end 82 | 83 | def strategy_class_for(string_or_class_or_object) 84 | strategy_class = strategy_matcher_for_any_strategy? ? strategy_class_with_strategy_matcher(string_or_class_or_object) : strategy_class_without_strategy_matcher(string_or_class_or_object) 85 | strategy_class ||= strategies.detect { |strategy| strategy.strategy_aliases.include?(string_or_class_or_object) } 86 | strategy_class ||= default_strategy 87 | end 88 | 89 | def strategy_class_with_strategy_matcher(string_or_class_or_object) 90 | strategies.detect do |strategy| 91 | match = strategy.strategy_aliases.include?(string_or_class_or_object) 92 | match ||= strategy&.strategy_matcher&.call(string_or_class_or_object) || (strategy_matcher && strategy.instance_exec(string_or_class_or_object, &strategy_matcher)) 93 | # match unless excluded or included by another strategy as an alias 94 | match unless strategy.strategy_exclusions.include?(string_or_class_or_object) || (strategies - [strategy]).map(&:strategy_aliases).flatten.include?(string_or_class_or_object) 95 | end 96 | end 97 | 98 | def strategy_class_without_strategy_matcher(string_or_class_or_object) 99 | if string_or_class_or_object.is_a?(String) 100 | strategy_class_name = string_or_class_or_object.downcase 101 | elsif string_or_class_or_object.is_a?(Class) 102 | strategy_class_name = string_or_class_or_object.name 103 | else 104 | strategy_class_name = string_or_class_or_object.class.name 105 | end 106 | return nil if strategy_class_name.to_s.strip.empty? 107 | begin 108 | class_name = "::#{self.name}::#{Strategic.classify(strategy_class_name)}Strategy" 109 | class_eval(class_name) 110 | rescue NameError 111 | # No Op 112 | end 113 | end 114 | 115 | def new_with_default_strategy(*args, &block) 116 | new(*args, &block).tap do |model| 117 | model.strategy = nil 118 | end 119 | end 120 | 121 | def new_with_strategy(string_or_class_or_object, *args, &block) 122 | new(*args, &block).tap do |model| 123 | model.strategy = string_or_class_or_object 124 | end 125 | end 126 | 127 | def strategies 128 | constants.map do |constant_symbol| 129 | const_get(constant_symbol) 130 | end.select do |constant| 131 | constant.respond_to?(:ancestors) 132 | end.select do |constant| 133 | constant.ancestors.include?(Strategic::Strategy) && constant.name.split('::').last.end_with?('Strategy') && constant.name.split('::').last != 'Strategy' # has to be something like PrefixStrategy 134 | end.sort_by(&:strategy_name) 135 | end 136 | 137 | def strategy_names 138 | strategies.map(&:strategy_name) 139 | end 140 | 141 | end 142 | 143 | def strategy=(string_or_class_or_object) 144 | strategy_class = self.class.strategy_class_for(string_or_class_or_object) 145 | self.strategy_name = strategy_class&.strategy_name 146 | end 147 | 148 | def strategy 149 | @strategy 150 | end 151 | 152 | def reload_strategy 153 | self.strategy = strategy_name 154 | end 155 | 156 | def method_missing(method_name, *args, &block) 157 | if strategy&.respond_to?(method_name, *args, &block) 158 | strategy.send(method_name, *args, &block) 159 | else 160 | begin 161 | super 162 | rescue => e 163 | raise "No strategy is set to handle the method #{method_name} with args #{args.inspect} and block #{block.inspect} / " + e.message 164 | end 165 | end 166 | end 167 | 168 | def respond_to?(method_name, *args, &block) 169 | strategy&.respond_to?(method_name, *args, &block) || super 170 | end 171 | 172 | private 173 | 174 | def self.classify(text) 175 | text.split("_").map {|word| "#{word[0].upcase}#{word[1..-1]}"}.join 176 | end 177 | 178 | def self.underscore(text) 179 | text.chars.reduce('') {|output,c| !output.empty? && c.match(/[A-Z]/) ? output + '_' + c : output + c}.downcase 180 | end 181 | end 182 | 183 | require_relative 'strategic/strategy' 184 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Strategic 1.2.0 2 | ## Painless Strategy Pattern in Ruby and Rails 3 | [![Gem Version](https://badge.fury.io/rb/strategic.svg)](http://badge.fury.io/rb/strategic) 4 | [![rspec](https://github.com/AndyObtiva/strategic/actions/workflows/ruby.yml/badge.svg)](https://github.com/AndyObtiva/strategic/actions/workflows/ruby.yml) 5 | [![Coverage Status](https://coveralls.io/repos/github/AndyObtiva/strategic/badge.svg?branch=master)](https://coveralls.io/github/AndyObtiva/strategic?branch=master) 6 | [![Maintainability](https://api.codeclimate.com/v1/badges/0e638e392c21500c4fbe/maintainability)](https://codeclimate.com/github/AndyObtiva/strategic/maintainability) 7 | 8 | (Featured in [DCR Programming Language](https://github.com/AndyObtiva/dcr)) 9 | 10 | `if`/`case` conditionals can get really hairy in highly sophisticated business domains. 11 | Object-oriented inheritance helps remedy the problem, but dumping all 12 | logic variations in domain model subclasses can cause a maintenance nightmare. 13 | Thankfully, the Strategy Pattern as per the [Gang of Four book](https://www.amazon.com/Design-Patterns-Elements-Reusable-Object-Oriented/dp/0201633612) solves the problem by externalizing logic via composition to separate classes outside the domain models. 14 | 15 | Still, there are a number of challenges with "repeated implementation" of the Strategy Pattern: 16 | - Making domain models aware of newly added strategies without touching their 17 | code (Open/Closed Principle). 18 | - Fetching the right strategy without the use of conditionals. 19 | - Avoiding duplication of strategy dispatch code for multiple domain models 20 | - Have strategies mirror an existing domain model inheritance hierarchy 21 | 22 | `strategic` solves these problems by offering: 23 | - Strategy Pattern support through a Ruby mixin and strategy path/name convention 24 | - Automatic discovery of strategies based on path/name convention 25 | - Ability to fetch needed strategy without use of conditionals 26 | - Ability to fetch a strategy by name or by object type to mirror 27 | - Plain Ruby and Ruby on Rails support 28 | 29 | `Strategic` enables you to make any existing domain model "strategic", 30 | externalizing all logic concerning algorithmic variations into separate strategy 31 | classes that are easy to find, maintain and extend while honoring the Open/Closed Principle and avoiding conditionals. 32 | 33 | In summary, if you make a class called `TaxCalculator` strategic by including the `Strategic` mixin module, now you are able to drop strategies under the `tax_calculator` directory sitting next to the class (e.g. `tax_calculator/us_strategy.rb`, `tax_calculator/canada_strategy.rb`) while gaining extra [API](#api) methods to grab strategy names to present in a user interface (`.strategy_names`), set a strategy (`#strategy=(strategy_name)` or `#strategy_name=(strategy_name)`), and/or instantiate `TaxCalculator` directly (`.new(*initialize_args)`), with default strategy (`.new_with_default_strategy(*initialize_args)`), or with a strategy from the get-go (`.new_with_strategy(strategy_name, *initialize_args)`). Finally, you can simply invoke strategy methods on the main strategic model (e.g. `tax_calculator.tax_for(39.78)`). 34 | 35 | ### Example 36 | 37 | Strategic Example 39 | 40 | 1. Include `Strategic` module in the Class to strategize: `TaxCalculator` 41 | 42 | ```ruby 43 | class TaxCalculator 44 | include Strategic 45 | 46 | # strategies implement tax_for(amount) method that can be invoked indirectly on strategic model 47 | end 48 | ``` 49 | 50 | 2. Now, you can add strategies under this directory without having to modify the original class: `tax_calculator` 51 | 52 | 3. Add strategy classes having names ending with `Strategy` by convention (e.g. `UsStrategy`) under the namespace matching the original class name (`TaxCalculator::` as in `tax_calculator/us_strategy.rb` representing `TaxCalculator::UsStrategy`) and including the module (`Strategic::Strategy`): 53 | 54 | All strategies get access to their context (strategic model instance), which they can use in their logic. 55 | 56 | ```ruby 57 | class TaxCalculator::UsStrategy 58 | include Strategic::Strategy 59 | 60 | def tax_for(amount) 61 | amount * state_rate(context.state) 62 | end 63 | # ... other strategy methods follow 64 | end 65 | 66 | class TaxCalculator::CanadaStrategy 67 | include Strategic::Strategy 68 | 69 | def tax_for(amount) 70 | amount * (gst(context.province) + qst(context.province)) 71 | end 72 | # ... other strategy methods follow 73 | end 74 | ``` 75 | 76 | (note: if you use strategy inheritance hierarchies, make sure to have strategy base classes end with `StrategyBase` to avoid getting picked up as strategies) 77 | 78 | 4. In client code, set the strategy by underscored string reference minus the word strategy (e.g. UsStrategy becomes simply 'us'): 79 | 80 | ```ruby 81 | tax_calculator = TaxCalculator.new(args) 82 | tax_calculator.strategy = 'us' 83 | ``` 84 | 85 | 4a. Alternatively, instantiate the strategic model with a strategy to begin with: 86 | 87 | ```ruby 88 | tax_calculator = TaxCalculator.new_with_strategy('us', args) 89 | ``` 90 | 91 | 4b. Alternatively in Rails, instantiate or create an ActiveRecord model with `strategy_name` column attribute included in args (you may generate migration for `strategy_name` column via `rails g migration add_strategy_name_to_resources strategy_name:string`): 92 | 93 | ```ruby 94 | tax_calculator = TaxCalculator.create(args) # args include strategy_name 95 | ``` 96 | 97 | 5. Invoke the strategy implemented method: 98 | 99 | ```ruby 100 | tax = tax_calculator.tax_for(39.78) 101 | ``` 102 | 103 | Default strategy for a strategy name that has no strategy class is `nil` unless the `DefaultStrategy` class exists under the model class namespace or the `default_strategy` class attribute is set. 104 | 105 | This is how to set a default strategy on a strategic model via class method `default_strategy`: 106 | 107 | ```ruby 108 | class TaxCalculator 109 | include Strategic 110 | 111 | default_strategy 'canada' 112 | # ... initialize and other methods 113 | end 114 | 115 | tax_calculator = TaxCalculator.new(args) 116 | tax = tax_calculator.tax_for(39.78) 117 | ``` 118 | 119 | If no strategy is selected and you try to invoke a method that belongs to strategies, Ruby raises an amended method missing error informing you that no strategy is set to handle the method (in case it was a strategy method). 120 | 121 | ## Setup 122 | 123 | ### Option 1: Bundler 124 | 125 | Add the following to bundler's `Gemfile`. 126 | 127 | ```ruby 128 | gem 'strategic', '~> 1.2.0' 129 | ``` 130 | 131 | ### Option 2: Manual 132 | 133 | Or manually install and require library. 134 | 135 | ```bash 136 | gem install strategic -v1.2.0 137 | ``` 138 | 139 | ```ruby 140 | require 'strategic' 141 | ``` 142 | 143 | ### Usage 144 | 145 | Steps: 146 | 1. Have the original class you'd like to strategize include `Strategic` (e.g. `def TaxCalculator; include Strategic; end` 147 | 2. Create a directory matching the class underscored file name minus the '.rb' extension (e.g. `tax_calculator/`) 148 | 3. Create a strategy class under that directory (e.g. `tax_calculator/us_strategy.rb`) (default is assumed as `tax_calculator/default_strategy.rb` unless customized with `default_strategy` class method): 149 | - Lives under the original class namespace 150 | - Includes the `Strategic::Strategy` module 151 | - Has a class name that ends with `Strategy` suffix (e.g. `NewCustomerStrategy`) 152 | 4. Set strategy on strategic model using `strategy=` attribute writer method or instantiate with `new_with_strategy` class method, which takes a strategy name string (any case), strategy class, or mirror object (having a class matching strategy name minus the word `Strategy`) (note: you can call `::strategy_names` class method to obtain available strategy names or `::stratgies` to obtain available strategy classes) 153 | 5. Alternatively in Rails, create migration `rails g migration add_strategy_name_to_resources strategy_name:string` and set strategy via `strategy_name` column, storing in database. On load of the model, the right strategy is automatically loaded based on `strategy_name` column. 154 | 6. Invoke strategy method needed 155 | 156 | ## API 157 | 158 | ### Strategic model 159 | 160 | #### Class Body Methods 161 | 162 | These methods can be delcared in a strategic model class body. 163 | 164 | - `::default_strategy(strategy_name)`: sets default strategy as a strategy name string (e.g. 'us' selects UsStrategy) or alternatively a class/object if you have a mirror hierarchy for the strategy hierarchy 165 | - `::default_strategy`: returns default strategy (default: `'default'` as in `DefaultStrategy`) 166 | - `::strategy_matcher`: custom matcher for all strategies (e.g. `strategy_matcher {|string| string.start_with?('C') && string.end_with?('o')}`) 167 | 168 | #### Class Methods 169 | 170 | - `::strategy_names`: returns list of strategy names (strings) discovered by convention (nested under a namespace matching the superclass name) 171 | - `::strategies`: returns list of strategies discovered by convention (nested under a namespace matching the superclass name) 172 | - `::new_with_strategy(string_or_class_or_object, *args, &block)`: instantiates a strategy based on a string/class/object and strategy constructor args 173 | - `::new_with_default_strategy(*args, &block)`: instantiates with default strategy 174 | - `::strategy_class_for(string_or_class_or_object)`: selects a strategy class based on a string (e.g. 'us' selects UsStrategy) or alternatively a class/object if you have a mirror hierarchy for the strategy hierarchy 175 | 176 | #### Instance Methods 177 | 178 | - `#strategy=`: sets strategy 179 | - `#strategy`: returns current strategy 180 | 181 | ### Strategy 182 | 183 | #### Class Body Methods 184 | 185 | - `::strategy_matcher`: custom matcher for a specific strategy (e.g. `strategy_matcher {|string| string.start_with?('C') && string.end_with?('o')}`) 186 | - `::strategy_exclusion`: exclusion from custom matcher (e.g. `strategy_exclusion 'Cio'`) 187 | - `::strategy_alias`: alias for strategy in addition to strategy's name derived from class name by convention (e.g. `strategy_alias 'USA'` for `UsStrategy`) 188 | 189 | #### Class Methods 190 | 191 | - `::strategy_name`: returns parsed strategy name of current strategy class 192 | 193 | #### Instance Methods 194 | 195 | - `#context`: returns strategy context (the strategic model instance) 196 | 197 | ### Example with Customizations via Class Body Methods 198 | 199 | ```ruby 200 | class TaxCalculator 201 | default_strategy 'us' 202 | 203 | # fuzz matcher 204 | strategy_matcher do |string_or_class_or_object| 205 | class_name = self.name # current strategy class name being tested for matching 206 | strategy_name = class_name.split('::').last.sub(/Strategy$/, '').gsub(/([A-Z])/) {|letter| "_#{letter.downcase}"}[1..-1] 207 | strategy_name_length = strategy_name.length 208 | possible_keywords = strategy_name_length.times.map {|n| strategy_name.chars.combination(strategy_name_length - n).to_a}.reduce(:+).map(&:join) 209 | possible_keywords.include?(string_or_class_or_object) 210 | end 211 | # ... more code follows 212 | end 213 | 214 | class TaxCalculator::UsStrategy 215 | include Strategic::Strategy 216 | 217 | strategy_alias 'USA' 218 | strategy_exclusion 'U' 219 | 220 | # ... strategy methods follow 221 | end 222 | 223 | class TaxCalculator::CanadaStrategy 224 | include Strategic::Strategy 225 | 226 | # ... strategy methods follow 227 | end 228 | ``` 229 | 230 | ## TODO 231 | 232 | [TODO.md](TODO.md) 233 | 234 | ## Change Log 235 | 236 | [CHANGELOG.md](CHANGELOG.md) 237 | 238 | ## Contributing 239 | 240 | * Check out the latest master to make sure the feature hasn't been implemented or the bug hasn't been fixed yet. 241 | * Check out the issue tracker to make sure someone already hasn't requested it and/or contributed it. 242 | * Fork the project. 243 | * Change directory into project 244 | * Run `gem install bundler && bundle && rake` and make sure RSpec tests are passing 245 | * Start a feature/bugfix branch. 246 | * Write RSpec tests, Code, Commit and push until you are happy with your contribution. 247 | * Make sure to add tests for it. This is important so I don't break it in a future version unintentionally. 248 | * Please try not to mess with the Rakefile, version, or history. If you want to have your own version, or is otherwise necessary, that is fine, but please isolate to its own commit so I can cherry-pick around it. 249 | 250 | ## License 251 | 252 | [MIT](LICENSE.txt) 253 | 254 | Copyright (c) 2020-2021 Andy Maleh. 255 | -------------------------------------------------------------------------------- /spec/lib/strategic_spec.rb: -------------------------------------------------------------------------------- 1 | require_relative '../spec_helper' 2 | require_relative '../fixtures/vehicle' 3 | require_relative '../fixtures/car' 4 | require_relative '../fixtures/mini_van' 5 | require_relative '../fixtures/move_action' 6 | require_relative '../fixtures/move_action_with_strategy_matcher' 7 | require_relative '../fixtures/move_action_with_implicit_default_strategy' 8 | 9 | RSpec.describe Strategic do 10 | let(:vehicle_attributes) { {make: 'NASA', model: 'Mars Curiosity Rover'} } 11 | let(:car_attributes) { {make: 'Mitsubishi', model: 'Eclipse'} } 12 | let(:mini_van_attributes) { {make: 'Toyota', model: 'Tundra'} } 13 | 14 | let(:vehicle) { Vehicle.new(**vehicle_attributes) } 15 | let(:car) { Car.new(**vehicle_attributes) } 16 | let(:mini_van) { MiniVan.new(**mini_van_attributes) } 17 | 18 | let(:position) { 0 } 19 | 20 | describe '.strategy_class_for' do 21 | context 'strategy name' do 22 | it 'returns strategy' do 23 | expect(MoveAction.strategy_class_for('car')).to eq(MoveAction::CarStrategy) 24 | expect(MoveAction.strategy_class_for('mini_van')).to eq(MoveAction::MiniVanStrategy) 25 | expect(MoveAction.strategy_class_for('default')).to be_nil 26 | end 27 | end 28 | 29 | context 'class name' do 30 | it 'returns strategy' do 31 | expect(MoveAction.strategy_class_for(Car)).to eq(MoveAction::CarStrategy) 32 | expect(MoveAction.strategy_class_for(MiniVan)).to eq(MoveAction::MiniVanStrategy) 33 | expect(MoveAction.strategy_class_for(Vehicle)).to be_nil 34 | end 35 | end 36 | 37 | context 'object type' do 38 | it 'returns strategy' do 39 | expect(MoveAction.strategy_class_for(car)).to eq(MoveAction::CarStrategy) 40 | expect(MoveAction.strategy_class_for(mini_van)).to eq(MoveAction::MiniVanStrategy) 41 | expect(MoveAction.strategy_class_for(vehicle)).to be_nil 42 | end 43 | end 44 | end 45 | 46 | describe '.new_with_strategy' do 47 | context 'strategy name' do 48 | it 'returns strategy' do 49 | expect(MoveAction.new_with_strategy('car', position).strategy).to be_a(MoveAction::CarStrategy) 50 | expect(MoveAction.new_with_strategy('car', position).strategy_name).to eq('car') 51 | expect(MoveAction.new_with_strategy('sedan', position).strategy).to be_a(MoveAction::CarStrategy) 52 | expect(MoveAction.new_with_strategy('sedan', position).strategy_name).to eq('car') 53 | expect(MoveAction.new_with_strategy('MINI_VAN', position).strategy).to be_a(MoveAction::MiniVanStrategy) 54 | expect(MoveAction.new_with_strategy('MINI_VAN', position).strategy_name).to eq('mini_van') 55 | expect(MoveAction.new_with_strategy('invalid name returns default strategy', position).strategy).to be_nil 56 | expect(MoveAction.new_with_strategy('invalid name returns default strategy', position).strategy_name).to be_nil 57 | expect(MoveAction.new_with_default_strategy(position).strategy).to be_nil 58 | expect(MoveAction.new_with_default_strategy(position).strategy_name).to be_nil 59 | 60 | expect(MoveActionWithStrategyMatcher.new_with_strategy('Car', position).strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 61 | expect(MoveActionWithStrategyMatcher.new_with_strategy('Car', position).strategy_name).to eq('car') 62 | expect(MoveActionWithStrategyMatcher.new_with_strategy('sedan', position).strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 63 | expect(MoveActionWithStrategyMatcher.new_with_strategy('sedan', position).strategy_name).to eq('car') 64 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini', position).strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 65 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini', position).strategy_name).to eq('car') 66 | 67 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini_van', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 68 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini_va', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 69 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini_v', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 70 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mini_', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 71 | expect(MoveActionWithStrategyMatcher.new_with_strategy('min', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 72 | expect(MoveActionWithStrategyMatcher.new_with_strategy('mi', position).strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 73 | expect(MoveActionWithStrategyMatcher.new_with_strategy('m', position).strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 74 | 75 | expect(MoveActionWithStrategyMatcher.new_with_strategy('invalid name returns default strategy', position).strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 76 | expect(MoveActionWithStrategyMatcher.new_with_strategy('invalid name returns default strategy', position).strategy_name).to eq('simple') 77 | expect(MoveActionWithStrategyMatcher.new_with_strategy('', position).strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 78 | expect(MoveActionWithStrategyMatcher.new_with_strategy('', position).strategy_name).to eq('simple') 79 | expect(MoveActionWithStrategyMatcher.new_with_default_strategy(position).strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 80 | expect(MoveActionWithStrategyMatcher.new_with_default_strategy(position).strategy_name).to eq('simple') 81 | 82 | expect(MoveActionWithImplicitDefaultStrategy.new_with_strategy('').strategy_name).to eq('default') 83 | expect(MoveActionWithImplicitDefaultStrategy.new_with_default_strategy.strategy_name).to eq('default') 84 | end 85 | end 86 | 87 | context 'class name' do 88 | it 'returns strategy' do 89 | expect(MoveAction.new_with_strategy(Car, position).strategy).to be_a(MoveAction::CarStrategy) 90 | expect(MoveAction.new_with_strategy(MiniVan, position).strategy).to be_a(MoveAction::MiniVanStrategy) 91 | expect(MoveAction.new_with_strategy(Vehicle, position).strategy).to be_nil 92 | end 93 | end 94 | 95 | context 'object type' do 96 | it 'returns strategy' do 97 | expect(MoveAction.new_with_strategy(car, position).strategy).to be_a(MoveAction::CarStrategy) 98 | expect(MoveAction.new_with_strategy(mini_van, position).strategy).to be_a(MoveAction::MiniVanStrategy) 99 | expect(MoveAction.new_with_strategy(vehicle, position).strategy).to be_nil 100 | end 101 | end 102 | end 103 | 104 | describe '::strategies' do 105 | it 'returns all loaded strategies' do 106 | expect(MoveAction.strategies).to match_array([MoveAction::CarStrategy, MoveAction::MiniVanStrategy]) 107 | end 108 | end 109 | 110 | describe '::strategy_names' do 111 | it 'returns all loaded strategy names' do 112 | expect(MoveAction.strategy_names).to eq(['car', 'mini_van']) 113 | end 114 | 115 | it 'returns all loaded strategy names for model with custom strategy matcher' do 116 | expect(MoveActionWithStrategyMatcher.strategy_names).to eq(['car', 'mini_van', 'simple']) 117 | end 118 | end 119 | 120 | describe '::strategy_name' do 121 | it 'returns all loaded strategy names' do 122 | expect(MoveAction::CarStrategy.strategy_name).to eq('car') 123 | expect(MoveAction::MiniVanStrategy.strategy_name).to eq('mini_van') 124 | end 125 | end 126 | 127 | describe '#strategy=' do 128 | let(:model) {MoveAction.new(position)} 129 | let(:model_with_strategy_matcher) {MoveActionWithStrategyMatcher.new(position)} 130 | 131 | it 'sets strategy on model instance' do 132 | model.strategy = 'car' 133 | expect(model.strategy).to be_a(MoveAction::CarStrategy) 134 | expect(model.strategy_name).to eq('car') 135 | expect(model.strategy.context).to eq(model) 136 | model.move 137 | expect(model.position).to eq(10) 138 | 139 | model.position = 0 140 | model.strategy = 'sedan' 141 | expect(model.strategy).to be_a(MoveAction::CarStrategy) 142 | expect(model.strategy_name).to eq('car') 143 | expect(model.strategy.context).to eq(model) 144 | model.move 145 | expect(model.position).to eq(10) 146 | 147 | model.position = 0 148 | model.strategy = 'MINI_VAN' 149 | expect(model.strategy).to be_a(MoveAction::MiniVanStrategy) 150 | expect(model.strategy_name).to eq('mini_van') 151 | expect(model.strategy.context).to eq(model) 152 | model.move 153 | expect(model.position).to eq(9) 154 | 155 | model.position = 0 156 | model.strategy = 'invalid name returns default strategy' 157 | expect(model.strategy).to be_nil 158 | expect(model.strategy_name).to be_nil 159 | expect {model.move}.to raise_error 160 | expect(model.position).to eq(0) 161 | 162 | model.position = 0 163 | model.strategy = '' 164 | expect(model.strategy).to be_nil 165 | expect(model.strategy_name).to be_nil 166 | expect {model.move}.to raise_error 167 | expect(model.position).to eq(0) 168 | end 169 | 170 | it 'sets strategy on model instance with strategy matcher and default strategy' do 171 | model_with_strategy_matcher.strategy = 'car' 172 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 173 | expect(model_with_strategy_matcher.strategy_name).to eq('car') 174 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 175 | model_with_strategy_matcher.move 176 | expect(model_with_strategy_matcher.position).to eq(10) 177 | 178 | model_with_strategy_matcher.position = 0 179 | model_with_strategy_matcher.strategy = 'sedan' 180 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 181 | expect(model_with_strategy_matcher.strategy_name).to eq('car') 182 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 183 | model_with_strategy_matcher.move 184 | expect(model_with_strategy_matcher.position).to eq(10) 185 | 186 | model_with_strategy_matcher.position = 0 187 | model_with_strategy_matcher.strategy = 'mini' 188 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::CarStrategy) 189 | expect(model_with_strategy_matcher.strategy_name).to eq('car') 190 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 191 | model_with_strategy_matcher.move 192 | expect(model_with_strategy_matcher.position).to eq(10) 193 | 194 | model_with_strategy_matcher.position = 0 195 | model_with_strategy_matcher.strategy = 'mini_van' 196 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 197 | expect(model_with_strategy_matcher.strategy_name).to eq('mini_van') 198 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 199 | model_with_strategy_matcher.move 200 | expect(model_with_strategy_matcher.position).to eq(9) 201 | 202 | model_with_strategy_matcher.position = 0 203 | model_with_strategy_matcher.strategy = 'mini_va' 204 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 205 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 206 | model_with_strategy_matcher.move 207 | expect(model_with_strategy_matcher.position).to eq(9) 208 | 209 | model_with_strategy_matcher.position = 0 210 | model_with_strategy_matcher.strategy = 'mini_v' 211 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 212 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 213 | model_with_strategy_matcher.move 214 | expect(model_with_strategy_matcher.position).to eq(9) 215 | 216 | model_with_strategy_matcher.position = 0 217 | model_with_strategy_matcher.strategy = 'mini_' 218 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 219 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 220 | model_with_strategy_matcher.move 221 | expect(model_with_strategy_matcher.position).to eq(9) 222 | 223 | model_with_strategy_matcher.position = 0 224 | model_with_strategy_matcher.strategy = 'min' 225 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 226 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 227 | model_with_strategy_matcher.move 228 | expect(model_with_strategy_matcher.position).to eq(9) 229 | 230 | model_with_strategy_matcher.position = 0 231 | model_with_strategy_matcher.strategy = 'mi' 232 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::MiniVanStrategy) 233 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 234 | model_with_strategy_matcher.move 235 | expect(model_with_strategy_matcher.position).to eq(9) 236 | 237 | model_with_strategy_matcher.position = 0 238 | model_with_strategy_matcher.strategy = 'm' 239 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 240 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 241 | model_with_strategy_matcher.move 242 | expect(model_with_strategy_matcher.position).to eq(1) 243 | 244 | model_with_strategy_matcher.position = 0 245 | model_with_strategy_matcher.strategy = 'invalid name returns default strategy' 246 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 247 | expect(model_with_strategy_matcher.strategy_name).to eq('simple') 248 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 249 | model_with_strategy_matcher.move 250 | expect(model_with_strategy_matcher.position).to eq(1) 251 | 252 | model_with_strategy_matcher.position = 0 253 | model_with_strategy_matcher.strategy = '' 254 | expect(model_with_strategy_matcher.strategy).to be_a(MoveActionWithStrategyMatcher::SimpleStrategy) 255 | expect(model_with_strategy_matcher.strategy_name).to eq('simple') 256 | expect(model_with_strategy_matcher.strategy.context).to eq(model_with_strategy_matcher) 257 | model_with_strategy_matcher.move 258 | expect(model_with_strategy_matcher.position).to eq(1) 259 | end 260 | end 261 | end 262 | --------------------------------------------------------------------------------