├── .document ├── .github └── workflows │ └── ruby.yml ├── .gitignore ├── .rspec ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── VERSION ├── lib ├── patterns.rb ├── patterns │ ├── calculation.rb │ ├── collection.rb │ ├── form.rb │ ├── query.rb │ ├── rule.rb │ ├── ruleset.rb │ ├── service.rb │ └── strong_ruleset.rb └── rails-patterns.rb ├── rails-patterns.gemspec └── spec ├── helpers ├── custom_calculation.rb ├── custom_calculation_script.rb └── rails_redis_cache_mock.rb ├── patterns ├── calculation_spec.rb ├── collection_spec.rb ├── form_spec.rb ├── query_spec.rb ├── rule_spec.rb ├── ruleset_spec.rb ├── service_spec.rb └── strong_ruleset_spec.rb └── spec_helper.rb /.document: -------------------------------------------------------------------------------- 1 | lib/**/*.rb 2 | bin/* 3 | - 4 | features/**/*.feature 5 | LICENSE.txt 6 | -------------------------------------------------------------------------------- /.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: Ruby 9 | 10 | on: 11 | push: 12 | branches: [master] 13 | pull_request: 14 | branches: [master] 15 | 16 | jobs: 17 | test: 18 | runs-on: ubuntu-latest 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | ruby: ["2.7", "3.0"] 23 | name: RSpec for Ruby version ${{ matrix.ruby }} 24 | steps: 25 | - uses: actions/checkout@v3 26 | - uses: supercharge/redis-github-action@1.1.0 27 | - name: Set up Ruby 28 | uses: ruby/setup-ruby@v1 29 | with: 30 | ruby-version: ${{ matrix.ruby }} 31 | - run: gem install bundler 32 | - run: bundle install 33 | - run: bundle exec rspec 34 | -------------------------------------------------------------------------------- /.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 | # juwelier 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 | .idea 33 | 34 | # For TextMate 35 | #*.tmproj 36 | #tmtags 37 | 38 | # For emacs: 39 | #*~ 40 | #\#* 41 | #.\#* 42 | 43 | # For vim: 44 | #*.swp 45 | 46 | # For redcar: 47 | #.redcar 48 | 49 | # For rubinius: 50 | #*.rbc 51 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | gem "activerecord", ">= 4.2.6" 4 | gem "actionpack", ">= 4.2.6" 5 | gem "virtus" 6 | gem "ruby2_keywords" 7 | 8 | # Add dependencies to develop your gem here. 9 | # Include everything needed to run rake, tests, features, etc. 10 | 11 | group :development do 12 | gem "rspec" 13 | gem "bundler", "~> 2.0" 14 | gem "juwelier" 15 | end 16 | 17 | group "test" do 18 | gem "pry-rails" 19 | gem "rspec_junit_formatter" 20 | gem "redis" 21 | end 22 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | actionpack (6.1.4.1) 5 | actionview (= 6.1.4.1) 6 | activesupport (= 6.1.4.1) 7 | rack (~> 2.0, >= 2.0.9) 8 | rack-test (>= 0.6.3) 9 | rails-dom-testing (~> 2.0) 10 | rails-html-sanitizer (~> 1.0, >= 1.2.0) 11 | actionview (6.1.4.1) 12 | activesupport (= 6.1.4.1) 13 | builder (~> 3.1) 14 | erubi (~> 1.4) 15 | rails-dom-testing (~> 2.0) 16 | rails-html-sanitizer (~> 1.1, >= 1.2.0) 17 | activemodel (6.1.4.1) 18 | activesupport (= 6.1.4.1) 19 | activerecord (6.1.4.1) 20 | activemodel (= 6.1.4.1) 21 | activesupport (= 6.1.4.1) 22 | activesupport (6.1.4.1) 23 | concurrent-ruby (~> 1.0, >= 1.0.2) 24 | i18n (>= 1.6, < 2) 25 | minitest (>= 5.1) 26 | tzinfo (~> 2.0) 27 | zeitwerk (~> 2.3) 28 | addressable (2.8.0) 29 | public_suffix (>= 2.0.2, < 5.0) 30 | axiom-types (0.1.1) 31 | descendants_tracker (~> 0.0.4) 32 | ice_nine (~> 0.11.0) 33 | thread_safe (~> 0.3, >= 0.3.1) 34 | builder (3.2.4) 35 | coderay (1.1.3) 36 | coercible (1.0.0) 37 | descendants_tracker (~> 0.0.1) 38 | concurrent-ruby (1.1.9) 39 | crass (1.0.6) 40 | descendants_tracker (0.0.4) 41 | thread_safe (~> 0.3, >= 0.3.1) 42 | diff-lcs (1.4.4) 43 | erubi (1.10.0) 44 | faraday (1.8.0) 45 | faraday-em_http (~> 1.0) 46 | faraday-em_synchrony (~> 1.0) 47 | faraday-excon (~> 1.1) 48 | faraday-httpclient (~> 1.0.1) 49 | faraday-net_http (~> 1.0) 50 | faraday-net_http_persistent (~> 1.1) 51 | faraday-patron (~> 1.0) 52 | faraday-rack (~> 1.0) 53 | multipart-post (>= 1.2, < 3) 54 | ruby2_keywords (>= 0.0.4) 55 | faraday-em_http (1.0.0) 56 | faraday-em_synchrony (1.0.0) 57 | faraday-excon (1.1.0) 58 | faraday-httpclient (1.0.1) 59 | faraday-net_http (1.0.1) 60 | faraday-net_http_persistent (1.2.0) 61 | faraday-patron (1.0.0) 62 | faraday-rack (1.0.0) 63 | git (1.9.1) 64 | rchardet (~> 1.8) 65 | github_api (0.19.0) 66 | addressable (~> 2.4) 67 | descendants_tracker (~> 0.0.4) 68 | faraday (>= 0.8, < 2) 69 | hashie (~> 3.5, >= 3.5.2) 70 | oauth2 (~> 1.0) 71 | hashie (3.6.0) 72 | highline (2.0.3) 73 | i18n (1.8.11) 74 | concurrent-ruby (~> 1.0) 75 | ice_nine (0.11.2) 76 | juwelier (2.4.9) 77 | builder 78 | bundler 79 | git 80 | github_api 81 | highline 82 | kamelcase (~> 0) 83 | nokogiri 84 | psych 85 | rake 86 | rdoc 87 | semver2 88 | jwt (2.3.0) 89 | kamelcase (0.0.2) 90 | semver2 (~> 3) 91 | loofah (2.13.0) 92 | crass (~> 1.0.2) 93 | nokogiri (>= 1.5.9) 94 | method_source (1.0.0) 95 | mini_portile2 (2.6.1) 96 | minitest (5.14.4) 97 | multi_json (1.15.0) 98 | multi_xml (0.6.0) 99 | multipart-post (2.1.1) 100 | nokogiri (1.12.5) 101 | mini_portile2 (~> 2.6.1) 102 | racc (~> 1.4) 103 | oauth2 (1.4.7) 104 | faraday (>= 0.8, < 2.0) 105 | jwt (>= 1.0, < 3.0) 106 | multi_json (~> 1.3) 107 | multi_xml (~> 0.5) 108 | rack (>= 1.2, < 3) 109 | pry (0.14.1) 110 | coderay (~> 1.1) 111 | method_source (~> 1.0) 112 | pry-rails (0.3.9) 113 | pry (>= 0.10.4) 114 | psych (4.0.2) 115 | public_suffix (4.0.6) 116 | racc (1.6.0) 117 | rack (2.2.3) 118 | rack-test (1.1.0) 119 | rack (>= 1.0, < 3) 120 | rails-dom-testing (2.0.3) 121 | activesupport (>= 4.2.0) 122 | nokogiri (>= 1.6) 123 | rails-html-sanitizer (1.4.2) 124 | loofah (~> 2.3) 125 | rake (13.0.6) 126 | rchardet (1.8.0) 127 | rdoc (6.3.3) 128 | redis (4.5.1) 129 | rspec (3.10.0) 130 | rspec-core (~> 3.10.0) 131 | rspec-expectations (~> 3.10.0) 132 | rspec-mocks (~> 3.10.0) 133 | rspec-core (3.10.1) 134 | rspec-support (~> 3.10.0) 135 | rspec-expectations (3.10.1) 136 | diff-lcs (>= 1.2.0, < 2.0) 137 | rspec-support (~> 3.10.0) 138 | rspec-mocks (3.10.2) 139 | diff-lcs (>= 1.2.0, < 2.0) 140 | rspec-support (~> 3.10.0) 141 | rspec-support (3.10.3) 142 | rspec_junit_formatter (0.4.1) 143 | rspec-core (>= 2, < 4, != 2.12.0) 144 | ruby2_keywords (0.0.5) 145 | semver2 (3.4.2) 146 | thread_safe (0.3.6) 147 | tzinfo (2.0.4) 148 | concurrent-ruby (~> 1.0) 149 | virtus (2.0.0) 150 | axiom-types (~> 0.1) 151 | coercible (~> 1.0) 152 | descendants_tracker (~> 0.0, >= 0.0.3) 153 | zeitwerk (2.5.1) 154 | 155 | PLATFORMS 156 | ruby 157 | 158 | DEPENDENCIES 159 | actionpack (>= 4.2.6) 160 | activerecord (>= 4.2.6) 161 | bundler (~> 2.0) 162 | juwelier 163 | pry-rails 164 | redis 165 | rspec 166 | rspec_junit_formatter 167 | ruby2_keywords 168 | virtus 169 | 170 | BUNDLED WITH 171 | 2.1.4 172 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Stevo 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![](https://github.com/Selleo/pattern/workflows/Ruby/badge.svg) 2 | 3 | # Pattern 4 | 5 | A collection of lightweight, standardized, rails-oriented patterns used by [RubyOnRails Developers @ Selleo](https://selleo.com/ruby-on-rails) 6 | 7 | - [Query - complex querying on active record relation](#query) 8 | - [Service - useful for handling processes involving multiple steps](#service) 9 | - [Collection - when in need to add a method that relates to the collection as whole](#collection) 10 | - [Form - when you need a place for callbacks, want to replace strong parameters or handle virtual/composite resources](#form) 11 | - [Calculation - when you need a place for calculating a simple value (numeric, array, hash) and/or cache it](#calculation) 12 | - [Rule and Ruleset - when you need a place for conditional logic](#rule-and-ruleset) 13 | 14 | ## Installation 15 | 16 | ```ruby 17 | # Gemfile 18 | 19 | #... 20 | gem "rails-patterns" 21 | #... 22 | ``` 23 | 24 | Then `bundle install` 25 | 26 | ## Query 27 | 28 | ### When to use it 29 | 30 | One should consider using query objects pattern when in need to perform complex querying on active record relation. 31 | Usually one should avoid using scopes for such purpose. 32 | As a rule of thumb, if scope interacts with more than one column and/or joins in other tables, it should be moved to query object. 33 | Also whenever a chain of scopes is to be used, one should consider using query object too. 34 | Some more information on using query objects can be found in [this article](https://medium.com/@blazejkosmowski/essential-rubyonrails-patterns-part-2-query-objects-4b253f4f4539). 35 | 36 | ### Assumptions and rules 37 | 38 | * Query objects are always used by calling class-level `.call` method 39 | * Query objects require `ActiveRecord::Relation` or `ActiveRecord::Base` as constructor argument 40 | * Default relation (see above) can be defined by using `queries` macro 41 | * Query objects have to implement `#query` method that returns `ActiveRecord::Relation` 42 | * Query objects provide access to consecutive keyword arguments using `#options` hash 43 | 44 | ### Other 45 | 46 | Because of the fact, that QueryObject implements `.call` method, those can be used to construct scopes if required. ([read more...](http://craftingruby.com/posts/2015/06/29/query-objects-through-scopes.html)) 47 | 48 | ### Examples 49 | 50 | #### Declaration 51 | 52 | ```ruby 53 | class RecentlyActivatedUsersQuery < Patterns::Query 54 | queries User 55 | 56 | private 57 | 58 | def query 59 | relation.active.where(activated_at: date_range) 60 | end 61 | 62 | def date_range 63 | options.fetch(:date_range, default_date_range) 64 | end 65 | 66 | def default_date_range 67 | Date.yesterday.beginning_of_day..Date.today.end_of_day 68 | end 69 | end 70 | ``` 71 | 72 | #### Usage 73 | 74 | ```ruby 75 | RecentlyActivatedUsersQuery.call 76 | RecentlyActivatedUsersQuery.call(User.without_test_users) 77 | RecentlyActivatedUsersQuery.call(date_range: Date.today.beginning_of_day..Date.today.end_of_day) 78 | RecentlyActivatedUsersQuery.call(User.without_test_users, date_range: Date.today.beginning_of_day..Date.today.end_of_day) 79 | 80 | class User < ApplicationRecord 81 | scope :recently_activated, RecentlyActivatedUsersQuery 82 | end 83 | ``` 84 | 85 | ## Service 86 | 87 | ### When to use it 88 | 89 | Service objects are commonly used to mitigate problems with model callbacks that interact with external classes ([read more...](http://samuelmullen.com/2013/05/the-problem-with-rails-callbacks/)). 90 | Service objects are also useful for handling processes involving multiple steps. E.g. a controller that performs more than one operation on its subject (usually a model instance) is a possible candidate for Extract ServiceObject (or Extract FormObject) refactoring. In many cases service object can be used as scaffolding for [replace method with object refactoring](https://sourcemaking.com/refactoring/replace-method-with-method-object). Some more information on using services can be found in [this article](https://medium.com/selleo/essential-rubyonrails-patterns-part-1-service-objects-1af9f9573ca1). 91 | 92 | ### Assumptions and rules 93 | 94 | * Service objects are always used by calling class-level `.call` method 95 | * Service objects have to implement `#call` method 96 | * Calling service object's `.call` method executes `#call` and returns service object instance 97 | * A result of `#call` method is accessible through `#result` method 98 | * It is recommended for `#call` method to be the only public method of service object (besides state readers) 99 | * It is recommended to name service object classes after commands (e.g. `ActivateUser` instead of `UserActivation`) 100 | 101 | ### Other 102 | 103 | A bit higher level of abstraction is provided by [business_process gem](https://github.com/Selleo/business_process). 104 | 105 | ### Examples 106 | 107 | #### Declaration 108 | 109 | ```ruby 110 | class ActivateUser < Patterns::Service 111 | def initialize(user) 112 | @user = user 113 | end 114 | 115 | def call 116 | user.activate! 117 | NotificationsMailer.user_activation_notification(user).deliver_now 118 | user 119 | end 120 | 121 | private 122 | 123 | attr_reader :user 124 | end 125 | ``` 126 | 127 | #### Usage 128 | 129 | ```ruby 130 | user_activation = ActivateUser.call(user) 131 | user_activation.result # 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 'juwelier' 15 | Juwelier::Tasks.new do |gem| 16 | # gem is a Gem::Specification... see http://guides.rubygems.org/specification-reference/ for more options 17 | gem.name = "rails-patterns" 18 | gem.homepage = "http://github.com/selleo/pattern" 19 | gem.license = "MIT" 20 | gem.summary = "A collection of lightweight, standardized, rails-oriented patterns." 21 | gem.description = "A collection of lightweight, standardized, rails-oriented patterns." 22 | gem.email = "b.kosmowski@selleo.com" 23 | gem.authors = ["Stevo"] 24 | gem.required_ruby_version = ">= 2.7.0" 25 | 26 | # dependencies defined in Gemfile 27 | end 28 | Juwelier::RubygemsDotOrgTasks.new 29 | 30 | require 'rake/testtask' 31 | Rake::TestTask.new(:test) do |test| 32 | test.libs << 'lib' << 'test' 33 | test.pattern = 'test/**/test_*.rb' 34 | test.verbose = true 35 | end 36 | 37 | desc "Code coverage detail" 38 | task :simplecov do 39 | ENV['COVERAGE'] = "true" 40 | Rake::Task['test'].execute 41 | end 42 | 43 | task :default => :test 44 | 45 | require 'rdoc/task' 46 | Rake::RDocTask.new do |rdoc| 47 | version = File.exist?('VERSION') ? File.read('VERSION') : "" 48 | 49 | rdoc.rdoc_dir = 'rdoc' 50 | rdoc.title = "pattern #{version}" 51 | rdoc.rdoc_files.include('README*') 52 | rdoc.rdoc_files.include('lib/**/*.rb') 53 | end 54 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 0.12.0 2 | -------------------------------------------------------------------------------- /lib/patterns.rb: -------------------------------------------------------------------------------- 1 | module Patterns 2 | end 3 | -------------------------------------------------------------------------------- /lib/patterns/calculation.rb: -------------------------------------------------------------------------------- 1 | require 'digest' 2 | 3 | module Patterns 4 | class Calculation 5 | class_attribute :cache_expiry_every 6 | 7 | def initialize(*args) 8 | @options = args.extract_options! 9 | @subject = args.first 10 | end 11 | 12 | def self.result(*args, &block) 13 | new(*args).cached_result(&block) 14 | end 15 | 16 | class << self 17 | alias_method :result_for, :result 18 | alias_method :calculate, :result 19 | end 20 | 21 | def self.set_cache_expiry_every(period) 22 | self.cache_expiry_every = period 23 | end 24 | 25 | def cached_result(&block) 26 | if cache_expiry_period.blank? 27 | result(&block) 28 | else 29 | Rails.cache.fetch(cache_key, expires_in: cache_expiry_period) do 30 | result(&block) 31 | end 32 | end 33 | end 34 | 35 | private 36 | 37 | attr_reader :subject, :options 38 | 39 | def result 40 | raise NotImplementedError 41 | end 42 | 43 | def cache_key 44 | "#{self.class.name}_#{hash_of(subject, options)}" 45 | end 46 | 47 | def self.hash_of(*args) 48 | Digest::SHA1.hexdigest(args.map(&:to_s).join(':')) 49 | end 50 | 51 | def hash_of(*args) 52 | self.class.hash_of(*args) 53 | end 54 | 55 | def cache_expiry_period 56 | self.class.cache_expiry_every 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/patterns/collection.rb: -------------------------------------------------------------------------------- 1 | module Patterns 2 | class Collection 3 | include Enumerable 4 | 5 | def initialize(*args) 6 | @options = args.extract_options! 7 | @subject = args.first 8 | end 9 | 10 | def each 11 | collection.each do |*args| 12 | yield(*args) 13 | end 14 | end 15 | 16 | class << self 17 | alias from new 18 | alias for new 19 | end 20 | 21 | private 22 | 23 | attr_reader :options, :subject 24 | 25 | def collection 26 | raise NotImplementedError, "#collection not implemented" 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/patterns/form.rb: -------------------------------------------------------------------------------- 1 | require "virtus" 2 | require "action_controller/metal/strong_parameters" 3 | 4 | module Patterns 5 | class Form 6 | include Virtus.model 7 | include ActiveModel::Validations 8 | 9 | Error = Class.new(StandardError) 10 | Invalid = Class.new(Error) 11 | NoParamKey = Class.new(Error) 12 | 13 | def initialize(*args) 14 | attributes = args.extract_options! 15 | 16 | if attributes.blank? && args.last.is_a?(ActionController::Parameters) 17 | attributes = args.pop.to_unsafe_h 18 | end 19 | 20 | @resource = args.first 21 | 22 | super(build_original_attributes.merge(attributes)) 23 | end 24 | 25 | def save 26 | valid? ? persist : false 27 | end 28 | 29 | def save! 30 | save.tap do |saved| 31 | raise Invalid unless saved 32 | end 33 | end 34 | 35 | def as(form_owner) 36 | @form_owner = form_owner 37 | self 38 | end 39 | 40 | def to_key 41 | nil 42 | end 43 | 44 | def to_partial_path 45 | nil 46 | end 47 | 48 | def to_model 49 | self 50 | end 51 | 52 | def to_param 53 | if resource.present? && resource.respond_to?(:to_param) 54 | resource.to_param 55 | else 56 | nil 57 | end 58 | end 59 | 60 | def persisted? 61 | if resource.present? && resource.respond_to?(:persisted?) 62 | resource.persisted? 63 | else 64 | false 65 | end 66 | end 67 | 68 | def model_name 69 | @model_name ||= OpenStruct.new(model_name_attributes) 70 | end 71 | 72 | def self.param_key(key = nil) 73 | if key.nil? 74 | @param_key 75 | else 76 | @param_key = key 77 | end 78 | end 79 | 80 | private 81 | 82 | attr_reader :resource, :form_owner 83 | 84 | def model_name_attributes 85 | if self.class.param_key.present? 86 | { 87 | param_key: self.class.param_key, 88 | route_key: self.class.param_key.pluralize, 89 | singular_route_key: self.class.param_key 90 | } 91 | elsif resource.present? && resource.respond_to?(:model_name) 92 | { 93 | param_key: resource.model_name.param_key, 94 | route_key: resource.model_name.route_key, 95 | singular_route_key: resource.model_name.singular_route_key 96 | } 97 | else 98 | raise NoParamKey 99 | end 100 | end 101 | 102 | def build_original_attributes 103 | return {} if resource.nil? 104 | base_attributes = resource.respond_to?(:attributes) && resource.attributes.symbolize_keys 105 | 106 | self.class.attribute_set.each_with_object(base_attributes || {}) do |attribute, result| 107 | if result[attribute.name].blank? && resource.respond_to?(attribute.name) 108 | result[attribute.name] = resource.public_send(attribute.name) 109 | end 110 | end 111 | end 112 | 113 | def persist 114 | raise NotImplementedError, "#persist has to be implemented" 115 | end 116 | end 117 | end 118 | -------------------------------------------------------------------------------- /lib/patterns/query.rb: -------------------------------------------------------------------------------- 1 | require 'active_record' 2 | require 'ruby2_keywords' 3 | 4 | module Patterns 5 | class Query 6 | RelationRequired = Class.new(StandardError) 7 | 8 | def initialize(*args) 9 | @options = args.extract_options! 10 | @relation = args.first || base_relation 11 | 12 | if relation.nil? 13 | raise( 14 | RelationRequired, 15 | 'Queries require a base relation defined. Use .queries method to define relation.' 16 | ) 17 | elsif !relation.is_a?(ActiveRecord::Relation) 18 | raise( 19 | RelationRequired, 20 | 'Queries accept only ActiveRecord::Relation as input' 21 | ) 22 | end 23 | end 24 | 25 | class << self 26 | ruby2_keywords def call(*args) 27 | new(*args).call 28 | end 29 | end 30 | 31 | def call 32 | query.tap do |relation| 33 | unless relation.is_a?(ActiveRecord::Relation) 34 | raise( 35 | RelationRequired, 36 | '#query method should return object of ActiveRecord::Relation class' 37 | ) 38 | end 39 | end 40 | end 41 | 42 | def self.queries(subject) 43 | self.base_relation = subject 44 | end 45 | 46 | def base_relation 47 | return nil if self.class.base_relation.nil? 48 | 49 | if self.class.base_relation.is_a?(ActiveRecord::Relation) 50 | self.class.base_relation 51 | elsif self.class.base_relation < ActiveRecord::Base 52 | self.class.base_relation.all 53 | end 54 | end 55 | 56 | private 57 | 58 | class << self 59 | attr_accessor :base_relation 60 | end 61 | 62 | attr_reader :relation, :options 63 | 64 | def query 65 | raise( 66 | NotImplementedError, 67 | 'You need to implement #query method which returns ActiveRecord::Relation object' 68 | ) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/patterns/rule.rb: -------------------------------------------------------------------------------- 1 | module Patterns 2 | class Rule 3 | def initialize(subject) 4 | @subject = subject 5 | end 6 | 7 | def satisfied? 8 | raise NotImplementedError 9 | end 10 | 11 | def not_applicable? 12 | false 13 | end 14 | 15 | def applicable? 16 | !not_applicable? 17 | end 18 | 19 | def forceable? 20 | true 21 | end 22 | 23 | private 24 | 25 | attr_reader :subject 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/patterns/ruleset.rb: -------------------------------------------------------------------------------- 1 | module Patterns 2 | class Ruleset 3 | class << self 4 | attr_accessor :rule_names 5 | end 6 | 7 | def self.rules 8 | (rule_names || []).map do |rule_name| 9 | rule_name.to_s.classify.constantize 10 | end 11 | end 12 | 13 | def self.add_rule(rule_name) 14 | self.rule_names ||= [] 15 | self.rule_names << rule_name.to_sym 16 | self 17 | end 18 | 19 | def initialize(subject = nil) 20 | @rules = self.class.rules.map { |rule| rule.new(subject) } 21 | end 22 | 23 | def satisfied?(force: false) 24 | rules.all? do |rule| 25 | rule.satisfied? || 26 | rule.not_applicable? || 27 | (force && rule.forceable?) 28 | end 29 | end 30 | 31 | def not_satisfied? 32 | !satisfied? 33 | end 34 | 35 | def applicable? 36 | !not_applicable? 37 | end 38 | 39 | def not_applicable? 40 | rules.all?(&:not_applicable?) 41 | end 42 | 43 | def forceable? 44 | rules.all? do |rule| 45 | rule.forceable? || 46 | rule.not_applicable? || 47 | rule.satisfied? 48 | end 49 | end 50 | 51 | def each(&block) 52 | return enum_for(:each) unless block_given? 53 | 54 | rules.each do |rule_or_ruleset| 55 | if rule_or_ruleset.is_a?(Ruleset) 56 | rule_or_ruleset.each(&block) 57 | else 58 | yield rule_or_ruleset 59 | end 60 | end 61 | end 62 | 63 | private 64 | 65 | attr_reader :rules 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/patterns/service.rb: -------------------------------------------------------------------------------- 1 | require 'ruby2_keywords' 2 | 3 | module Patterns 4 | class Service 5 | attr_reader :result 6 | 7 | class << self 8 | ruby2_keywords def call(*args) 9 | new(*args).tap do |service| 10 | service.instance_variable_set( 11 | "@result", 12 | service.call 13 | ) 14 | end 15 | end 16 | end 17 | 18 | def call 19 | raise NotImplementedError 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/patterns/strong_ruleset.rb: -------------------------------------------------------------------------------- 1 | # StrongRuleset is not satisfied and not forceable if any of rules is not applicable 2 | 3 | module Patterns 4 | class StrongRuleset < Ruleset 5 | def satisfied?(force: false) 6 | rules.all? do |rule| 7 | (rule.applicable? && rule.satisfied?) || (force && rule.forceable?) 8 | end 9 | end 10 | 11 | def not_applicable? 12 | rules.any?(&:not_applicable?) 13 | end 14 | 15 | def forceable? 16 | rules.all? do |rule| 17 | (rule.applicable? && rule.forceable?) || rule.satisfied? 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/rails-patterns.rb: -------------------------------------------------------------------------------- 1 | require "patterns" 2 | require "patterns/query" 3 | require "patterns/service" 4 | require "patterns/collection" 5 | require "patterns/calculation" 6 | require "patterns/form" 7 | require "patterns/rule" 8 | require "patterns/ruleset" 9 | require "patterns/strong_ruleset" 10 | -------------------------------------------------------------------------------- /rails-patterns.gemspec: -------------------------------------------------------------------------------- 1 | # Generated by juwelier 2 | # DO NOT EDIT THIS FILE DIRECTLY 3 | # Instead, edit Juwelier::Tasks in Rakefile, and run 'rake gemspec' 4 | # -*- encoding: utf-8 -*- 5 | # stub: rails-patterns 0.12.0 ruby lib 6 | 7 | Gem::Specification.new do |s| 8 | s.name = "rails-patterns".freeze 9 | s.version = "0.12.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 = ["Stevo".freeze] 14 | s.date = "2023-04-18" 15 | s.description = "A collection of lightweight, standardized, rails-oriented patterns.".freeze 16 | s.email = "b.kosmowski@selleo.com".freeze 17 | s.extra_rdoc_files = [ 18 | "LICENSE.txt", 19 | "README.md" 20 | ] 21 | s.files = [ 22 | ".document", 23 | ".github/workflows/ruby.yml", 24 | ".rspec", 25 | "Gemfile", 26 | "Gemfile.lock", 27 | "LICENSE.txt", 28 | "README.md", 29 | "Rakefile", 30 | "VERSION", 31 | "lib/patterns.rb", 32 | "lib/patterns/calculation.rb", 33 | "lib/patterns/collection.rb", 34 | "lib/patterns/form.rb", 35 | "lib/patterns/query.rb", 36 | "lib/patterns/rule.rb", 37 | "lib/patterns/ruleset.rb", 38 | "lib/patterns/service.rb", 39 | "lib/patterns/strong_ruleset.rb", 40 | "lib/rails-patterns.rb", 41 | "rails-patterns.gemspec", 42 | "spec/helpers/custom_calculation.rb", 43 | "spec/helpers/custom_calculation_script.rb", 44 | "spec/helpers/rails_redis_cache_mock.rb", 45 | "spec/patterns/calculation_spec.rb", 46 | "spec/patterns/collection_spec.rb", 47 | "spec/patterns/form_spec.rb", 48 | "spec/patterns/query_spec.rb", 49 | "spec/patterns/rule_spec.rb", 50 | "spec/patterns/ruleset_spec.rb", 51 | "spec/patterns/service_spec.rb", 52 | "spec/patterns/strong_ruleset_spec.rb", 53 | "spec/spec_helper.rb" 54 | ] 55 | s.homepage = "http://github.com/selleo/pattern".freeze 56 | s.licenses = ["MIT".freeze] 57 | s.required_ruby_version = Gem::Requirement.new(">= 2.7.0".freeze) 58 | s.rubygems_version = "3.0.8".freeze 59 | s.summary = "A collection of lightweight, standardized, rails-oriented patterns.".freeze 60 | 61 | if s.respond_to? :specification_version then 62 | s.specification_version = 4 63 | 64 | if Gem::Version.new(Gem::VERSION) >= Gem::Version.new('1.2.0') then 65 | s.add_runtime_dependency(%q.freeze, [">= 4.2.6"]) 66 | s.add_runtime_dependency(%q.freeze, [">= 4.2.6"]) 67 | s.add_runtime_dependency(%q.freeze, [">= 0"]) 68 | s.add_runtime_dependency(%q.freeze, [">= 0"]) 69 | s.add_development_dependency(%q.freeze, [">= 0"]) 70 | s.add_development_dependency(%q.freeze, ["~> 2.0"]) 71 | s.add_development_dependency(%q.freeze, [">= 0"]) 72 | else 73 | s.add_dependency(%q.freeze, [">= 4.2.6"]) 74 | s.add_dependency(%q.freeze, [">= 4.2.6"]) 75 | s.add_dependency(%q.freeze, [">= 0"]) 76 | s.add_dependency(%q.freeze, [">= 0"]) 77 | s.add_dependency(%q.freeze, [">= 0"]) 78 | s.add_dependency(%q.freeze, ["~> 2.0"]) 79 | s.add_dependency(%q.freeze, [">= 0"]) 80 | end 81 | else 82 | s.add_dependency(%q.freeze, [">= 4.2.6"]) 83 | s.add_dependency(%q.freeze, [">= 4.2.6"]) 84 | s.add_dependency(%q.freeze, [">= 0"]) 85 | s.add_dependency(%q.freeze, [">= 0"]) 86 | s.add_dependency(%q.freeze, [">= 0"]) 87 | s.add_dependency(%q.freeze, ["~> 2.0"]) 88 | s.add_dependency(%q.freeze, [">= 0"]) 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /spec/helpers/custom_calculation.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/all' 2 | require 'active_support/testing/time_helpers' 3 | require_relative 'rails_redis_cache_mock' 4 | require_relative '../../lib/patterns/calculation' 5 | 6 | CustomCalculation = Class.new(Patterns::Calculation) do 7 | set_cache_expiry_every 1.hour 8 | class_attribute :counter 9 | self.counter = 0 10 | 11 | private 12 | 13 | def result 14 | self.class.counter += 1 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /spec/helpers/custom_calculation_script.rb: -------------------------------------------------------------------------------- 1 | require_relative 'rails_redis_cache_mock' 2 | require_relative 'custom_calculation' 3 | 4 | CustomCalculation.result 5 | -------------------------------------------------------------------------------- /spec/helpers/rails_redis_cache_mock.rb: -------------------------------------------------------------------------------- 1 | class Rails 2 | def self.cache 3 | @cache ||= ActiveSupport::Cache::RedisCacheStore.new 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/patterns/calculation_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Calculation do 2 | before(:all) do 3 | class Rails 4 | def self.cache 5 | @cache ||= ActiveSupport::Cache::MemoryStore.new 6 | end 7 | end 8 | end 9 | 10 | after(:all) do 11 | Object.send(:remove_const, :Rails) 12 | end 13 | 14 | after do 15 | Object.send(:remove_const, :CustomCalculation) if defined?(CustomCalculation) 16 | Rails.cache.clear 17 | ActiveSupport::Cache::RedisCacheStore.new.clear 18 | end 19 | 20 | describe ".result" do 21 | it "returns a result of the calculation within a #result method" do 22 | CustomCalculation = Class.new(Patterns::Calculation) do 23 | private 24 | 25 | def result 26 | 50 27 | end 28 | end 29 | 30 | expect(CustomCalculation.result).to eq 50 31 | end 32 | 33 | it "#result, #result_for and #calculate are aliases" do 34 | CustomCalculation = Class.new(Patterns::Calculation) 35 | 36 | expect(CustomCalculation.method(:result)).to eq CustomCalculation.method(:result_for) 37 | expect(CustomCalculation.method(:result)).to eq CustomCalculation.method(:calculate) 38 | end 39 | 40 | it "exposes the first argument as a subject" do 41 | CustomCalculation = Class.new(Patterns::Calculation) do 42 | private 43 | 44 | def result 45 | subject 46 | end 47 | end 48 | 49 | expect(CustomCalculation.result('test')).to eq 'test' 50 | end 51 | 52 | it "exposes all keyword arguments using #options" do 53 | CustomCalculation = Class.new(Patterns::Calculation) do 54 | private 55 | 56 | def result 57 | [options[:arg_1], options[:arg_2]] 58 | end 59 | end 60 | 61 | expect(CustomCalculation.result(nil, arg_1: 20, arg_2: 30)).to eq([20, 30]) 62 | end 63 | 64 | it 'executes calculation with given block' do 65 | CustomCalculation = Class.new(Patterns::Calculation) do 66 | private 67 | 68 | def result 69 | yield(subject) 70 | end 71 | end 72 | 73 | expect(CustomCalculation.result(5) { |a| a * 3 }).to eq(15) 74 | end 75 | end 76 | 77 | describe "caching" do 78 | it "caches result for 'set_cache_expiry_every' period" do 79 | travel_to DateTime.new(2017, 1, 1, 12, 0) do 80 | CustomCalculation = Class.new(Patterns::Calculation) do 81 | set_cache_expiry_every 1.hour 82 | 83 | class_attribute :counter 84 | self.counter = 0 85 | 86 | private 87 | 88 | def result 89 | self.class.counter += 1 90 | end 91 | end 92 | 93 | expect(CustomCalculation.result).to eq 1 94 | expect(CustomCalculation.result).to eq 1 95 | end 96 | 97 | travel_to DateTime.new(2017, 1, 1, 13, 1) do 98 | expect(CustomCalculation.result).to eq 2 99 | expect(CustomCalculation.result).to eq 2 100 | end 101 | end 102 | 103 | it "caches result for every option passed" do 104 | CustomCalculation = Class.new(Patterns::Calculation) do 105 | set_cache_expiry_every 1.hour 106 | 107 | class_attribute :counter 108 | self.counter = 0 109 | 110 | private 111 | 112 | def result 113 | self.class.counter += 1 114 | end 115 | end 116 | 117 | expect(CustomCalculation.result(123)).to eq 1 118 | expect(CustomCalculation.result(123)).to eq 1 119 | expect(CustomCalculation.result(1024)).to eq 2 120 | expect(CustomCalculation.result(1024)).to eq 2 121 | expect(CustomCalculation.result(1024, arg: 1)).to eq 3 122 | expect(CustomCalculation.result(1024, arg: 1)).to eq 3 123 | end 124 | 125 | it "caches result for every option passed dependant on the class" do 126 | CustomCalculation = Class.new(Patterns::Calculation) do 127 | set_cache_expiry_every 1.hour 128 | 129 | class_attribute :counter 130 | self.counter = 0 131 | 132 | private 133 | 134 | def result 135 | self.class.counter += 1 136 | end 137 | end 138 | 139 | DifferentCalculation = Class.new(Patterns::Calculation) do 140 | set_cache_expiry_every 1.hour 141 | 142 | class_attribute :counter 143 | self.counter = 100 144 | 145 | private 146 | 147 | def result 148 | self.class.counter += 1 149 | end 150 | end 151 | 152 | expect(CustomCalculation.result(123)).to eq 1 153 | expect(CustomCalculation.result(123)).to eq 1 154 | expect(DifferentCalculation.result(123)).to eq 101 155 | expect(DifferentCalculation.result(123)).to eq 101 156 | 157 | Object.send(:remove_const, :DifferentCalculation) 158 | end 159 | 160 | it "does not cache result if 'set_cache_expiry_every' is not set" do 161 | CustomCalculation = Class.new(Patterns::Calculation) do 162 | class_attribute :counter 163 | self.counter = 0 164 | 165 | private 166 | 167 | def result 168 | self.class.counter += 1 169 | end 170 | end 171 | 172 | expect(CustomCalculation.result).to eq 1 173 | expect(CustomCalculation.result).to eq 2 174 | end 175 | 176 | describe "when RedisCacheStore is used" do 177 | it "does not store data in cache if 'cache_expiry_period' is not set" do 178 | client = Redis.new 179 | class Rails 180 | def self.cache 181 | @cache ||= ActiveSupport::Cache::RedisCacheStore.new 182 | end 183 | end 184 | 185 | CustomCalculation = Class.new(Patterns::Calculation) do 186 | class_attribute :counter 187 | self.counter = 0 188 | 189 | private 190 | 191 | def result 192 | self.class.counter += 1 193 | end 194 | end 195 | 196 | expect(CustomCalculation.result).to eq 1 197 | expect(CustomCalculation.result).to eq 2 198 | expect(client.keys).to be_empty 199 | end 200 | end 201 | 202 | it "uses cache keys consistent between processes" do 203 | `bundle exec ruby spec/helpers/custom_calculation.rb` 204 | Process.spawn('bundle exec ruby spec/helpers/custom_calculation_script.rb') 205 | Process.spawn('bundle exec ruby spec/helpers/custom_calculation_script.rb') 206 | Process.spawn('bundle exec ruby spec/helpers/custom_calculation_script.rb') 207 | Process.waitall 208 | 209 | expect(Redis.new.keys.length).to eq 1 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /spec/patterns/collection_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Collection do 2 | after { Object.send(:remove_const, :CustomCollection) if defined?(CustomCollection) } 3 | 4 | it "includes Enumerable" do 5 | CustomCollection = Class.new(Patterns::Collection) 6 | 7 | expect(CustomCollection).to be < Enumerable 8 | end 9 | 10 | describe ".new" do 11 | it "exposes all keyword arguments using #options by default" do 12 | CustomCollection = Class.new(Patterns::Collection) do 13 | private 14 | 15 | def collection 16 | [options[:arg_1], options[:arg_2]] 17 | end 18 | end 19 | 20 | collection = CustomCollection.new(arg_1: 20, arg_2: 30) 21 | 22 | expect { |b| collection.each(&b) }.to yield_successive_args(20, 30) 23 | end 24 | 25 | it "exposes first parameter using #subject by default" do 26 | CustomCollection = Class.new(Patterns::Collection) do 27 | private 28 | 29 | def collection 30 | subject 31 | end 32 | end 33 | 34 | collection = CustomCollection.new([1, 2, 4, 8]) 35 | 36 | expect { |b| collection.each(&b) }.to yield_successive_args(1, 2, 4, 8) 37 | end 38 | end 39 | 40 | describe ".from" do 41 | it "returns collection instance" do 42 | CustomCollection = Class.new(Patterns::Collection) do 43 | end 44 | 45 | collection = CustomCollection.from 46 | 47 | expect(collection).to be_a_kind_of(CustomCollection) 48 | end 49 | 50 | it "exposes all keyword arguments using #options by default" do 51 | CustomCollection = Class.new(Patterns::Collection) do 52 | private 53 | 54 | def collection 55 | [options[:arg_1], options[:arg_2]] 56 | end 57 | end 58 | 59 | collection = CustomCollection.from(arg_1: 20, arg_2: 30) 60 | 61 | expect { |b| collection.each(&b) }.to yield_successive_args(20, 30) 62 | end 63 | 64 | it "exposes first parameter using #subject by default" do 65 | CustomCollection = Class.new(Patterns::Collection) do 66 | private 67 | 68 | def collection 69 | subject 70 | end 71 | end 72 | 73 | collection = CustomCollection.from([1, 2, 4, 8]) 74 | 75 | expect { |b| collection.each(&b) }.to yield_successive_args(1, 2, 4, 8) 76 | end 77 | end 78 | 79 | describe ".for" do 80 | it "returns collection instance" do 81 | CustomCollection = Class.new(Patterns::Collection) do 82 | end 83 | 84 | collection = CustomCollection.for 85 | 86 | expect(collection).to be_a_kind_of(CustomCollection) 87 | end 88 | 89 | it "exposes all keyword arguments using #options by default" do 90 | CustomCollection = Class.new(Patterns::Collection) do 91 | private 92 | 93 | def collection 94 | [options[:arg_1], options[:arg_2]] 95 | end 96 | end 97 | 98 | collection = CustomCollection.for(arg_1: 20, arg_2: 30) 99 | 100 | expect { |b| collection.each(&b) }.to yield_successive_args(20, 30) 101 | end 102 | 103 | it "exposes first parameter using #subject by default" do 104 | CustomCollection = Class.new(Patterns::Collection) do 105 | private 106 | 107 | def collection 108 | subject 109 | end 110 | end 111 | 112 | collection = CustomCollection.for([1, 2, 4, 8]) 113 | 114 | expect { |b| collection.each(&b) }.to yield_successive_args(1, 2, 4, 8) 115 | end 116 | end 117 | 118 | describe "#each" do 119 | it "requires #collection method being implemented" do 120 | CustomCollection = Class.new(Patterns::Collection) 121 | 122 | collection = CustomCollection.new 123 | 124 | expect { collection.each }.to raise_error(NotImplementedError, "#collection not implemented") 125 | end 126 | 127 | it "performs #each on result of #collection" do 128 | CustomCollection = Class.new(Patterns::Collection) do 129 | private 130 | 131 | def collection 132 | [[1, "a"], [2, "b"], [3, "c"]] 133 | end 134 | end 135 | 136 | collection = CustomCollection.new 137 | 138 | expect { |b| collection.each(&b) }.to yield_successive_args([1, "a"], [2, "b"], [3, "c"]) 139 | end 140 | end 141 | end 142 | -------------------------------------------------------------------------------- /spec/patterns/form_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Form do 2 | after { Object.send(:remove_const, :CustomForm) if defined?(CustomForm) } 3 | 4 | it "includes Virtus.model" do 5 | CustomForm = Class.new(Patterns::Form) 6 | 7 | expect(CustomForm.attribute_set).to be_kind_of(Virtus::AttributeSet) 8 | end 9 | 10 | it "includes ActiveModel::Validations" do 11 | CustomForm = Class.new(Patterns::Form) 12 | 13 | expect(CustomForm).to be < ActiveModel::Validations 14 | end 15 | 16 | describe ".new" do 17 | it "returns form instance" do 18 | CustomForm = Class.new(Patterns::Form) 19 | 20 | form = CustomForm.new(double) 21 | 22 | expect(form).to be_a_kind_of(CustomForm) 23 | end 24 | 25 | it "assigns form attributes with values passed as second argument" do 26 | CustomForm = Class.new(Patterns::Form) do 27 | attribute :first_name, String 28 | attribute :last_name, String 29 | end 30 | 31 | form = CustomForm.new({ first_name: "Tony", last_name: "Stark" }) 32 | 33 | expect(form.first_name).to eq "Tony" 34 | expect(form.last_name).to eq "Stark" 35 | end 36 | 37 | it "handles both symbols and strings as attribute keys" do 38 | CustomForm = Class.new(Patterns::Form) do 39 | attribute :first_name, String 40 | attribute :last_name, String 41 | attribute :email, String 42 | attribute :age, Integer 43 | end 44 | resource = double( 45 | attributes: { 46 | "first_name" => "Bat", 47 | last_name: "Man", 48 | "email" => "bat@man.dev" 49 | } 50 | ) 51 | 52 | form = CustomForm.new( 53 | resource, { first_name: "Christian", "last_name" => "Bale", "age" => 40 } 54 | ) 55 | 56 | expect(form.first_name).to eq "Christian" 57 | expect(form.last_name).to eq "Bale" 58 | expect(form.email).to eq "bat@man.dev" 59 | expect(form.age).to eq 40 60 | end 61 | 62 | context "if second parameter is ActionController::Parameters object" do 63 | it "treats ActionController::Parameters as regular hash" do 64 | CustomForm = Class.new(Patterns::Form) do 65 | attribute :first_name, String 66 | attribute :last_name, String 67 | end 68 | 69 | strong_parameters = ActionController::Parameters.new( 70 | { "first_name" => "Kobe", "last_name" => "Bryant" } 71 | ) 72 | 73 | form = CustomForm.new(double, strong_parameters) 74 | 75 | expect(form.first_name).to eq "Kobe" 76 | expect(form.last_name).to eq "Bryant" 77 | end 78 | end 79 | 80 | context "if only parameter is ActionController::Parameters object" do 81 | it "treats ActionController::Parameters as regular hash" do 82 | CustomForm = Class.new(Patterns::Form) do 83 | attribute :first_name, String 84 | attribute :last_name, String 85 | end 86 | 87 | strong_parameters = ActionController::Parameters.new( 88 | { "first_name" => "Saul", "last_name" => "Goodman" } 89 | ) 90 | 91 | form = CustomForm.new(strong_parameters) 92 | 93 | expect(form.first_name).to eq "Saul" 94 | expect(form.last_name).to eq "Goodman" 95 | end 96 | end 97 | 98 | it "can be initialized without providing resource" do 99 | CustomForm = Class.new(Patterns::Form) 100 | 101 | form = CustomForm.new 102 | 103 | expect(form).to be_a_kind_of(CustomForm) 104 | end 105 | 106 | context "when resource exists" do 107 | context "when resource responds to #attributes" do 108 | it "assigns merged attributes from resource and passed as argument" do 109 | CustomForm = Class.new(Patterns::Form) do 110 | attribute :first_name, String 111 | attribute :last_name, String 112 | end 113 | resource = double(attributes: { first_name: "Jack", last_name: "Black" }) 114 | 115 | form = CustomForm.new(resource, { first_name: "Tony" }) 116 | 117 | expect(form.first_name).to eq "Tony" 118 | expect(form.last_name).to eq "Black" 119 | end 120 | 121 | it "attempts to use public getters to populate missing attributes" do 122 | CustomForm = Class.new(Patterns::Form) do 123 | attribute :first_name, String 124 | attribute :last_name, String 125 | attribute :age, Integer 126 | attribute :email, String 127 | end 128 | resource = double(attributes: { first_name: "Jack", last_name: "Black" }, age: 27) 129 | 130 | form = CustomForm.new(resource, { first_name: "Tony" }) 131 | 132 | expect(form.first_name).to eq "Tony" 133 | expect(form.last_name).to eq "Black" 134 | expect(form.age).to eq 27 135 | expect(form.email).to eq nil 136 | end 137 | end 138 | 139 | context "when resource does not respond to #attributes" do 140 | it "assigns attributes passed as arguments" do 141 | CustomForm = Class.new(Patterns::Form) do 142 | attribute :first_name, String 143 | attribute :last_name, String 144 | end 145 | 146 | form = CustomForm.new(double, { first_name: "Tony" }) 147 | 148 | expect(form.first_name).to eq "Tony" 149 | expect(form.last_name).to eq nil 150 | end 151 | 152 | it "attempts to use public getters to populate missing attributes" do 153 | CustomForm = Class.new(Patterns::Form) do 154 | attribute :first_name, String 155 | attribute :last_name, String 156 | attribute :age, Integer 157 | attribute :email, String 158 | end 159 | resource = double(last_name: "Black", age: 27) 160 | 161 | form = CustomForm.new(resource, { first_name: "Tony" }) 162 | 163 | expect(form.first_name).to eq "Tony" 164 | expect(form.last_name).to eq "Black" 165 | expect(form.age).to eq 27 166 | expect(form.email).to eq nil 167 | end 168 | end 169 | end 170 | end 171 | 172 | describe "#save" do 173 | context "when form is valid" do 174 | it "requires #persist method to be implemented" do 175 | CustomForm = Class.new(Patterns::Form) 176 | 177 | form = CustomForm.new(double) 178 | 179 | expect { form.save }.to raise_error NotImplementedError, "#persist has to be implemented" 180 | end 181 | 182 | it "returns result of #persist method" do 183 | CustomForm = Class.new(Patterns::Form) do 184 | private 185 | 186 | def persist 187 | 10 188 | end 189 | end 190 | 191 | form = CustomForm.new(double) 192 | result = form.save 193 | 194 | expect(result).to eq 10 195 | end 196 | end 197 | 198 | context "when form is invalid" do 199 | it "does not call #persist method" do 200 | CustomForm = Class.new(Patterns::Form) do 201 | private 202 | 203 | def persist 204 | raise StandardError, "Should not be raised!" 205 | end 206 | end 207 | form = CustomForm.new(double) 208 | allow(form).to receive(:valid?) { false } 209 | 210 | expect { form.save }.to_not raise_error 211 | end 212 | 213 | it "returns false" do 214 | CustomForm = Class.new(Patterns::Form) do 215 | private 216 | 217 | def persist 218 | 10 219 | end 220 | end 221 | form = CustomForm.new(double) 222 | allow(form).to receive(:valid?) { false } 223 | 224 | expect(form.save).to eq false 225 | end 226 | end 227 | end 228 | 229 | describe "#save!" do 230 | context "#save returned falsey value" do 231 | it "returns Pattern::Form::Invalid exception" do 232 | CustomForm = Class.new(Patterns::Form) do 233 | private 234 | 235 | def persist 236 | 10 237 | end 238 | end 239 | form = CustomForm.new(double) 240 | allow(form).to receive(:save) { false } 241 | 242 | expect { form.save! }.to raise_error Patterns::Form::Invalid 243 | end 244 | end 245 | 246 | context "#save returned truthy value" do 247 | it "returns value returned from #save" do 248 | CustomForm = Class.new(Patterns::Form) do 249 | private 250 | 251 | def persist 252 | 10 253 | end 254 | end 255 | form = CustomForm.new(double) 256 | 257 | expect(form.save!).to eq 10 258 | end 259 | end 260 | end 261 | 262 | describe "#as" do 263 | it "saves argument in @form_owner" do 264 | CustomForm = Class.new(Patterns::Form) 265 | form_owner = double("Form owner") 266 | 267 | form = CustomForm.new(double).as(form_owner) 268 | 269 | expect(form.instance_variable_get("@form_owner")).to eq form_owner 270 | end 271 | 272 | it "returns itself" do 273 | CustomForm = Class.new(Patterns::Form) 274 | 275 | form = CustomForm.new(double) 276 | result = form.as(double) 277 | 278 | expect(result).to eq form 279 | end 280 | end 281 | 282 | describe "#persisted?" do 283 | context "when resource is nil" do 284 | it "returns false" do 285 | CustomForm = Class.new(Patterns::Form) 286 | 287 | form = CustomForm.new 288 | 289 | expect(form.persisted?).to eq false 290 | end 291 | end 292 | 293 | context "when resource is not nil" do 294 | context "when resource responds to #persisted?" do 295 | it "returns resource#persisted?" do 296 | CustomForm = Class.new(Patterns::Form) 297 | 298 | form_1 = CustomForm.new(double(persisted?: true)) 299 | form_2 = CustomForm.new(double(persisted?: false)) 300 | 301 | expect(form_1.persisted?).to eq true 302 | expect(form_2.persisted?).to eq false 303 | end 304 | end 305 | 306 | context "when resource does not respond to #persisted?" do 307 | it "returns false" do 308 | CustomForm = Class.new(Patterns::Form) 309 | 310 | form = CustomForm.new(double) 311 | 312 | expect(form.persisted?).to eq false 313 | end 314 | end 315 | end 316 | end 317 | 318 | describe "#to_model" do 319 | it "returns itself" do 320 | CustomForm = Class.new(Patterns::Form) 321 | 322 | form = CustomForm.new(double) 323 | 324 | expect(form.to_model).to eq form 325 | end 326 | end 327 | 328 | describe "#to_partial_path" do 329 | it "returns nil" do 330 | CustomForm = Class.new(Patterns::Form) 331 | 332 | form = CustomForm.new(double) 333 | 334 | expect(form.to_partial_path).to eq nil 335 | end 336 | end 337 | 338 | describe "#to_key" do 339 | it "returns nil" do 340 | CustomForm = Class.new(Patterns::Form) 341 | 342 | form = CustomForm.new(double) 343 | 344 | expect(form.to_key).to eq nil 345 | end 346 | end 347 | 348 | describe "#to_param" do 349 | context "resource exists" do 350 | context "resource responds to #to_param" do 351 | it "returns resource#to_param" do 352 | CustomForm = Class.new(Patterns::Form) 353 | resource = double(to_param: 100) 354 | 355 | form = CustomForm.new(resource) 356 | 357 | expect(form.to_param).to eq 100 358 | end 359 | end 360 | end 361 | 362 | context "resource does not exist" do 363 | it "returns nil" do 364 | CustomForm = Class.new(Patterns::Form) 365 | 366 | form = CustomForm.new 367 | 368 | expect(form.to_param).to eq nil 369 | end 370 | end 371 | end 372 | 373 | describe "#model_name" do 374 | context "resource exists" do 375 | context "resource responds to #model_name" do 376 | context "param_key is not defined" do 377 | it "returns object's model name param_key, route_key and singular_route_key" do 378 | CustomForm = Class.new(Patterns::Form) 379 | resource = double(model_name: double( 380 | param_key: "resource_key", 381 | route_key: "resource_keys", 382 | singular_route_key: "resource_key" 383 | )) 384 | 385 | form = CustomForm.new(resource) 386 | result = form.model_name 387 | 388 | expect(result).to have_attributes( 389 | param_key: "resource_key", 390 | route_key: "resource_keys", 391 | singular_route_key: "resource_key" 392 | ) 393 | end 394 | end 395 | 396 | context "param_key is defined" do 397 | it "returns param_key, route_key and singular_route_key derived from param key" do 398 | CustomForm = Class.new(Patterns::Form) do 399 | param_key "test_key" 400 | end 401 | resource = double(model_name: double( 402 | param_key: "resource_key", 403 | route_key: "resource_keys", 404 | singular_route_key: "resource_key" 405 | )) 406 | 407 | form = CustomForm.new(resource) 408 | result = form.model_name 409 | 410 | expect(result).to have_attributes( 411 | param_key: "test_key", 412 | route_key: "test_keys", 413 | singular_route_key: "test_key" 414 | ) 415 | end 416 | end 417 | end 418 | 419 | context "resource does not respond to #model_name" do 420 | context "param_key is not defined" do 421 | it "raises NoParamKey" do 422 | CustomForm = Class.new(Patterns::Form) 423 | 424 | form = CustomForm.new(double) 425 | 426 | expect { form.model_name }.to raise_error(Patterns::Form::NoParamKey) 427 | end 428 | end 429 | 430 | context "param_key is defined" do 431 | it "returns param_key, route_key and singular_route_key derived from param key" do 432 | CustomForm = Class.new(Patterns::Form) do 433 | param_key "test_key" 434 | end 435 | 436 | form = CustomForm.new(double) 437 | result = form.model_name 438 | 439 | expect(result).to have_attributes( 440 | param_key: "test_key", 441 | route_key: "test_keys", 442 | singular_route_key: "test_key" 443 | ) 444 | end 445 | end 446 | end 447 | end 448 | 449 | context "resource does not exist" do 450 | context "param_key is not defined" do 451 | it "raises NoParamKey" do 452 | CustomForm = Class.new(Patterns::Form) 453 | 454 | form = CustomForm.new 455 | 456 | expect { form.model_name }.to raise_error(Patterns::Form::NoParamKey) 457 | end 458 | end 459 | 460 | context "param_key is defined" do 461 | it "returns param_key, route_key and singular_route_key derived from param key" do 462 | CustomForm = Class.new(Patterns::Form) do 463 | param_key "test_key" 464 | end 465 | 466 | form = CustomForm.new 467 | result = form.model_name 468 | 469 | expect(result).to have_attributes( 470 | param_key: "test_key", 471 | route_key: "test_keys", 472 | singular_route_key: "test_key" 473 | ) 474 | end 475 | end 476 | end 477 | end 478 | end 479 | -------------------------------------------------------------------------------- /spec/patterns/query_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Query do 2 | after { Object.send(:remove_const, :CustomQuery) if defined?(CustomQuery) } 3 | 4 | describe '.new' do 5 | it 'accepts a relation only as argument' do 6 | CustomQuery = Class.new(Patterns::Query) 7 | 8 | expect { 9 | CustomQuery.new([1, 2, 3]) 10 | }.to raise_error( 11 | Patterns::Query::RelationRequired, 12 | 'Queries accept only ActiveRecord::Relation as input' 13 | ) 14 | end 15 | 16 | it 'accepts a relation only as keyword positional arguments' do 17 | CustomQuery = Class.new(Patterns::Query) do 18 | def initialize(a); end 19 | 20 | def query 21 | ActiveRecord::Relation.allocate 22 | end 23 | end 24 | 25 | expect { CustomQuery.call([1, 2]) }.not_to raise_error 26 | end 27 | 28 | it 'accepts a relation only as keyword arguments' do 29 | CustomQuery = Class.new(Patterns::Query) do 30 | def initialize(a:); end 31 | 32 | def query 33 | ActiveRecord::Relation.allocate 34 | end 35 | end 36 | 37 | expect { CustomQuery.call(a: [1, 2]) }.not_to raise_error 38 | end 39 | 40 | it 'requires an argument' do 41 | CustomQuery = Class.new(Patterns::Query) 42 | 43 | expect { 44 | CustomQuery.new 45 | }.to raise_error( 46 | Patterns::Query::RelationRequired, 47 | 'Queries require a base relation defined. Use .queries method to define relation.' 48 | ) 49 | end 50 | 51 | it 'initializes a query object' do 52 | CustomQuery = Class.new(Patterns::Query) 53 | relation = ActiveRecord::Relation.allocate 54 | 55 | query = CustomQuery.new(relation) 56 | 57 | expect(query).to be_a_kind_of(CustomQuery) 58 | end 59 | end 60 | 61 | describe '.call' do 62 | it 'calls #call and passes argument to constructor' do 63 | CustomQuery = Class.new(Patterns::Query) 64 | relation = ActiveRecord::Relation.allocate 65 | result_relation = ActiveRecord::Relation.allocate 66 | query_double = instance_double(CustomQuery) 67 | allow(CustomQuery).to receive(:new) { query_double } 68 | allow(query_double).to receive(:call) { result_relation } 69 | 70 | result = CustomQuery.call(relation) 71 | 72 | expect(result).to eql result_relation 73 | expect(query_double).to have_received(:call) 74 | end 75 | end 76 | 77 | describe '#call' do 78 | it 'requires impementing #query method' do 79 | relation = ActiveRecord::Relation.allocate 80 | CustomQuery = Class.new(Patterns::Query) 81 | 82 | expect { 83 | CustomQuery.new(relation).call 84 | }.to raise_error( 85 | NotImplementedError, 86 | 'You need to implement #query method which returns ActiveRecord::Relation object' 87 | ) 88 | end 89 | 90 | it 'returns result of calling #query' do 91 | relation = ActiveRecord::Relation.allocate 92 | result_relation = ActiveRecord::Relation.allocate 93 | CustomQuery = Class.new(Patterns::Query) do 94 | attr_accessor :internal_query 95 | 96 | def query 97 | internal_query 98 | end 99 | end 100 | 101 | query = CustomQuery.new(relation) 102 | query.internal_query = result_relation 103 | result = query.call 104 | 105 | expect(result).to eql result_relation 106 | end 107 | 108 | it 'ensures that #query returns a relation' do 109 | relation = ActiveRecord::Relation.allocate 110 | CustomQuery = Class.new(Patterns::Query) do 111 | def query 112 | [1, 2, 3] 113 | end 114 | end 115 | 116 | expect { 117 | CustomQuery.call(relation) 118 | }.to raise_error( 119 | Patterns::Query::RelationRequired, 120 | '#query method should return object of ActiveRecord::Relation class' 121 | ) 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/patterns/rule_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Rule do 2 | after(:each) do 3 | Object.send(:remove_const, :CustomRule) if defined?(CustomRule) 4 | end 5 | 6 | it 'requires subject as the first argument' do 7 | CustomRule = Class.new(Patterns::Rule) 8 | 9 | expect { CustomRule.new }.to raise_error ArgumentError 10 | expect { CustomRule.new(Object.new) }.not_to raise_error 11 | end 12 | 13 | it 'requires #satisfied? method to be defined' do 14 | InvalidCustomRule = Class.new(Patterns::Rule) 15 | CustomRule = Class.new(Patterns::Rule) do 16 | def satisfied? 17 | true 18 | end 19 | end 20 | 21 | expect { InvalidCustomRule.new(Object.new).satisfied? }.to raise_error NotImplementedError 22 | expect { CustomRule.new(Object.new).satisfied? }.not_to raise_error 23 | end 24 | 25 | describe '#satisfied?' do 26 | context 'when subject meets the conditions' do 27 | it 'returns true' do 28 | article = OpenStruct.new('published?' => true, 'deleted?' => false) 29 | 30 | ArticleIsPublishedRule = Class.new(Patterns::Rule) do 31 | def satisfied? 32 | subject.published? 33 | end 34 | 35 | def not_applicable? 36 | subject.deleted? 37 | end 38 | end 39 | 40 | expect(ArticleIsPublishedRule.new(article).satisfied?).to eq true 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/patterns/ruleset_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Ruleset do 2 | describe '#forceable?' do 3 | context 'all rules are forceable' do 4 | it 'returns true' do 5 | with_mocked_rules do |rules| 6 | subject = double 7 | rules << mock_rule(:rule_1, is_forceable: true) 8 | rules << mock_rule(:rule_2, is_forceable: true) 9 | 10 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 11 | custom_ruleset_klass.add_rule(:rule_1) 12 | custom_ruleset_klass.add_rule(:rule_2) 13 | 14 | expect(custom_ruleset_klass.new(subject).forceable?).to eq true 15 | end 16 | end 17 | end 18 | 19 | context 'at least one rule is not forceable' do 20 | it 'returns false' do 21 | with_mocked_rules do |rules| 22 | subject = double 23 | rules << mock_rule(:rule_1, is_forceable: false, is_satisfied: false, is_applicable: true) 24 | rules << mock_rule(:rule_2, is_forceable: true) 25 | 26 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 27 | custom_ruleset_klass.add_rule(:rule_1) 28 | custom_ruleset_klass.add_rule(:rule_2) 29 | 30 | expect(custom_ruleset_klass.new(subject).forceable?).to eq false 31 | end 32 | end 33 | 34 | context 'and rule is satisfied' do 35 | it 'returns true' do 36 | with_mocked_rules do |rules| 37 | subject = double 38 | rules << mock_rule( 39 | :rule_1, 40 | is_forceable: false, 41 | is_satisfied: true, 42 | is_applicable: true 43 | ) 44 | rules << mock_rule(:rule_2, is_forceable: true) 45 | 46 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 47 | custom_ruleset_klass.add_rule(:rule_1) 48 | custom_ruleset_klass.add_rule(:rule_2) 49 | 50 | expect(custom_ruleset_klass.new(subject).forceable?).to eq true 51 | end 52 | end 53 | end 54 | 55 | context 'and rule is not applicable' do 56 | it 'returns true' do 57 | with_mocked_rules do |rules| 58 | subject = double 59 | rules << mock_rule( 60 | :rule_1, 61 | is_forceable: false, 62 | is_satisfied: false, 63 | is_applicable: false 64 | ) 65 | rules << mock_rule(:rule_2, is_forceable: true) 66 | 67 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 68 | custom_ruleset_klass.add_rule(:rule_1) 69 | custom_ruleset_klass.add_rule(:rule_2) 70 | 71 | expect(custom_ruleset_klass.new(subject).forceable?).to eq true 72 | end 73 | end 74 | end 75 | end 76 | end 77 | 78 | describe '#not_applicable?' do 79 | context 'all rules are not applicable' do 80 | it 'returns true' do 81 | with_mocked_rules do |rules| 82 | subject = double 83 | rules << mock_rule(:rule_1, is_applicable: false) 84 | rules << mock_rule(:rule_2, is_applicable: false) 85 | 86 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 87 | custom_ruleset_klass.add_rule(:rule_1) 88 | custom_ruleset_klass.add_rule(:rule_2) 89 | 90 | expect(custom_ruleset_klass.new(subject).not_applicable?).to eq true 91 | end 92 | end 93 | end 94 | 95 | context 'at least one rule is applicable' do 96 | it 'returns false' do 97 | with_mocked_rules do |rules| 98 | subject = double 99 | rules << mock_rule(:rule_1, is_applicable: false) 100 | rules << mock_rule(:rule_2, is_applicable: true) 101 | 102 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 103 | custom_ruleset_klass.add_rule(:rule_1) 104 | custom_ruleset_klass.add_rule(:rule_2) 105 | 106 | expect(custom_ruleset_klass.new(subject).not_applicable?).to eq false 107 | end 108 | end 109 | end 110 | end 111 | 112 | describe '#satisfied?' do 113 | context 'all rules are satisfied' do 114 | it 'returns true' do 115 | with_mocked_rules do |rules| 116 | subject = double 117 | rules << mock_rule(:rule_1) 118 | rules << mock_rule(:rule_2) 119 | 120 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 121 | custom_ruleset_klass.add_rule(:rule_1) 122 | custom_ruleset_klass.add_rule(:rule_2) 123 | 124 | expect(custom_ruleset_klass.new(subject).satisfied?).to eq true 125 | end 126 | end 127 | end 128 | 129 | context 'at least one rule is not satisfied' do 130 | it 'returns false' do 131 | with_mocked_rules do |rules| 132 | subject = double 133 | rules << mock_rule(:rule_1) 134 | rules << mock_rule(:rule_2, is_satisfied: false) 135 | 136 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 137 | custom_ruleset_klass.add_rule(:rule_1) 138 | custom_ruleset_klass.add_rule(:rule_2) 139 | 140 | expect(custom_ruleset_klass.new(subject).satisfied?).to eq false 141 | end 142 | end 143 | 144 | context 'when rule is not applicable' do 145 | it 'returns true' do 146 | with_mocked_rules do |rules| 147 | subject = double 148 | rules << mock_rule(:rule_1) 149 | rules << mock_rule(:rule_2, is_satisfied: false, is_applicable: false) 150 | 151 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 152 | custom_ruleset_klass.add_rule(:rule_1) 153 | custom_ruleset_klass.add_rule(:rule_2) 154 | 155 | expect(custom_ruleset_klass.new(subject).satisfied?).to eq true 156 | end 157 | end 158 | end 159 | 160 | context 'when provided with force: true' do 161 | context 'when rule is forceable' do 162 | it 'returns true' do 163 | with_mocked_rules do |rules| 164 | subject = double 165 | rules << mock_rule(:rule_1) 166 | rules << mock_rule(:rule_2, is_satisfied: false, is_forceable: true) 167 | 168 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 169 | custom_ruleset_klass.add_rule(:rule_1) 170 | custom_ruleset_klass.add_rule(:rule_2) 171 | 172 | expect(custom_ruleset_klass.new(subject).satisfied?(force: true)).to eq true 173 | end 174 | end 175 | end 176 | 177 | context 'when rule is not forceable' do 178 | it 'returns false' do 179 | with_mocked_rules do |rules| 180 | subject = double 181 | rules << mock_rule(:rule_1) 182 | rules << mock_rule(:rule_2, is_satisfied: false, is_forceable: false) 183 | 184 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 185 | custom_ruleset_klass.add_rule(:rule_1) 186 | custom_ruleset_klass.add_rule(:rule_2) 187 | 188 | expect(custom_ruleset_klass.new(subject).satisfied?(force: true)).to eq false 189 | end 190 | end 191 | end 192 | end 193 | end 194 | 195 | context 'ruleset has no rules' do 196 | it 'returns true' do 197 | with_mocked_rules do |_rules| 198 | subject = double 199 | 200 | custom_ruleset_klass = Class.new(Patterns::Ruleset) 201 | 202 | expect(custom_ruleset_klass.new(subject).satisfied?).to eq true 203 | end 204 | end 205 | end 206 | end 207 | 208 | describe '#each' do 209 | it 'yields all rules for ruleset' do 210 | with_mocked_rules do |rules| 211 | rules << (_, rule_1 = mock_rule(:rule_1)) 212 | rules << (_, rule_2 = mock_rule(:rule_2)) 213 | rules << (_, rule_3 = mock_rule(:rule_3)) 214 | custom_ruleset_klass_1 = Class.new(Patterns::Ruleset) 215 | custom_ruleset_klass_1.add_rule(:rule_1) 216 | custom_ruleset_klass_1.add_rule(:rule_2) 217 | Ruleset2 = Class.new(Patterns::Ruleset) 218 | Ruleset2.add_rule(:rule_3) 219 | custom_ruleset_klass_1.add_rule(:ruleset_2) 220 | 221 | ruleset = custom_ruleset_klass_1.new(double) 222 | 223 | expect { |b| ruleset.each(&b) }.to yield_successive_args(rule_1, rule_2, rule_3) 224 | ensure 225 | remove_class(Ruleset2) 226 | end 227 | end 228 | end 229 | 230 | private 231 | 232 | def mock_rule(rule_name, is_applicable: true, is_satisfied: true, is_forceable: true) 233 | klass = Object.const_set(rule_name.to_s.classify, Class.new(Patterns::Rule)) 234 | rule = double( 235 | not_applicable?: !is_applicable, 236 | satisfied?: is_satisfied, 237 | forceable?: is_forceable 238 | ) 239 | allow(klass).to receive(:new).with(anything) { rule } 240 | [klass, rule] 241 | end 242 | 243 | def with_mocked_rules 244 | rules_storage = [] 245 | yield rules_storage 246 | ensure 247 | rules_storage.each do |rule_klass, _rule_instance| 248 | remove_class(rule_klass) 249 | end 250 | end 251 | 252 | def remove_class(klass) 253 | Object.send(:remove_const, klass.name.to_sym) 254 | end 255 | end 256 | -------------------------------------------------------------------------------- /spec/patterns/service_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::Service do 2 | after { Object.send(:remove_const, :DoSomething) if defined?(DoSomething) } 3 | 4 | describe '.call' do 5 | it 'returns instance of service object' do 6 | DoSomething = Class.new(Patterns::Service) do 7 | def call; end 8 | end 9 | 10 | expect(DoSomething.call).to be_kind_of(DoSomething) 11 | end 12 | 13 | it 'instantiates service object passing keyword arguments to constructor' do 14 | DoSomething = Class.new(Patterns::Service) do 15 | def initialize(argument_1:, argument_2:); end 16 | def call; end 17 | end 18 | 19 | expect { 20 | DoSomething.call 21 | }.to raise_error ArgumentError 22 | 23 | expect { 24 | DoSomething.call(argument_1: 10, argument_2: 20) 25 | }.not_to raise_error 26 | end 27 | 28 | it 'instantiates service object passing positional arguments to constructor' do 29 | DoSomething = Class.new(Patterns::Service) do 30 | def initialize(argument_1, argument_2); end 31 | def call; end 32 | end 33 | 34 | expect { 35 | DoSomething.call 36 | }.to raise_error ArgumentError 37 | 38 | expect { 39 | DoSomething.call(10, 20) 40 | }.not_to raise_error 41 | end 42 | 43 | it 'calls #call method on service object instance' do 44 | Spy = Class.new do 45 | def self.some_method; end 46 | end 47 | allow(Spy).to receive(:some_method) 48 | DoSomething = Class.new(Patterns::Service) do 49 | def call 50 | Spy.some_method 51 | end 52 | end 53 | 54 | DoSomething.call 55 | 56 | expect(Spy).to have_received(:some_method) 57 | end 58 | 59 | it 'requires #call method to be implemented' do 60 | DoSomething = Class.new(Patterns::Service) 61 | 62 | expect { 63 | DoSomething.call 64 | }.to raise_error NotImplementedError 65 | end 66 | end 67 | 68 | describe '#result' do 69 | it 'returns a result of expression within #call method' do 70 | DoSomething = Class.new(Patterns::Service) do 71 | def call 72 | 50 73 | end 74 | end 75 | 76 | service = DoSomething.call 77 | expect(service.result).to eq 50 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /spec/patterns/strong_ruleset_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Patterns::StrongRuleset do 2 | it 'inherites from Ruleset' do 3 | custom_strong_ruleset_klass = Class.new(Patterns::StrongRuleset) 4 | expect(custom_strong_ruleset_klass.ancestors).to include Patterns::Ruleset 5 | end 6 | 7 | context 'when any of rules is not applicable' do 8 | it 'is not satisfied' do 9 | with_mocked_rules do |rules| 10 | subject = double 11 | rules << mock_rule(:rule_1, is_applicable: false) 12 | rules << mock_rule(:rule_2) 13 | 14 | custom_ruleset_klass = Class.new(Patterns::StrongRuleset) 15 | custom_ruleset_klass.add_rule(:rule_1) 16 | custom_ruleset_klass.add_rule(:rule_2) 17 | 18 | expect(custom_ruleset_klass.new(subject).satisfied?).to eq false 19 | end 20 | end 21 | 22 | context 'when not applicable rule is not satisfied' do 23 | it 'is not forceable' do 24 | with_mocked_rules do |rules| 25 | subject = double 26 | rules << mock_rule(:rule_1, is_applicable: false, is_satisfied: false) 27 | rules << mock_rule(:rule_2) 28 | 29 | custom_ruleset_klass = Class.new(Patterns::StrongRuleset) 30 | custom_ruleset_klass.add_rule(:rule_1) 31 | custom_ruleset_klass.add_rule(:rule_2) 32 | 33 | expect(custom_ruleset_klass.new(subject).forceable?).to eq false 34 | end 35 | end 36 | end 37 | 38 | it 'is not applicable' do 39 | with_mocked_rules do |rules| 40 | subject = double 41 | rules << mock_rule(:rule_1, is_applicable: false) 42 | rules << mock_rule(:rule_2) 43 | 44 | custom_ruleset_klass = Class.new(Patterns::StrongRuleset) 45 | custom_ruleset_klass.add_rule(:rule_1) 46 | custom_ruleset_klass.add_rule(:rule_2) 47 | 48 | expect(custom_ruleset_klass.new(subject).applicable?).to eq false 49 | end 50 | end 51 | end 52 | 53 | private 54 | 55 | def mock_rule(rule_name, is_applicable: true, is_satisfied: true, is_forceable: true) 56 | klass = Object.const_set(rule_name.to_s.classify, Class.new(Patterns::Rule)) 57 | rule = double( 58 | not_applicable?: !is_applicable, 59 | applicable?: is_applicable, 60 | satisfied?: is_satisfied, 61 | forceable?: is_forceable 62 | ) 63 | allow(klass).to receive(:new).with(anything) { rule } 64 | [klass, rule] 65 | end 66 | 67 | def with_mocked_rules 68 | rules_storage = [] 69 | yield rules_storage 70 | ensure 71 | rules_storage.each do |rule_klass, _rule_instance| 72 | remove_class(rule_klass) 73 | end 74 | end 75 | 76 | def remove_class(klass) 77 | Object.send(:remove_const, klass.name.to_sym) 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | 20 | require "active_support/all" 21 | require "active_support/testing/time_helpers" 22 | require "pry" 23 | require "redis" 24 | require "rails-patterns" 25 | 26 | RSpec.configure do |config| 27 | config.include ActiveSupport::Testing::TimeHelpers 28 | 29 | # rspec-expectations config goes here. You can use an alternate 30 | # assertion/expectation library such as wrong or the stdlib/minitest 31 | # assertions if you prefer. 32 | config.expect_with :rspec do |expectations| 33 | # This option will default to `true` in RSpec 4. It makes the `description` 34 | # and `failure_message` of custom matchers include text for helper methods 35 | # defined using `chain`, e.g.: 36 | # be_bigger_than(2).and_smaller_than(4).description 37 | # # => "be bigger than 2 and smaller than 4" 38 | # ...rather than: 39 | # # => "be bigger than 2" 40 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 41 | end 42 | 43 | # rspec-mocks config goes here. You can use an alternate test double 44 | # library (such as bogus or mocha) by changing the `mock_with` option here. 45 | config.mock_with :rspec do |mocks| 46 | # Prevents you from mocking or stubbing a method that does not exist on 47 | # a real object. This is generally recommended, and will default to 48 | # `true` in RSpec 4. 49 | mocks.verify_partial_doubles = true 50 | end 51 | 52 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 53 | # have no way to turn it off -- the option exists only for backwards 54 | # compatibility in RSpec 3). It causes shared context metadata to be 55 | # inherited by the metadata hash of host groups and examples, rather than 56 | # triggering implicit auto-inclusion in groups with matching metadata. 57 | config.shared_context_metadata_behavior = :apply_to_host_groups 58 | 59 | # The settings below are suggested to provide a good initial experience 60 | # with RSpec, but feel free to customize to your heart's content. 61 | =begin 62 | # This allows you to limit a spec run to individual examples or groups 63 | # you care about by tagging them with `:focus` metadata. When nothing 64 | # is tagged with `:focus`, all examples get run. RSpec also provides 65 | # aliases for `it`, `describe`, and `context` that include `:focus` 66 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 67 | config.filter_run_when_matching :focus 68 | 69 | # Allows RSpec to persist some state between runs in order to support 70 | # the `--only-failures` and `--next-failure` CLI options. We recommend 71 | # you configure your source control system to ignore this file. 72 | config.example_status_persistence_file_path = "spec/examples.txt" 73 | 74 | # Limits the available syntax to the non-monkey patched syntax that is 75 | # recommended. For more details, see: 76 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 77 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 78 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 79 | config.disable_monkey_patching! 80 | 81 | # This setting enables warnings. It's recommended, but in some cases may 82 | # be too noisy due to issues in dependencies. 83 | config.warnings = true 84 | 85 | # Many RSpec users commonly either run the entire suite or an individual 86 | # file, and it's useful to allow more verbose output when running an 87 | # individual spec file. 88 | if config.files_to_run.one? 89 | # Use the documentation formatter for detailed output, 90 | # unless a formatter has already been configured 91 | # (e.g. via a command-line flag). 92 | config.default_formatter = 'doc' 93 | end 94 | 95 | # Print the 10 slowest examples and example groups at the 96 | # end of the spec run, to help surface which specs are running 97 | # particularly slow. 98 | config.profile_examples = 10 99 | 100 | # Run specs in random order to surface order dependencies. If you find an 101 | # order dependency and want to debug it, you can fix the order by providing 102 | # the seed, which is printed after each run. 103 | # --seed 1234 104 | config.order = :random 105 | 106 | # Seed global randomization in this process using the `--seed` CLI option. 107 | # Setting this allows you to use `--seed` to deterministically reproduce 108 | # test failures related to randomization by passing the same `--seed` value 109 | # as the one that triggered the failure. 110 | Kernel.srand config.seed 111 | =end 112 | end 113 | --------------------------------------------------------------------------------