├── .github └── workflows │ ├── rspec.yml │ └── rubocop.yml ├── .gitignore ├── .rdoc_options ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── Appraisals ├── Gemfile ├── Gemfile.lock ├── Guardfile ├── LICENSE ├── README.md ├── Rakefile ├── bin └── start_db.sh ├── filterameter.gemspec ├── gemfiles ├── rails_6_1.gemfile ├── rails_7.gemfile ├── rails_7_1.gemfile ├── rails_7_2.gemfile └── rails_8.gemfile ├── lib ├── filterameter.rb └── filterameter │ ├── configuration.rb │ ├── declaration_errors.rb │ ├── declaration_errors │ ├── cannot_be_inline_scope_error.rb │ ├── filter_scope_argument_error.rb │ ├── no_such_attribute_error.rb │ ├── not_a_scope_error.rb │ ├── sort_scope_requires_one_argument_error.rb │ └── unexpected_error.rb │ ├── declarations_validator.rb │ ├── declarative_filters.rb │ ├── errors.rb │ ├── exceptions.rb │ ├── exceptions │ ├── cannot_determine_model_error.rb │ ├── collection_association_sort_error.rb │ ├── invalid_association_declaration_error.rb │ ├── undeclared_parameter_error.rb │ └── validation_error.rb │ ├── filter_coordinator.rb │ ├── filter_declaration.rb │ ├── filter_factory.rb │ ├── filters │ ├── arel_filter.rb │ ├── attribute_filter.rb │ ├── attribute_validator.rb │ ├── conditional_scope_filter.rb │ ├── matches_filter.rb │ ├── maximum_filter.rb │ ├── minimum_filter.rb │ ├── nested_collection_filter.rb │ ├── nested_filter.rb │ └── scope_filter.rb │ ├── helpers │ ├── declaration_with_model.rb │ ├── joins_values_builder.rb │ └── requested_sort.rb │ ├── log_subscriber.rb │ ├── options │ └── partial_options.rb │ ├── parameters_base.rb │ ├── query_builder.rb │ ├── registries │ ├── filter_registry.rb │ ├── registry.rb │ ├── sort_registry.rb │ └── sub_registry.rb │ ├── sort_declaration.rb │ ├── sort_factory.rb │ ├── sorts │ ├── attribute_sort.rb │ └── scope_sort.rb │ ├── validators │ └── inclusion_validator.rb │ └── version.rb └── spec ├── README.md ├── controllers ├── activities_controller_spec.rb └── projects_controller_spec.rb ├── dummy ├── Rakefile ├── app │ ├── controllers │ │ ├── active_activities_controller.rb │ │ ├── active_tasks_controller.rb │ │ ├── activities_controller.rb │ │ ├── application_controller.rb │ │ ├── legacy_projects_controller.rb │ │ ├── projects_controller.rb │ │ └── tasks_controller.rb │ └── models │ │ ├── activity.rb │ │ ├── activity_member.rb │ │ ├── application_record.rb │ │ ├── project.rb │ │ ├── task.rb │ │ └── user.rb ├── bin │ ├── bundle │ ├── rails │ ├── rake │ ├── setup │ └── update ├── config.ru ├── config │ ├── application.rb │ ├── boot.rb │ ├── database.yml │ ├── environment.rb │ ├── environments │ │ ├── development.rb │ │ ├── production.rb │ │ └── test.rb │ ├── initializers │ │ ├── application_controller_renderer.rb │ │ ├── backtrace_silencers.rb │ │ ├── cors.rb │ │ ├── filter_parameter_logging.rb │ │ ├── inflections.rb │ │ ├── mime_types.rb │ │ ├── new_framework_defaults_6_1.rb │ │ └── wrap_parameters.rb │ ├── locales │ │ └── en.yml │ ├── puma.rb │ ├── routes.rb │ └── spring.rb ├── db │ ├── migrate │ │ ├── 20240528221556_create_projects.rb │ │ ├── 20240528223335_create_users.rb │ │ ├── 20240528223943_create_activities.rb │ │ ├── 20240528224644_create_activity_members.rb │ │ └── 20240528224846_create_tasks.rb │ └── schema.rb ├── docker-compose.yml ├── filterameter_spec.rb └── log │ └── .keep ├── filterameter ├── configuration_spec.rb ├── declarations_validator_spec.rb ├── declarative_filters_spec.rb ├── errors_spec.rb ├── exceptions │ ├── cannot_determine_model_error_spec.rb │ └── undeclared_parameter_error_spec.rb ├── filter_coordinator_spec.rb ├── filter_declaration_spec.rb ├── filter_factory_spec.rb ├── filters │ ├── attribute_filter_spec.rb │ ├── conditional_scope_filter_spec.rb │ ├── matches_filter_spec.rb │ ├── maximum_filter_spec.rb │ ├── minimum_filter_spec.rb │ ├── nested_collection_filter_spec.rb │ ├── nested_filter_spec.rb │ └── scope_filter_spec.rb ├── helpers │ ├── declaration_with_model_spec.rb │ ├── joins_values_builder_spec.rb │ └── requested_sort_spec.rb ├── options │ └── partial_options_spec.rb ├── parameters_base_spec.rb ├── query_builder_spec.rb ├── registries │ ├── filter_registry_spec.rb │ ├── registry_spec.rb │ └── sort_registry_spec.rb ├── sort_declaration_spec.rb ├── sort_factory_spec.rb ├── sorts │ ├── attribute_sort_spec.rb │ └── scope_sort_spec.rb └── validators │ └── inclusion_validator_spec.rb ├── fixtures ├── activities.yml ├── activity_members.yml ├── projects.yml ├── tasks.yml └── users.yml ├── rails_helper.rb ├── requests ├── attribute_filters_spec.rb ├── attribute_sorts_spec.rb ├── controller_overrides_spec.rb ├── default_sorts_spec.rb ├── filter_key_configuration_spec.rb ├── multi_level_nested_attribute_filters_spec.rb ├── multi_level_nested_scope_filters_spec.rb ├── nested_filters_spec.rb ├── partial_filters_spec.rb ├── range_filters_spec.rb ├── scope_filters_spec.rb └── scope_sorts_spec.rb └── spec_helper.rb /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | name: 'RSpec' 2 | on: 3 | - push 4 | 5 | jobs: 6 | tests: 7 | name: 'RSpec' 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby: ['3.1', '3.2', '3.3', '3.4'] 12 | gemfile: 13 | - rails_6_1 14 | - rails_7 15 | - rails_7_1 16 | - rails_7_2 17 | - rails_8 18 | exclude: 19 | - ruby: '3.1' 20 | gemfile: 'rails_8' 21 | env: 22 | BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/${{ matrix.gemfile }}.gemfile 23 | BUNDLE_PATH_RELATIVE_TO_CWD: true 24 | services: 25 | db: 26 | image: postgres:16 27 | env: 28 | POSTGRES_USER: filterameter 29 | POSTGRES_DB: filterameter_test 30 | POSTGRES_PASSWORD: r!teoqA2bA 31 | ports: ['5432:5432'] 32 | options: >- 33 | --health-cmd pg_isready 34 | --health-interval 10s 35 | --health-timeout 5s 36 | --health-retries 5 37 | steps: 38 | - uses: actions/checkout@master 39 | 40 | - name: Set up Ruby 41 | uses: ruby/setup-ruby@v1 42 | with: 43 | ruby-version: ${{ matrix.ruby }} 44 | bundler: default 45 | bundler-cache: true 46 | 47 | - name: 'RSpec' 48 | env: 49 | RAILS_ENV: test 50 | DATABASE_URL: postgres://filterameter:@localhost:5432/filterameter_test 51 | POSTGRES_USER: filteramter 52 | POSTGRES_PASSWORD: r!teoqA2bA 53 | run: bundle exec rspec --format progress 54 | -------------------------------------------------------------------------------- /.github/workflows/rubocop.yml: -------------------------------------------------------------------------------- 1 | name: RuboCop 2 | on: 3 | - push 4 | 5 | jobs: 6 | rubocop: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Set up Ruby 11 | uses: ruby/setup-ruby@v1 12 | with: 13 | ruby-version: 3.3 14 | - name: Install RuboCop 15 | run: | 16 | gem install bundler --no-document 17 | gem install rubocop -v 1.69.1 --no-document 18 | gem install rubocop-packaging -v 0.5.2 --no-document 19 | gem install rubocop-rails -v 2.27.0 --no-document 20 | gem install rubocop-rspec -v 3.2.0 --no-document 21 | gem install rubocop-rspec_rails -v 2.30.0 --no-document 22 | - name: RuboCop 23 | run: rubocop 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle/ 3 | **/.DS_Store 4 | coverage/ 5 | gemfiles/*.lock 6 | log/*.log 7 | pkg/ 8 | spec/dummy/log/*.log 9 | spec/dummy/node_modules/ 10 | spec/dummy/yarn-error.log 11 | spec/dummy/tmp/ 12 | -------------------------------------------------------------------------------- /.rdoc_options: -------------------------------------------------------------------------------- 1 | --- 2 | encoding: UTF-8 3 | static_path: [] 4 | rdoc_include: [] 5 | page_dir: 6 | charset: UTF-8 7 | exclude: 8 | - "~\\z" 9 | - "\\.orig\\z" 10 | - "\\.rej\\z" 11 | - "\\.bak\\z" 12 | - "\\.gemspec\\z" 13 | hyperlink_all: false 14 | line_numbers: false 15 | locale_dir: locale 16 | locale_name: 17 | main_page: 18 | markup: markdown 19 | output_decoration: true 20 | show_hash: false 21 | skip_tests: true 22 | tab_width: 8 23 | template_stylesheets: [] 24 | title: 25 | visibility: :protected 26 | webcvs: 27 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | require: 2 | - rubocop-packaging 3 | - rubocop-rails 4 | - rubocop-rspec 5 | - rubocop-rspec_rails 6 | 7 | AllCops: 8 | TargetRubyVersion: 3.2 9 | Exclude: 10 | - 'filterameter.gemspec' 11 | - 'gemfiles/*.gemfile' 12 | - 'Rakefile' 13 | - 'spec/dummy/bin/**/*' 14 | - 'spec/dummy/db/schema.rb' 15 | - 'spec/dummy/config.ru' 16 | - 'spec/dummy/config/**/*' 17 | NewCops: enable 18 | Metrics/BlockLength: 19 | Exclude: 20 | - 'spec/**/*' 21 | - 'Guardfile' 22 | AllowedMethods: 23 | - 'class_methods' 24 | Layout/LineLength: 25 | Max: 120 26 | Style/FrozenStringLiteralComment: 27 | Exclude: 28 | - 'spec/dummy/db/**/*' 29 | Style/HashSyntax: 30 | EnforcedShorthandSyntax: either 31 | RSpec/MultipleExpectations: 32 | Exclude: 33 | - 'spec/requests/*' 34 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | filterameter 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | 3.4.1 2 | -------------------------------------------------------------------------------- /Appraisals: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | appraise 'rails-6-1' do 4 | gem 'rails', '~> 6.1.0' 5 | gem 'concurrent-ruby', '1.3.4' # https://github.com/rails/rails/pull/54264 6 | gem 'rspec-rails', '~> 6.1' 7 | gem 'base64' 8 | gem 'bigdecimal' 9 | gem 'drb' 10 | gem 'mutex_m' 11 | end 12 | 13 | appraise 'rails-7' do 14 | gem 'rails', '~> 7.0.0' 15 | gem 'concurrent-ruby', '1.3.4' # https://github.com/rails/rails/pull/54264 16 | gem 'base64' 17 | gem 'bigdecimal' 18 | gem 'drb' 19 | gem 'mutex_m' 20 | end 21 | 22 | appraise 'rails-7-1' do 23 | gem 'rails', '~> 7.1.0' 24 | end 25 | 26 | appraise 'rails-7-2' do 27 | gem 'rails', '~> 7.2.0' 28 | end 29 | 30 | appraise 'rails-8' do 31 | gem 'rails', '~> 8.0.0' 32 | end 33 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 5 | 6 | # Declare your gem's dependencies in filterameter.gemspec. 7 | # Bundler will treat runtime dependencies like base dependencies, and 8 | # development dependencies will be added by default to the :development group. 9 | gemspec 10 | 11 | # Declare any dependencies that are still in development here instead of in 12 | # your gemspec. These might include edge Rails or gems from your path or 13 | # Git. Remember to move these dependencies to your gemspec before releasing 14 | # your gem to rubygems.org. 15 | 16 | # To use a debugger 17 | # gem 'byebug', group: [:development, :test] 18 | -------------------------------------------------------------------------------- /Guardfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | guard :rspec, cmd: 'bundle exec rspec' do 4 | require 'guard/rspec/dsl' 5 | dsl = Guard::RSpec::Dsl.new(self) 6 | 7 | # RSpec files 8 | rspec = dsl.rspec 9 | watch(rspec.spec_helper) { rspec.spec_dir } 10 | watch(rspec.spec_support) { rspec.spec_dir } 11 | watch(rspec.spec_files) 12 | 13 | # Ruby files 14 | ruby = dsl.ruby 15 | dsl.watch_spec_files_for(ruby.lib_files) 16 | 17 | # Rails files 18 | rails = dsl.rails 19 | dsl.watch_spec_files_for(%r{^spec/dummy/app/(.+)\.rb$}) 20 | 21 | # Rails config changes 22 | watch(rails.spec_helper) { rspec.spec_dir } 23 | watch('spec/dummy/app/controllers/application_controller.rb') { "#{rspec.spec_dir}/controllers" } 24 | end 25 | 26 | guard :rubocop, cli: %w[--display-cop-names --autocorrect-all] do 27 | watch(/.+\.rb$/) 28 | watch(%r{(?:.+/)?\.rubocop(?:_todo)?\.yml$}) { |m| File.dirname(m[0]) } 29 | end 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Todd Kummer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | begin 2 | require 'bundler/setup' 3 | rescue LoadError 4 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 5 | end 6 | 7 | require 'rdoc/task' 8 | 9 | RDoc::Task.new(:rdoc) do |rdoc| 10 | rdoc.rdoc_dir = 'rdoc' 11 | rdoc.title = 'Filterameter' 12 | rdoc.options << '--line-numbers' 13 | rdoc.rdoc_files.include('README.md') 14 | rdoc.rdoc_files.include('lib/**/*.rb') 15 | end 16 | 17 | require 'bundler/gem_tasks' 18 | -------------------------------------------------------------------------------- /bin/start_db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd spec/dummy 4 | docker compose up -d 5 | bundle exec rails db:test:prepare 6 | -------------------------------------------------------------------------------- /filterameter.gemspec: -------------------------------------------------------------------------------- 1 | $:.push File.expand_path("lib", __dir__) 2 | 3 | # Maintain your gem's version: 4 | require "filterameter/version" 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |spec| 8 | spec.name = "filterameter" 9 | spec.version = Filterameter::VERSION 10 | spec.authors = ["Todd Kummer"] 11 | spec.email = ["todd@rockridgesolutions.com"] 12 | spec.summary = "Declarative Filter Parameters" 13 | spec.description = "Declare filters in Rails controllers to increase readability and reduce boilerplate code." 14 | spec.homepage = "https://github.com/RockSolt/filterameter" 15 | spec.license = "MIT" 16 | spec.required_ruby_version = '>= 3.1.0' 17 | 18 | spec.files = Dir["{app,config,db,lib}/**/*", "MIT-LICENSE", "Rakefile", "README.md"] 19 | 20 | spec.add_dependency "rails", '>= 6.1' 21 | 22 | spec.add_development_dependency "appraisal", "~> 2.5.0" 23 | spec.add_development_dependency "guard", "~> 2.16" 24 | spec.add_development_dependency "guard-rspec", "~> 4.7" 25 | spec.add_development_dependency "guard-rubocop", "~> 1.5.0" 26 | spec.add_development_dependency "pg", "~> 1.5.4" 27 | spec.add_development_dependency "rspec-rails", "~> 7.0" 28 | spec.add_development_dependency "rubocop", "~> 1.64" 29 | spec.add_development_dependency "rubocop-packaging", "~> 0.5.2" 30 | spec.add_development_dependency "rubocop-rails", "~> 2.25" 31 | spec.add_development_dependency "rubocop-rspec", "~> 3.2.0" 32 | spec.add_development_dependency "rubocop-rspec_rails", "~> 2.30.0" 33 | spec.add_development_dependency "simplecov", "~> 0.18" 34 | end 35 | -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 6.1.0" 6 | gem "concurrent-ruby", "1.3.4" 7 | gem "rspec-rails", "~> 6.1" 8 | gem "base64" 9 | gem "bigdecimal" 10 | gem "drb" 11 | gem "mutex_m" 12 | 13 | gemspec path: "../" 14 | -------------------------------------------------------------------------------- /gemfiles/rails_7.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.0.0" 6 | gem "concurrent-ruby", "1.3.4" 7 | gem "base64" 8 | gem "bigdecimal" 9 | gem "drb" 10 | gem "mutex_m" 11 | 12 | gemspec path: "../" 13 | -------------------------------------------------------------------------------- /gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.1.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 7.2.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /gemfiles/rails_8.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "rails", "~> 8.0.0" 6 | 7 | gemspec path: "../" 8 | -------------------------------------------------------------------------------- /lib/filterameter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'zeitwerk' 4 | 5 | loader = Zeitwerk::Loader.for_gem 6 | loader.setup # ready! 7 | 8 | # # Filterameter 9 | module Filterameter 10 | class << self 11 | attr_writer :configuration 12 | end 13 | 14 | def self.configuration 15 | @configuration ||= Configuration.new 16 | end 17 | 18 | def self.reset 19 | @configuration = Configuration.new 20 | end 21 | 22 | def self.configure 23 | yield(configuration) 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/filterameter/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Configuration 5 | # 6 | # Class Configuration stores the following settings: 7 | # * action_on_undeclared_parameters 8 | # * action_on_validation_failure 9 | # * filter_key 10 | # 11 | # ## Action on Undeclared Parameters 12 | # 13 | # Occurs when the filter parameter contains any keys that are not defined. Valid 14 | # actions are :log, :raise, and false (do not take action). By default, 15 | # development will log, test will raise, and production will do nothing. 16 | # 17 | # ## Action on Validation Failure 18 | # 19 | # Occurs when a filter parameter fails a validation. Valid actions are :log, 20 | # :raise, and false (do not take action). By default, development will log, test 21 | # will raise, and production will do nothing. 22 | # 23 | # ## Filter Key 24 | # 25 | # By default, the filter parameters are nested under the key :filter. Use this 26 | # setting to override the key. 27 | # 28 | # If the filter parameters are NOT nested, set this to false. Doing so will 29 | # restrict the filter parameters to only those that have been declared, meaning 30 | # undeclared parameters are ignored (and the action_on_undeclared_parameters 31 | # configuration option does not come into play). 32 | class Configuration 33 | attr_accessor :action_on_undeclared_parameters, :action_on_validation_failure, :filter_key 34 | 35 | def initialize 36 | @action_on_undeclared_parameters = 37 | @action_on_validation_failure = 38 | if Rails.env.development? 39 | :log 40 | elsif Rails.env.test? 41 | :raise 42 | else 43 | false 44 | end 45 | 46 | @filter_key = :filter 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Declaration Error 6 | # 7 | # Error DeclarationError provides a base class for errors from filter or sort declarations. 8 | class DeclarationError < StandardError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/cannot_be_inline_scope_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Cannot Be Inline Scope Error 6 | # 7 | # Error CannotBeInlineScopeError occurs when an inline scope has been used to define a filter that takes a 8 | # parameter. This is not valid for use as a Filterameter filter because an inline scope always has an arity of -1 9 | # meaning the factory cannot tell if it has an argument or not. As such, all inline scopes are assumed to not have 10 | # arguments and thus be conditional scopes. 11 | # 12 | # [The Rails guide](https://guides.rubyonrails.org/active_record_querying.html#passing-in-arguments) provides 13 | # guidance suggesting scopes that take arguments be written as class methods. This takes that guidance a step 14 | # further and makes it a requirement for a scope that will be used as a filter. 15 | class CannotBeInlineScopeError < DeclarationError 16 | def initialize(model_name, scope_name) 17 | super(<<~ERROR.chomp) 18 | #{model_name} scope '#{scope_name}' needs to be written as a class method, not as an inline scope. This is a 19 | suggestion from the Rails guide but a requirement in order to use a scope that has an argument as a filter. 20 | ERROR 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/filter_scope_argument_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Filter Scope Argument Error 6 | # 7 | # Error FilterScopeArgumentError occurs when a scope used as a filter but does not have either zero or one 8 | # arument. A conditional scope filter should take zero arguments; other scope filters should take one argument. 9 | class FilterScopeArgumentError < DeclarationError 10 | def initialize(model_name, scope_name) 11 | super("#{model_name} scope '#{scope_name}' takes too many arguments. Scopes for filters can only have either " \ 12 | 'zero (conditional scope) or one argument') 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/no_such_attribute_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # No Such Attribute Error 6 | # 7 | # Error NoSuchAttributeError occurs when a filter or sort references an attribute that does not exist on the model. 8 | # The most likely case of this is a typo. Note that if the typo was supposed to reference a scope, this error is 9 | # added as attributes are assumed when no matching scopes are found. 10 | class NoSuchAttributeError < DeclarationError 11 | def initialize(model_name, attribute_name) 12 | super("Attribute '#{attribute_name}' does not exist on #{model_name}") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/not_a_scope_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Not A Scope Error 6 | # 7 | # Error NotAScopeError flags a class method that has been used as a filter but is not a scope. This could occur if 8 | # there is a class method of the same name an attribute, in which case the class method is going to block the 9 | # creation of an attribute filter. The work around (if the class method cannot be renamed) is to create a scope 10 | # that provides a filter on the attribute. 11 | class NotAScopeError < DeclarationError 12 | def initialize(model_name, scope_name) 13 | super("#{model_name} class method '#{scope_name}' is not a scope.") 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/sort_scope_requires_one_argument_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Sort Scope Requires One Argument Error 6 | # 7 | # Error SortScopeRequiresOneArgumentError occurs when a sort has been declared for a scope that does not take 8 | # exactly one argument. Sort scopes must take a single argument and will receive either :asc or :desc to indicate 9 | # the direction. 10 | class SortScopeRequiresOneArgumentError < DeclarationError 11 | def initialize(model_name, scope_name) 12 | super("#{model_name} scope '#{scope_name}' must take exactly one argument to sort by.") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/filterameter/declaration_errors/unexpected_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module DeclarationErrors 5 | # # Unexpected Error 6 | # 7 | # Error UnexpectedError occurs when the filter or scope factory raises an exception that the validator did not 8 | # expect. 9 | class UnexpectedError < DeclarationError 10 | def initialize(error) 11 | super(<<~ERROR) 12 | The previous error was unexpected. It occurred during while building a filter or sort (see below). Please 13 | report this to the library so that the error can be handled and provide clearer feedback about what is wrong 14 | with the declaration. 15 | 16 | #{error.message} 17 | #{error.backtrace.join("\n\t")} 18 | ERROR 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/filterameter/declarations_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Declarations Validator 5 | # 6 | # Class DeclarationsValidtor fetches each filter and sort from the registry to validate the declaration. This class 7 | # can be accessed from the controller as `declarations_validator` (via the FilterCoordinator) and be used in tests. 8 | # 9 | # Use the `valid?` method to test, then report errors with the `errors` attribute. 10 | # 11 | # A test in RSpec might look like this: 12 | # 13 | # expect(WidgetsController.declarations_validator).to be_valid 14 | # 15 | # In Minitest it might look like this: 16 | # 17 | # validator = WidgetsController.declarations_validator 18 | # assert_predicate validator, :valid?, -> { validator.errors } 19 | class DeclarationsValidator 20 | include Filterameter::Errors 21 | 22 | def initialize(controller_name, model, registry) 23 | @controller_name = controller_name 24 | @model = model 25 | @registry = registry 26 | end 27 | 28 | def inspect 29 | "filter declarations on #{@controller_name.titleize}Controller" 30 | end 31 | 32 | def errors 33 | @errors&.join("\n") 34 | end 35 | 36 | private 37 | 38 | def validate(_) 39 | @errors.push(*validation_errors_for('filter', fetch_filters)) 40 | @errors.push(*validation_errors_for('sort', fetch_sorts)) 41 | end 42 | 43 | def fetch_filters 44 | @registry.filter_parameter_names.index_with { |name| fetch_filter(name) } 45 | end 46 | 47 | def fetch_filter(name) 48 | @registry.fetch_filter(name) 49 | rescue StandardError => e 50 | FactoryErrors.new(e) 51 | end 52 | 53 | def fetch_sorts 54 | (@registry.sort_parameter_names - @registry.filter_parameter_names).index_with { |name| fetch_sort(name) } 55 | end 56 | 57 | def fetch_sort(name) 58 | @registry.fetch_sort(name) 59 | rescue StandardError => e 60 | FactoryErrors.new(e) 61 | end 62 | 63 | def validation_errors_for(type, items) 64 | items.select { |_name, item| item.respond_to? :valid? } 65 | .reject { |_name, item| item.valid?(@model) } 66 | .map { |name, item| error_message(type, name, item.errors) } 67 | end 68 | 69 | def error_message(type, name, errors) 70 | "\nInvalid #{type} for '#{name}':\n #{errors.join("\n ")}" 71 | end 72 | 73 | # # Factory Errors 74 | # 75 | # Class FactoryErrors is swapped in if the fetch from a factory fails. It is always invalid and provides the reason. 76 | class FactoryErrors 77 | attr_reader :errors 78 | 79 | def initialize(error) 80 | @errors = [wrap_if_unexpected(error)] 81 | end 82 | 83 | def valid?(_) 84 | false 85 | end 86 | 87 | def to_s 88 | @errors.join("\n") 89 | end 90 | 91 | private 92 | 93 | def wrap_if_unexpected(error) 94 | return error if error.is_a?(Filterameter::DeclarationErrors::DeclarationError) 95 | 96 | Filterameter::DeclarationErrors::UnexpectedError.new(error) 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /lib/filterameter/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Errors 5 | # 6 | # Module Errors provides `valid?` and `errors` to implementing classes. If the `valid?` method is not overridden, 7 | # then it returns true. 8 | # 9 | # To provide validations rules, override `validate`. If any fail, populate the errors attribute with the 10 | # reason for the failures. 11 | module Errors 12 | attr_reader :errors 13 | 14 | def valid?(model = nil) 15 | @errors = [] 16 | validate(model) 17 | @errors.empty? 18 | end 19 | 20 | private 21 | 22 | def validate(_model); end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | class FilterameterError < StandardError 6 | end 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions/cannot_determine_model_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | # # Cannot Determine Model Error 6 | # 7 | # Class CannotDetermineModelError is raised when the model class cannot be determined from either the controller 8 | # name or controller path. This is a setup issue; the resolution is for the controller to specify the model class 9 | # explicitly by adding a call to `filter_model`. 10 | class CannotDetermineModelError < FilterameterError 11 | def initialize(name, path) 12 | super("Cannot determine model name from controller name #{value_and_classify(name)} " \ 13 | "or path #{value_and_classify(path)}. Declare the model explicitly with filter_model.") 14 | end 15 | 16 | private 17 | 18 | def value_and_classify(value) 19 | "(#{value} => #{value.classify})" 20 | rescue StandardError 21 | value 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions/collection_association_sort_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | # # Collection Association Sort Error 6 | # 7 | # Class CollectionAssociationSortError is raised when a sort is attempted on a 8 | # collection association. (Sorting is only valid on *singular* associations.) 9 | class CollectionAssociationSortError < FilterameterError 10 | def initialize(declaration) 11 | super("Sorting is not allowed on collection associations: \n\t\t#{declaration}") 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions/invalid_association_declaration_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | # # Invalid Association Declaration Error 6 | # 7 | # Class InvalidAssociationDeclarationError is raised when the declared association(s) are not valid. 8 | class InvalidAssociationDeclarationError < FilterameterError 9 | def initialize(name, model_name, associations) 10 | super("The association(s) declared on filter #{name} are not valid for model #{model_name}: #{associations}") 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions/undeclared_parameter_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | # # Undeclared Parameter Error 6 | # 7 | # Class UndeclaredParameterError is raised when a request contains filter parameters that have not been declared. 8 | # Configuration setting `action_on_undeclared_parameters` determines whether or not the exception is raised. 9 | class UndeclaredParameterError < FilterameterError 10 | attr_reader :key 11 | 12 | def initialize(key) 13 | super 14 | @key = key 15 | end 16 | 17 | def message 18 | "The following filter parameter has not been declared: #{key}" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/filterameter/exceptions/validation_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Exceptions 5 | # # Validation Error 6 | # 7 | # Class ValidationError is raised when a specified parameter fails a validation. Configuration setting 8 | # `action_on_validation_failure` determines whether or not the exception is raised. 9 | class ValidationError < FilterameterError 10 | attr_reader :errors 11 | 12 | def initialize(errors) 13 | super 14 | @errors = errors 15 | end 16 | 17 | def message 18 | "The following parameter(s) failed validation: #{errors.full_messages}" 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/filterameter/filter_coordinator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/inflector' 4 | 5 | require 'active_support/rails' 6 | require 'action_dispatch' 7 | require 'action_controller/metal/live' 8 | require 'action_controller/metal/strong_parameters' 9 | 10 | module Filterameter 11 | # # Filter Coordinator 12 | # 13 | # Class FilterCoordinator stores the configuration declared via class-level method calls such as the list of 14 | # filters and the optionally declared model class. Each controller will have one instance of the coordinator 15 | # stored as a class variable. 16 | # 17 | # The coordinators encapsulate references to the Query Builder and Filter Registry to keep the namespace clean for 18 | # controllers that implement filter parameters. 19 | class FilterCoordinator 20 | attr_writer :query_variable_name 21 | 22 | delegate :add_filter, :add_sort, :filter_parameter_names, to: :registry 23 | delegate :build_query, to: :query_builder 24 | 25 | def initialize(controller_name, controller_path) 26 | @controller_name = controller_name 27 | @controller_path = controller_path 28 | end 29 | 30 | def model_class=(model_class) 31 | @model_class = model_class.is_a?(String) ? model_class.constantize : model_class 32 | end 33 | 34 | def query_builder 35 | @query_builder ||= Filterameter::QueryBuilder.new(default_query, @default_sort, registry) 36 | end 37 | 38 | def query_variable_name 39 | @query_variable_name ||= model_class.model_name.plural 40 | end 41 | 42 | def default_sort=(sort_and_direction_pairs) 43 | @default_sort = sort_and_direction_pairs.map do |name, direction| 44 | Filterameter::Helpers::RequestedSort.new(name, direction) 45 | end 46 | end 47 | 48 | def declarations_validator 49 | Filterameter::DeclarationsValidator.new(@controller_name, model_class, registry) 50 | end 51 | 52 | private 53 | 54 | def model_class 55 | @model_class ||= @controller_name.classify.safe_constantize || 56 | @controller_path.classify.safe_constantize || 57 | raise(Filterameter::Exceptions::CannotDetermineModelError.new(@controller_name, 58 | @controller_path)) 59 | end 60 | 61 | def default_query 62 | model_class.all 63 | end 64 | 65 | # lazy so that model_class can be optionally set 66 | def registry 67 | @registry ||= Filterameter::Registries::Registry.new(model_class) 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/filterameter/filter_declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support/core_ext/array/wrap' 4 | 5 | module Filterameter 6 | # # Filter Declaration 7 | # 8 | # Class FilterDeclaration captures the filter declaration within the controller. 9 | # 10 | # When the min_only or max_only range option is specified, in addition to the attribute filter which carries that 11 | # option, the registry builds a duplicate declaration that also carries the range_type flag (as either :minimum 12 | # or :maximum). 13 | # 14 | # The predicate methods `min_only?` and `max_only?` answer what was declared; the predicate methods `minimum_range?` 15 | # and `maximum_range?` answer what type of filter should be built. 16 | class FilterDeclaration 17 | VALID_RANGE_OPTIONS = [true, :min_only, :max_only].freeze 18 | 19 | attr_reader :name, :parameter_name, :association, :validations 20 | 21 | def initialize(parameter_name, options, range_type: nil) 22 | @parameter_name = parameter_name.to_s 23 | 24 | validate_options(options) 25 | @name = options.fetch(:name, parameter_name).to_s 26 | @association = Array.wrap(options[:association]).presence 27 | @validations = Array.wrap(options[:validates]) 28 | @raw_partial_options = options.fetch(:partial, false) 29 | @raw_range = options[:range] 30 | @range_type = range_type 31 | @sortable = options.fetch(:sortable, true) 32 | end 33 | 34 | def nested? 35 | !@association.nil? 36 | end 37 | 38 | def validations? 39 | !@validations.empty? 40 | end 41 | 42 | def partial_search? 43 | partial_options.present? 44 | end 45 | 46 | def partial_options 47 | @partial_options ||= @raw_partial_options ? Options::PartialOptions.new(@raw_partial_options) : nil 48 | end 49 | 50 | def range_enabled? 51 | @raw_range.present? 52 | end 53 | 54 | def range? 55 | @raw_range == true 56 | end 57 | 58 | def min_only? 59 | @raw_range == :min_only 60 | end 61 | 62 | def max_only? 63 | @raw_range == :max_only 64 | end 65 | 66 | def minimum_range? 67 | @range_type == :minimum 68 | end 69 | 70 | def maximum_range? 71 | @range_type == :maximum 72 | end 73 | 74 | def sortable? 75 | @sortable 76 | end 77 | 78 | def to_s 79 | options = {} 80 | options[:name] = ":#{@name}" if @parameter_name != @name 81 | options[:association] = @association if nested? 82 | options[:partial] = partial_options if partial_options 83 | 84 | (["filter :#{@parameter_name}"] + options.map { |k, v| "#{k}: #{v}" }) 85 | .join(', ') 86 | end 87 | 88 | private 89 | 90 | def validate_options(options) 91 | options.assert_valid_keys(:name, :association, :validates, :partial, :range, :sortable) 92 | validate_range(options[:range]) if options.key?(:range) 93 | end 94 | 95 | def validate_range(range) 96 | return if VALID_RANGE_OPTIONS.include?(range) 97 | 98 | raise ArgumentError, "Invalid range option: #{range}" 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/filterameter/filter_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Filter Factory 5 | # 6 | # Class FilterFactory builds a filter from a FilterDeclaration. 7 | class FilterFactory 8 | def initialize(model_class) 9 | @model_class = model_class 10 | end 11 | 12 | def build(declaration) 13 | context = Helpers::DeclarationWithModel.new(@model_class, declaration) 14 | 15 | if declaration.nested? 16 | build_nested_filter(declaration, context) 17 | else 18 | build_filter(@model_class, declaration, context.scope?) 19 | end 20 | end 21 | 22 | private 23 | 24 | def build_nested_filter(declaration, context) 25 | model = context.model_from_association 26 | filter = build_filter(model, declaration, context.scope?) 27 | nested_filter_class = context.any_collections? ? Filters::NestedCollectionFilter : Filters::NestedFilter 28 | 29 | nested_filter_class.new(declaration.association, model, filter) 30 | end 31 | 32 | def build_filter(model, declaration, declaration_is_a_scope) # rubocop:disable Metrics/MethodLength 33 | if declaration_is_a_scope 34 | build_scope_filter(model, declaration) 35 | elsif declaration.partial_search? 36 | Filterameter::Filters::MatchesFilter.new(declaration.name, declaration.partial_options) 37 | elsif declaration.minimum_range? 38 | Filterameter::Filters::MinimumFilter.new(model, declaration.name) 39 | elsif declaration.maximum_range? 40 | Filterameter::Filters::MaximumFilter.new(model, declaration.name) 41 | else 42 | Filterameter::Filters::AttributeFilter.new(declaration.name) 43 | end 44 | end 45 | 46 | # Inline scopes return an arity of -1 regardless of arguments, so those will always be assumed to be 47 | # conditional scopes. To have a filter that passes a value to a scope, it must be a class method. 48 | def build_scope_filter(model, declaration) 49 | number_of_arguments = model.method(declaration.name).arity 50 | if number_of_arguments < 1 51 | Filterameter::Filters::ConditionalScopeFilter.new(declaration.name) 52 | elsif number_of_arguments == 1 53 | Filterameter::Filters::ScopeFilter.new(declaration.name) 54 | else 55 | raise Filterameter::DeclarationErrors::FilterScopeArgumentError.new(model.name, declaration.name) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/filterameter/filters/arel_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Arel Filter 6 | # 7 | # Class ArelFilter is a base class for arel queries. It does not implement 8 | # `apply`. 9 | class ArelFilter 10 | include Filterameter::Errors 11 | include Filterameter::Filters::AttributeValidator 12 | 13 | def initialize(model, attribute_name) 14 | @attribute_name = attribute_name 15 | @arel_attribute = model.arel_table[attribute_name] 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/filterameter/filters/attribute_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Attribute Filter 6 | # 7 | # Class AttributeFilter leverages ActiveRecord's where query method to add criteria for an attribute. 8 | class AttributeFilter 9 | include Filterameter::Errors 10 | include AttributeValidator 11 | 12 | def initialize(attribute_name) 13 | @attribute_name = attribute_name 14 | end 15 | 16 | def apply(query, value) 17 | query.where(@attribute_name => value) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/filterameter/filters/attribute_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Attribute Validator 6 | # 7 | # Module AttributeValidator validates that the attribute exists on the model. 8 | module AttributeValidator 9 | private 10 | 11 | def validate(model) 12 | return if model.attribute_method? @attribute_name 13 | 14 | @errors << Filterameter::DeclarationErrors::NoSuchAttributeError.new(model, @attribute_name) 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/filterameter/filters/conditional_scope_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Conditional Scope Filter 6 | # 7 | # Class ConditionalScopeFilter applies the scope if the parameter is not false. 8 | class ConditionalScopeFilter 9 | include Filterameter::Errors 10 | 11 | def initialize(scope_name) 12 | @scope_name = scope_name 13 | end 14 | 15 | def apply(query, value) 16 | return query unless ActiveModel::Type::Boolean.new.cast(value) 17 | 18 | query.public_send(@scope_name) 19 | end 20 | 21 | private 22 | 23 | def validate(model) 24 | validate_is_a_scope(model) 25 | rescue ArgumentError 26 | @errors << Filterameter::DeclarationErrors::CannotBeInlineScopeError.new(model.name, @scope_name) 27 | end 28 | 29 | def validate_is_a_scope(model) 30 | return if model.public_send(@scope_name).is_a? ActiveRecord::Relation 31 | 32 | @errors << Filterameter::DeclarationErrors::NotAScopeError.new(model.name, @scope_name) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/filterameter/filters/matches_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Matches Filter 6 | # 7 | # Class MatchesFilter uses arel's `matches` to generate a LIKE query. 8 | class MatchesFilter 9 | include Filterameter::Errors 10 | include Filterameter::Filters::AttributeValidator 11 | 12 | def initialize(attribute_name, options) 13 | @attribute_name = attribute_name 14 | @prefix = options.match_anywhere? ? '%' : nil 15 | @suffix = options.match_anywhere? || options.match_from_start? ? '%' : nil 16 | @case_sensitive = options.case_sensitive? 17 | end 18 | 19 | def apply(query, value) 20 | arel = query.arel_table[@attribute_name].matches("#{@prefix}#{value}#{@suffix}", false, @case_sensitive) 21 | query.where(arel) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/filterameter/filters/maximum_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Maximum Filter 6 | # 7 | # Class MaximumFilter adds criteria for all values greater than or equal to a maximum. 8 | class MaximumFilter < ArelFilter 9 | def apply(query, value) 10 | query.where(@arel_attribute.lteq(value)) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/filterameter/filters/minimum_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Minimum Filter 6 | # 7 | # Class MinimumFilter adds criteria for all values greater than or equal to a minimum. 8 | class MinimumFilter < ArelFilter 9 | def apply(query, value) 10 | query.where(@arel_attribute.gteq(value)) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/filterameter/filters/nested_collection_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Nested Collection Filter 6 | # 7 | # Class NestedCollectionFilter joins the nested table(s), merges the filter to the association's model, then makes 8 | # the results distinct. 9 | class NestedCollectionFilter < NestedFilter 10 | def apply(*) 11 | super.distinct 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/filterameter/filters/nested_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Nested Attribute Filter 6 | # 7 | # Class NestedFilter joins the nested table(s) then merges the filter to the association's model. 8 | class NestedFilter 9 | include Filterameter::Errors 10 | 11 | def initialize(association_names, association_model, attribute_filter) 12 | @joins_values = Filterameter::Helpers::JoinsValuesBuilder.build(association_names) 13 | @association_model = association_model 14 | @attribute_filter = attribute_filter 15 | end 16 | 17 | def apply(query, value) 18 | query.joins(@joins_values) 19 | .merge(@attribute_filter.apply(@association_model.all, value)) 20 | end 21 | 22 | private 23 | 24 | def validate(model) 25 | @errors.push(*@attribute_filter.errors) unless @attribute_filter.valid?(@association_model) 26 | validate_associations(model) 27 | end 28 | 29 | def validate_associations(model) 30 | model.joins(@joins_values).to_sql 31 | rescue ActiveRecord::ConfigurationError => e 32 | @errors << e.message 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/filterameter/filters/scope_filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Filters 5 | # # Scope Filter 6 | # 7 | # Class ScopeFilter applies the named scope passing in the parameter value. 8 | class ScopeFilter 9 | include Filterameter::Errors 10 | 11 | def initialize(scope_name) 12 | @scope_name = scope_name 13 | end 14 | 15 | def apply(query, value) 16 | query.public_send(@scope_name, value) 17 | end 18 | 19 | private 20 | 21 | def validate(model) 22 | validate_is_a_scope(model) 23 | end 24 | 25 | def validate_is_a_scope(model) 26 | return if model.public_send(@scope_name, '42').is_a? ActiveRecord::Relation 27 | 28 | @errors << Filterameter::DeclarationErrors::NotAScopeError.new(model.name, @scope_name) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/filterameter/helpers/declaration_with_model.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Helpers 5 | # # Declaration with Model 6 | # 7 | # Class DeclarationWithModel inspects the declaration under the context of the model. This enables 8 | # predicate methods as well as drilling through associations. 9 | class DeclarationWithModel 10 | def initialize(model, declaration) 11 | @model = model 12 | @declaration = declaration 13 | end 14 | 15 | def scope? 16 | model = @declaration.nested? ? model_from_association : @model 17 | 18 | model.respond_to?(@declaration.name) && 19 | # checking dangerous_class_method? excludes any names that cannot be scope names, such as "name" 20 | !model.dangerous_class_method?(@declaration.name) 21 | end 22 | 23 | def any_collections? 24 | @declaration.association.reduce(@model) do |related_model, name| 25 | association = related_model.reflect_on_association(name) 26 | return true if association.collection? 27 | 28 | association.klass 29 | end 30 | 31 | false 32 | end 33 | 34 | def model_from_association 35 | @declaration.association.flatten.reduce(@model) do |memo, name| 36 | association = memo.reflect_on_association(name) 37 | raise_invalid_association if association.nil? 38 | 39 | association.klass 40 | end 41 | end 42 | 43 | private 44 | 45 | def raise_invalid_association 46 | raise Filterameter::Exceptions::InvalidAssociationDeclarationError.new(@declaration.name, 47 | @model.name, 48 | @declaration.association) 49 | end 50 | end 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/filterameter/helpers/joins_values_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Helpers 5 | # # Joins Values Builder 6 | # 7 | # Class JoinsValuesBuilder evaluates an array of names to return either the single entry when there is only 8 | # one element in the array or a nested hash when there is more than one element. This is the argument that is 9 | # passed into the ActiveRecord query method `joins`. 10 | class JoinsValuesBuilder 11 | def self.build(association_names) 12 | return association_names.first if association_names.size == 1 13 | 14 | new(association_names).to_h 15 | end 16 | 17 | def initialize(association_names) 18 | @association_names = association_names 19 | end 20 | 21 | def to_h 22 | {}.tap do |nested_hash| 23 | @association_names.reduce(nested_hash) { |memo, name| memo.store(name, {}) } 24 | end 25 | end 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/filterameter/helpers/requested_sort.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Helpers 5 | # # Reqested Sort 6 | # 7 | # Class RequestedSort parses the name and direction from a sort segment. 8 | class RequestedSort 9 | SIGN_AND_NAME = /(?[+|-]?)(?\w+)/ 10 | attr_reader :name, :direction 11 | 12 | def self.parse(sort) 13 | parsed = sort.match SIGN_AND_NAME 14 | 15 | new(parsed['name'], 16 | parsed['sign'] == '-' ? :desc : :asc) 17 | end 18 | 19 | def initialize(name, direction) 20 | @name = name 21 | @direction = direction 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/filterameter/log_subscriber.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Log Subscriber 5 | # 6 | # Class LogSubscriber provides instrumentation for events. 7 | class LogSubscriber < ActiveSupport::LogSubscriber 8 | def validation_failure(event) 9 | debug do 10 | errors = event.payload[:errors] 11 | ([' The following filter validation errors occurred:'] + errors.full_messages).join("\n - ") 12 | end 13 | end 14 | 15 | def undeclared_parameters(event) 16 | debug do 17 | key = event.payload[:key] 18 | " Undeclared filter parameter: #{key}" 19 | end 20 | end 21 | end 22 | end 23 | 24 | Filterameter::LogSubscriber.attach_to :filterameter 25 | -------------------------------------------------------------------------------- /lib/filterameter/options/partial_options.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Options 5 | # # Partial Options 6 | # 7 | # Class PartialOptions parses the options passed in as partial, then exposes 8 | # those. Here are the options along with their valid values: 9 | # * match: anywhere (default), from_start, dynamic 10 | # * case_sensitive: true, false (default) 11 | # 12 | # Options may be specified by passing a hash with the option keys: 13 | # 14 | # partial: { match: :from_start, case_sensitive: true } 15 | # 16 | # There are two shortcuts: the partial option can be declared with `true`, which 17 | # just uses the defaults; or the partial option can be declared with the match 18 | # option directly, such as partial: :from_start. 19 | class PartialOptions 20 | VALID_OPTIONS = %i[match case_sensitive].freeze 21 | VALID_MATCH_OPTIONS = %w[anywhere from_start dynamic].freeze 22 | 23 | def initialize(options) 24 | @match = 'anywhere' 25 | @case_sensitive = false 26 | 27 | case options 28 | when TrueClass 29 | nil 30 | when Hash 31 | evaluate_hash(options) 32 | when String, Symbol 33 | assign_match(options) 34 | end 35 | end 36 | 37 | def case_sensitive? 38 | @case_sensitive 39 | end 40 | 41 | def match_anywhere? 42 | @match == 'anywhere' 43 | end 44 | 45 | def match_from_start? 46 | @match == 'from_start' 47 | end 48 | 49 | def match_dynamically? 50 | @match == 'dynamic' 51 | end 52 | 53 | def to_s 54 | if case_sensitive? 55 | case_sensitive_to_s 56 | elsif match_anywhere? 57 | 'true' 58 | else 59 | ":#{@match}" 60 | end 61 | end 62 | 63 | private 64 | 65 | def evaluate_hash(options) 66 | options.assert_valid_keys(:match, :case_sensitive) 67 | assign_match(options[:match]) if options.key?(:match) 68 | assign_case_sensitive(options[:case_sensitive]) if options.key?(:case_sensitive) 69 | end 70 | 71 | def assign_match(value) 72 | validate_match(value) 73 | @match = value.to_s 74 | end 75 | 76 | def validate_match(value) 77 | return if VALID_MATCH_OPTIONS.include? value.to_s 78 | 79 | raise ArgumentError, 80 | "Invalid match option for partial: #{value}. Valid options are #{VALID_MATCH_OPTIONS.to_sentence}" 81 | end 82 | 83 | def assign_case_sensitive(value) 84 | validate_case_sensitive(value) 85 | @case_sensitive = value 86 | end 87 | 88 | def validate_case_sensitive(value) 89 | return if value.is_a?(TrueClass) || value.is_a?(FalseClass) 90 | 91 | raise ArgumentError, "Invalid case_sensitive option for partial: #{value}. Valid options are true and false." 92 | end 93 | 94 | def case_sensitive_to_s 95 | match_anywhere? ? '{ case_sensitive: true }' : "{ match: :#{@match}, case_sensitive: true }" 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/filterameter/parameters_base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_model/attribute_assignment' 4 | require 'active_model/validations' 5 | 6 | module Filterameter 7 | # # Parameters 8 | # 9 | # Class Parameters is sub-classed to provide controller-specific validations. 10 | class ParametersBase 11 | include ActiveModel::Validations 12 | include Filterameter::Validators 13 | 14 | def self.build_sub_class(declarations) 15 | Class.new(self).tap do |sub_class| 16 | declarations.select(&:validations?).each do |declaration| 17 | sub_class.add_validation(declaration.parameter_name, declaration.validations) 18 | end 19 | end 20 | end 21 | 22 | def self.name 23 | 'ControllerParameters' 24 | end 25 | 26 | def self.add_validation(parameter_name, validations) 27 | attr_accessor parameter_name 28 | 29 | default_options = { allow_nil: true } 30 | validations.each do |validation| 31 | validates parameter_name, default_options.merge(validation) 32 | end 33 | end 34 | 35 | def initialize(attributes) 36 | attributes.each { |k, v| assign_attribute(k, v) } 37 | end 38 | 39 | private 40 | 41 | def assign_attribute(key, value) 42 | setter = :"#{key}=" 43 | public_send(setter, value) if respond_to?(setter) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/filterameter/query_builder.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Query Builder 5 | # 6 | # Class Query Builder turns filter parameters into a query. 7 | # 8 | # The query builder is instantiated by the filter coordinator. The default query currently is simple `all`. The 9 | # default sort comes for the controller declaration of the same name; it is optional and the value may be nil. 10 | # 11 | # If the request includes a sort, it is always applied. If not, the following 12 | # logic kicks in to provide a sort: 13 | # * if the starting query includes a sort, no additional sort is applied 14 | # * if a default sort has been declared, it is applied 15 | # * if neither of those provides a sort, then the fallback is primary key desc 16 | class QueryBuilder 17 | def initialize(default_query, default_sort, filter_registry) 18 | @default_query = default_query 19 | @default_sort = default_sort 20 | @registry = filter_registry 21 | end 22 | 23 | def build_query(filter_params, starting_query = nil) 24 | sorts, filters = parse_filter_params(filter_params.stringify_keys) 25 | query = apply_filters(starting_query || @default_query, filters) 26 | apply_sorts(query, sorts) 27 | end 28 | 29 | private 30 | 31 | def parse_filter_params(filter_params) 32 | sort = parse_sorts(filter_params.delete('sort')) 33 | params = filter_params.reject { |_k, v| v.is_a?(String) && v.empty? } 34 | .then { |p| remove_invalid_values(p) } 35 | [sort, params] 36 | end 37 | 38 | def parse_sorts(sorts) 39 | Array.wrap(sorts).map { |sort| Helpers::RequestedSort.parse(sort) } 40 | end 41 | 42 | def apply_filters(query, filters) 43 | filters.tap { |parameters| convert_min_and_max_to_range(parameters) } 44 | .reduce(query) do |memo, (name, value)| 45 | add_filter_parameter_to_query(memo, name, value) 46 | end 47 | end 48 | 49 | def add_filter_parameter_to_query(query, filter_name, parameter_value) 50 | @registry.fetch_filter(filter_name).apply(query, parameter_value) 51 | rescue Filterameter::Exceptions::FilterameterError => e 52 | handle_undeclared_parameter(e) 53 | query 54 | end 55 | 56 | def apply_sorts(query, requested_sorts) 57 | return query if no_sort_requested_but_starting_query_includes_sort?(query, requested_sorts) 58 | 59 | sorts = requested_sorts.presence || @default_sort 60 | if sorts.present? 61 | sorts.reduce(query) { |memo, sort| add_sort_to_query(memo, sort.name, sort.direction) } 62 | else 63 | sort_by_primary_key_desc(query) 64 | end 65 | end 66 | 67 | def no_sort_requested_but_starting_query_includes_sort?(query, requested_sorts) 68 | requested_sorts.empty? && query.order_values.present? 69 | end 70 | 71 | def add_sort_to_query(query, name, direction) 72 | @registry.fetch_sort(name).apply(query, direction) 73 | rescue Filterameter::Exceptions::FilterameterError => e 74 | handle_undeclared_parameter(e) 75 | query 76 | end 77 | 78 | def sort_by_primary_key_desc(query) 79 | primary_key = query.model.primary_key 80 | query.order(primary_key => :desc) 81 | end 82 | 83 | # if both min and max are present in the query parameters, replace with range 84 | def convert_min_and_max_to_range(parameters) 85 | @registry.ranges.each do |attribute_name, min_max_names| 86 | next unless min_max_names.values.all? { |min_max_name| parameters[min_max_name].present? } 87 | 88 | parameters[attribute_name] = Range.new(parameters.delete(min_max_names[:min]), 89 | parameters.delete(min_max_names[:max])) 90 | end 91 | end 92 | 93 | # TODO: this handles any runtime exceptions, not just undeclared parameter 94 | # errors: 95 | # * should the config option be more generalized? 96 | # * or should there be a config option for each type of error? 97 | def handle_undeclared_parameter(exception) 98 | action = Filterameter.configuration.action_on_undeclared_parameters 99 | return unless action 100 | 101 | case action 102 | when :log 103 | ActiveSupport::Notifications.instrument('undeclared_parameters.filterameter', key: exception.key) 104 | when :raise 105 | raise exception 106 | end 107 | end 108 | 109 | def remove_invalid_values(filter_params) 110 | validator = validator_class.new(filter_params) 111 | return filter_params if validator.valid? 112 | 113 | case Filterameter.configuration.action_on_validation_failure 114 | when :log 115 | ActiveSupport::Notifications.instrument('validation_failure.filterameter', errors: validator.errors) 116 | when :raise 117 | raise Filterameter::Exceptions::ValidationError, validator.errors 118 | end 119 | 120 | filter_params.except(*validator.errors.attribute_names.map(&:to_s)) 121 | end 122 | 123 | def validator_class 124 | @validator_class ||= Filterameter::ParametersBase.build_sub_class(@registry.filter_declarations) 125 | end 126 | end 127 | end 128 | -------------------------------------------------------------------------------- /lib/filterameter/registries/filter_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Registries 5 | # # Filter Registry 6 | # 7 | # Class FilterRegistry is a collection of the filters. It captures the filter declarations when classes are loaded, 8 | # then uses the injected FilterFactory to build the filters on demand as they are needed. 9 | class FilterRegistry < SubRegistry 10 | attr_reader :ranges 11 | 12 | def initialize(factory) 13 | super 14 | @ranges = {} 15 | end 16 | 17 | def build_declaration(name, options) 18 | Filterameter::FilterDeclaration.new(name, options).tap do |fd| 19 | add_declarations_for_range(fd, options, name) if fd.range_enabled? 20 | end 21 | end 22 | 23 | def filter_declarations 24 | @declarations.values 25 | end 26 | 27 | private 28 | 29 | # if range is enabled, then in addition to the attribute filter this also adds min and/or max filters 30 | def add_declarations_for_range(attribute_declaration, options, parameter_name) 31 | add_range_minimum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.min_only? 32 | add_range_maximum(parameter_name, options) if attribute_declaration.range? || attribute_declaration.max_only? 33 | capture_range_declaration(parameter_name) if attribute_declaration.range? 34 | end 35 | 36 | def add_range_minimum(parameter_name, options) 37 | parameter_name_min = "#{parameter_name}_min" 38 | options_with_name = options.with_defaults(name: parameter_name) 39 | @declarations[parameter_name_min] = Filterameter::FilterDeclaration.new(parameter_name_min, 40 | options_with_name, 41 | range_type: :minimum) 42 | end 43 | 44 | def add_range_maximum(parameter_name, options) 45 | parameter_name_max = "#{parameter_name}_max" 46 | options_with_name = options.with_defaults(name: parameter_name) 47 | @declarations[parameter_name_max] = Filterameter::FilterDeclaration.new(parameter_name_max, 48 | options_with_name, 49 | range_type: :maximum) 50 | end 51 | 52 | def capture_range_declaration(name) 53 | @ranges[name] = { min: "#{name}_min", max: "#{name}_max" } 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /lib/filterameter/registries/registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Registries 5 | # # Registry 6 | # 7 | # Class Registry records declarations and allows resulting filters and sorts to be fetched from sub-registries. 8 | class Registry 9 | delegate :filter_declarations, :ranges, to: :@filter_registry 10 | delegate :parameter_names, prefix: :filter, to: :@filter_registry 11 | delegate :parameter_names, prefix: :sort, to: :@sort_registry 12 | 13 | def initialize(model_class) 14 | @filter_registry = Filterameter::Registries::FilterRegistry.new(Filterameter::FilterFactory.new(model_class)) 15 | @sort_registry = Filterameter::Registries::SortRegistry.new(Filterameter::SortFactory.new(model_class)) 16 | end 17 | 18 | def add_filter(parameter_name, options) 19 | declaration = @filter_registry.add(parameter_name, options) 20 | add_sort(parameter_name, options.slice(:name, :association)) if declaration.sortable? 21 | end 22 | 23 | def fetch_filter(parameter_name) 24 | @filter_registry.fetch(parameter_name) 25 | end 26 | 27 | def add_sort(parameter_name, options) 28 | @sort_registry.add(parameter_name, options) 29 | end 30 | 31 | def fetch_sort(parameter_name) 32 | @sort_registry.fetch(parameter_name) 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/filterameter/registries/sort_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Registries 5 | # # Sort Registry 6 | # 7 | # Class SortRegistry is a collection of the sorts. It captures the declarations when classes are loaded, 8 | # then uses the injected SortFactory to build the sorts on demand as they are needed. 9 | class SortRegistry < SubRegistry 10 | def sort_parameter_names 11 | @declarations.keys 12 | end 13 | 14 | private 15 | 16 | def build_declaration(name, options) 17 | Filterameter::SortDeclaration.new(name, options) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/filterameter/registries/sub_registry.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Registries 5 | # # SubRegistry 6 | # 7 | # Class SubRegistry provides add and fetch methods as well as the initialization for sub-registries. 8 | # 9 | # Subclasses must implement build_declaration. 10 | class SubRegistry 11 | def initialize(factory) 12 | @factory = factory 13 | @declarations = {} 14 | @registry = {} 15 | end 16 | 17 | def add(parameter_name, options) 18 | name = parameter_name.to_s 19 | @declarations[name] = build_declaration(name, options) 20 | end 21 | 22 | def fetch(parameter_name) 23 | name = parameter_name.to_s 24 | @registry.fetch(name) do 25 | raise Filterameter::Exceptions::UndeclaredParameterError, name unless @declarations.keys.include?(name) 26 | 27 | @registry[name] = @factory.build(@declarations[name]) 28 | end 29 | end 30 | 31 | def parameter_names 32 | @declarations.keys 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/filterameter/sort_declaration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Sort Declaration 5 | # 6 | # Class SortDeclaration captures the sort declaration within the controller. A sort declaration is also generated 7 | # from a FilterDeclaration when it is `sortable?`. 8 | class SortDeclaration 9 | attr_reader :name, :parameter_name, :association 10 | 11 | def initialize(parameter_name, options) 12 | @parameter_name = parameter_name.to_s 13 | 14 | validate_options(options) 15 | @name = options.fetch(:name, parameter_name).to_s 16 | @association = Array.wrap(options[:association]).presence 17 | end 18 | 19 | def nested? 20 | !@association.nil? 21 | end 22 | 23 | def to_s 24 | options = {} 25 | options[:name] = ":#{@name}" if @parameter_name != @name 26 | options[:association] = @association if nested? 27 | 28 | (["sort :#{@parameter_name}"] + options.map { |k, v| "#{k}: #{v}" }) 29 | .join(', ') 30 | end 31 | 32 | private 33 | 34 | def validate_options(options) 35 | options.assert_valid_keys(:name, :association) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/filterameter/sort_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | # # Sort Factory 5 | # 6 | # Class SortFactory builds a sort from a model and a declaration. 7 | class SortFactory 8 | def initialize(model_class) 9 | @model_class = model_class 10 | end 11 | 12 | def build(declaration) 13 | context = Helpers::DeclarationWithModel.new(@model_class, declaration) 14 | 15 | if declaration.nested? 16 | build_nested_sort(declaration, context) 17 | else 18 | build_sort(declaration.name, context.scope?) 19 | end 20 | end 21 | 22 | private 23 | 24 | def build_nested_sort(declaration, context) 25 | validate!(declaration, context) 26 | 27 | model = context.model_from_association 28 | sort = build_sort(declaration.name, context.scope?) 29 | nested_sort_class = context.any_collections? ? Filters::NestedCollectionFilter : Filters::NestedFilter 30 | 31 | nested_sort_class.new(declaration.association, model, sort) 32 | end 33 | 34 | def build_sort(name, declaration_is_a_scope) 35 | if declaration_is_a_scope 36 | Filterameter::Sorts::ScopeSort.new(name) 37 | else 38 | Filterameter::Sorts::AttributeSort.new(name) 39 | end 40 | end 41 | 42 | def validate!(declaration, context) 43 | return unless context.any_collections? 44 | 45 | raise Exceptions::CollectionAssociationSortError, declaration 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /lib/filterameter/sorts/attribute_sort.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Sorts 5 | # # Attribute Sort 6 | # 7 | # Class AttributeSort leverages ActiveRecord's `order` query method to add sorting for an attribute. 8 | class AttributeSort 9 | include Filterameter::Errors 10 | include Filterameter::Filters::AttributeValidator 11 | 12 | def initialize(attribute_name) 13 | @attribute_name = attribute_name 14 | end 15 | 16 | def apply(query, direction) 17 | query.order(@attribute_name => direction) 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/filterameter/sorts/scope_sort.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Sorts 5 | # # Scope Sort 6 | # 7 | # Class ScopeSort applies the scope with the a param that is either :asc or :desc. A scope that does not take 8 | # exactly one argument is not valid for sorting. 9 | class ScopeSort < Filterameter::Filters::ScopeFilter 10 | private 11 | 12 | def validate(model) 13 | validate_is_a_scope(model) 14 | rescue ArgumentError 15 | @errors << Filterameter::DeclarationErrors::SortScopeRequiresOneArgumentError.new(model.name, @scope_name) 16 | end 17 | 18 | def validate_is_a_scope(model) 19 | return if model.public_send(@scope_name, :asc).is_a? ActiveRecord::Relation 20 | 21 | @errors << Filterameter::DeclarationErrors::NotAScopeError.new(model.name, @scope_name) 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/filterameter/validators/inclusion_validator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | module Validators 5 | # # Inclusion Validator 6 | # 7 | # Class InclusionValidator extends ActiveModel::Validations::InclusionValidator to enable validations of multiple 8 | # values. 9 | # 10 | # ## Example 11 | # 12 | # validates: { inclusion: { in: %w[Small Medium Large], allow_multiple_values: true } } 13 | # 14 | class InclusionValidator < ActiveModel::Validations::InclusionValidator 15 | def validate_each(record, attribute, value) 16 | return super unless allow_multiple_values? 17 | 18 | # any? just provides a mechanism to stop after first error 19 | Array.wrap(value).any? { |v| super(record, attribute, v) } 20 | end 21 | 22 | private 23 | 24 | def allow_multiple_values? 25 | @options.fetch(:allow_multiple_values, false) 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/filterameter/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Filterameter 4 | VERSION = '1.0.2' 5 | end 6 | -------------------------------------------------------------------------------- /spec/README.md: -------------------------------------------------------------------------------- 1 | # Filterameter Tests 2 | 3 | The tests are written in RSpec and include both unit tests for the filterameter classes as well as a dummy application with models and controllers that support request specs. 4 | 5 | ## Test Application Data Model 6 | 7 | The data model is primarily projects, activities, and tasks, with a few supporting models to enable association and multi-level association tests. 8 | 9 | [![Data Model](https://yuml.me/diagram/scruffy;dir:LR/class/%5BProject%5D-%3E%5BActivity%5D,%20%5BActivity%5D-%3E%5BActivity%20Member%5D,%20%5BActivity%5D-%3E%5BTask%5D,%20%5BUser%5D-Activity%20Manager%3E%5BActivity%5D,%20%5BUser%5D-%3E%5BActivity%20Member%5D.svg)](https://yuml.me/diagram/scruffy;dir:LR/class/edit/%5BProject%5D-%3E%5BActivity%5D,%20%5BActivity%5D-%3E%5BActivity%20Member%5D,%20%5BActivity%5D-%3E%5BTask%5D,%20%5BUser%5D-Activity%20Manager%3E%5BActivity%5D,%20%5BUser%5D-%3E%5BActivity%20Member%5D) 10 | 11 | In order to support testing across Rails versions, [the schema was manually edited to remove the Rails version in the `ActiveRecord::Schema.define` statement](https://github.com/RockSolt/filterameter/commit/742cfa91c30e640bff342740fb493176d9feb44e). If additional updates are made to the schema, this manual step will need to be repeated. (Versions prior to 7.1 do not add the stamp.) 12 | 13 | ## Request Specs 14 | 15 | The request specs are broken up into the following groups: 16 | 17 | - attribute filters 18 | - attribute sorts 19 | - scope filters 20 | - partial filters 21 | - range filters 22 | - nested filters 23 | - multi-level nested filters 24 | - controller overrides 25 | - default sorts 26 | 27 | The controllers use JSON repsonses because it is easier to check JSON responses than HTML responses. 28 | -------------------------------------------------------------------------------- /spec/controllers/activities_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ActivitiesController do 6 | let(:error_messages) do 7 | <<~ERROR.chomp 8 | 9 | Invalid filter for 'inline_with_arg': 10 | #{Filterameter::DeclarationErrors::CannotBeInlineScopeError.new('Activity', :inline_with_arg)} 11 | 12 | Invalid filter for 'not_a_scope': 13 | #{Filterameter::DeclarationErrors::NotAScopeError.new('Activity', :not_a_scope)} 14 | 15 | Invalid filter for 'not_a_conditional_scope': 16 | #{Filterameter::DeclarationErrors::NotAScopeError.new('Activity', :not_a_conditional_scope)} 17 | 18 | Invalid filter for 'scope_with_multiple_args': 19 | #{Filterameter::DeclarationErrors::FilterScopeArgumentError.new('Activity', :scope_with_multiple_args)} 20 | 21 | Invalid sort for 'updated_at_typo': 22 | #{Filterameter::DeclarationErrors::NoSuchAttributeError.new('Activity', :updated_at_typo)} 23 | 24 | Invalid sort for 'sort_scope_with_no_args': 25 | #{Filterameter::DeclarationErrors::SortScopeRequiresOneArgumentError.new('Activity', :sort_scope_with_no_args)} 26 | ERROR 27 | end 28 | 29 | it 'flags invalid declarations' do 30 | declarations = described_class.declarations_validator 31 | expect(declarations).not_to be_valid 32 | end 33 | 34 | it 'provides clear explanations for invalid declarations' do 35 | declarations = described_class.declarations_validator 36 | declarations.valid? 37 | 38 | expect(declarations.errors).to eq error_messages 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /spec/controllers/projects_controller_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe ProjectsController do 6 | it 'has valid declarations' do 7 | declarations = described_class.declarations_validator 8 | expect(declarations).to be_valid 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Add your own tasks in files placed in lib/tasks ending in .rake, 4 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 5 | 6 | require_relative 'config/application' 7 | 8 | Rails.application.load_tasks 9 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/active_activities_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveActivitiesController < ApplicationController 4 | filter_model 'Activity' 5 | filter :project_id 6 | 7 | def index 8 | render json: build_query_from_filters(Activity.incomplete) 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/active_tasks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveTasksController < ApplicationController 4 | filter_model Task 5 | filters :activity_id, :completed 6 | 7 | def index 8 | @tasks = build_query_from_filters 9 | 10 | render json: @tasks 11 | end 12 | 13 | private 14 | 15 | def filter_parameters 16 | super.merge(completed: false) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/activities_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActivitiesController < ApplicationController 4 | before_action :build_filtered_query, only: :index 5 | 6 | filter :activity_manager_id 7 | filter :manager_id, name: :activity_manager_id 8 | filter :incomplete 9 | filter :in_progress, name: :incomplete 10 | filter :task_count, range: true 11 | filter :min_only_task_count, name: :task_count, range: :min_only 12 | filter :max_only_task_count, name: :task_count, range: :max_only 13 | filter :project_priority, name: :priority, association: :project 14 | filter :tasks_completed, name: :completed, association: :tasks 15 | filter :high_priority, association: :project 16 | filter :incomplete_tasks, name: :incomplete, association: :tasks 17 | 18 | sort :completed 19 | sort :project_id 20 | 21 | # invalid declarations 22 | filter :inline_with_arg 23 | filter :not_a_scope 24 | filter :not_a_conditional_scope 25 | filter :scope_with_multiple_args 26 | sort :updated_at_typo 27 | sort :sort_scope_with_no_args 28 | 29 | default_sort project_id: :desc 30 | 31 | def index 32 | render json: @activities 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::API 4 | include Filterameter::DeclarativeFilters 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/legacy_projects_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class LegacyProjectsController < ApplicationController 4 | filter_model Project 5 | filter :priority, validates: { inclusion: { in: Project.priorities.keys } } 6 | 7 | def index 8 | @projects = build_query_from_filters 9 | 10 | render json: @projects 11 | end 12 | 13 | private 14 | 15 | def filter_parameters 16 | params.to_unsafe_h.fetch(:criteria, {}) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/projects_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ProjectsController < ApplicationController 4 | filter :priority, validates: { inclusion: { in: Project.priorities.keys } } 5 | filter :activity_manager_active, name: :active, association: %i[activities activity_manager] 6 | filter :tasks_completed, name: :completed, association: %i[activities tasks] 7 | filter :with_inactive_activity_manager, name: :inactive, association: %i[activities activity_manager] 8 | filter :with_incomplete_tasks, name: :incomplete, association: %i[activities tasks] 9 | filter :in_progress 10 | 11 | sort :by_created_at 12 | sort :by_project_id 13 | 14 | def index 15 | @projects = build_query_from_filters 16 | 17 | render json: @projects 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy/app/controllers/tasks_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TasksController < ApplicationController 4 | filter :completed 5 | filter :description, partial: true 6 | filter :description_starts_with, name: :description, partial: :from_start 7 | filter :description_case_sensitive, name: :description, partial: { case_sensitive: true } 8 | filter :description_dynamic, name: :description, partial: { match: :dynamic } 9 | filter :project_priority, name: :priority, association: %i[activity project] 10 | filter :by_activity_member, name: :user_id, association: %i[activity activity_members] 11 | filter :activity_member_active, name: :active, association: %i[activity activity_members user] 12 | filter :low_priority_projects, name: :low_priority, association: %i[activity project] 13 | filter :with_inactive_activity_manager, name: :inactive, association: %i[activity activity_manager] 14 | filter :with_inactive_activity_member, name: :inactive, association: %i[activity activity_members] 15 | 16 | sorts :activity_id, :completed 17 | 18 | def index 19 | @tasks = build_query_from_filters 20 | 21 | render json: @tasks 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/dummy/app/models/activity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Activity < ApplicationRecord 4 | belongs_to :project 5 | belongs_to :activity_manager, class_name: 'User' 6 | has_many :activity_members 7 | has_many :tasks 8 | 9 | scope :incomplete, -> { where(completed: false) } 10 | 11 | # this could be handled by attribute sort, added here for testing purposes only 12 | def self.by_task_count(dir) 13 | order(task_count: dir) 14 | end 15 | 16 | # no visibility to the arg when inline, so filterameter assumes it is a conditional scope 17 | scope :inline_with_arg, ->(arg) { where(completed: arg) } 18 | 19 | # a class method that would get flagged as a scope by the factory, but does not return an arel 20 | def self.not_a_scope(arg) 21 | arg 22 | end 23 | 24 | # a class method that would get flagged as a conditional scope by the factory, but does not return an arel 25 | def self.not_a_conditional_scope 26 | 42 27 | end 28 | 29 | # filters only support single argument scopes 30 | def self.scope_with_multiple_args(completed, name) 31 | where(completed:, name:) 32 | end 33 | 34 | # sort scopes must have exactly one argement 35 | scope :inline_sort_scope_with_no_args, -> { Activity.order(:created_at) } 36 | 37 | def self.sort_scope_with_no_args 38 | Activity.order(:created_at) 39 | end 40 | 41 | def self.sort_scope_with_two_args(one, two) 42 | Activity.order(one, two) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/dummy/app/models/activity_member.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActivityMember < ApplicationRecord 4 | belongs_to :activity 5 | belongs_to :user 6 | 7 | scope :inactive, -> { joins(:user).merge(User.inactive) } 8 | end 9 | -------------------------------------------------------------------------------- /spec/dummy/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/app/models/project.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Project < ApplicationRecord 4 | has_many :activities 5 | 6 | # enum signature changed in Rails 7 7 | if method(:enum).arity == 1 8 | enum priority: %i[low medium high], _suffix: true 9 | else 10 | enum :priority, %i[low medium high], suffix: true 11 | end 12 | 13 | def self.in_progress(as_of) 14 | where(start_date: ..as_of) 15 | .where(end_date: as_of..) 16 | end 17 | 18 | def self.by_created_at(dir) 19 | order(created_at: dir) 20 | end 21 | 22 | # could be handled by an attribute sort, added for testing purposes only 23 | scope :by_project_id, ->(dir) { order(id: dir) } 24 | 25 | # multi-argument scope, not valid for filter 26 | def self.multi_arg_scope(active, priority) 27 | where(active:, priority:) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy/app/models/task.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Task < ApplicationRecord 4 | belongs_to :activity 5 | 6 | scope :incomplete, -> { where(completed: false) } 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/app/models/user.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class User < ApplicationRecord 4 | scope :inactive, -> { where(active: false) } 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | 4 | # path to your application root. 5 | APP_ROOT = File.expand_path('..', __dir__) 6 | 7 | def system!(*args) 8 | system(*args) || abort("\n== Command #{args} failed ==") 9 | end 10 | 11 | FileUtils.chdir APP_ROOT do 12 | # This script is a way to set up or update your development environment automatically. 13 | # This script is idempotent, so that you can run it at any time and get an expectable outcome. 14 | # Add necessary setup steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | # puts "\n== Copying sample files ==" 21 | # unless File.exist?('config/database.yml') 22 | # FileUtils.cp 'config/database.yml.sample', 'config/database.yml' 23 | # end 24 | 25 | puts "\n== Preparing database ==" 26 | system! 'bin/rails db:prepare' 27 | 28 | puts "\n== Removing old logs and tempfiles ==" 29 | system! 'bin/rails log:clear tmp:clear' 30 | 31 | puts "\n== Restarting application server ==" 32 | system! 'bin/rails restart' 33 | end 34 | -------------------------------------------------------------------------------- /spec/dummy/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'fileutils' 3 | include FileUtils 4 | 5 | # path to your application root. 6 | APP_ROOT = File.expand_path('..', __dir__) 7 | 8 | def system!(*args) 9 | system(*args) || abort("\n== Command #{args} failed ==") 10 | end 11 | 12 | chdir APP_ROOT do 13 | # This script is a way to update your development environment automatically. 14 | # Add necessary update steps to this file. 15 | 16 | puts '== Installing dependencies ==' 17 | system! 'gem install bundler --conservative' 18 | system('bundle check') || system!('bundle install') 19 | 20 | puts "\n== Updating database ==" 21 | system! 'bin/rails db:migrate' 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system! 'bin/rails log:clear tmp:clear' 25 | 26 | puts "\n== Restarting application server ==" 27 | system! 'bin/rails restart' 28 | end 29 | -------------------------------------------------------------------------------- /spec/dummy/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | Rails.application.load_server 7 | -------------------------------------------------------------------------------- /spec/dummy/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | # require "active_job/railtie" 7 | require "active_record/railtie" 8 | # require "active_storage/engine" 9 | require "action_controller/railtie" 10 | # require "action_mailer/railtie" 11 | # require "action_view/railtie" 12 | # require "action_cable/engine" 13 | # require "sprockets/railtie" 14 | # require "rails/test_unit/railtie" 15 | 16 | Bundler.require(*Rails.groups) 17 | 18 | module Dummy 19 | class Application < Rails::Application 20 | # Initialize configuration defaults for originally generated Rails version. 21 | config.load_defaults 5.2 22 | 23 | # Settings in config/environments/* take precedence over those specified here. 24 | # Application configuration can go into files in config/initializers 25 | # -- all .rb files in that directory are automatically loaded after loading 26 | # the framework and any gems in your application. 27 | 28 | # Only loads a smaller set of middleware suitable for API only apps. 29 | # Middleware like session, flash, cookies can be added back manually. 30 | # Skip views, helpers and assets when generating a new resource. 31 | config.api_only = true 32 | end 33 | end 34 | 35 | -------------------------------------------------------------------------------- /spec/dummy/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require "bundler/setup" # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/dummy/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | user: filterameter 5 | password: r!teoqA2bA 6 | host: localhost 7 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 8 | 9 | development: 10 | <<: *default 11 | database: filterameter 12 | 13 | test: 14 | <<: *default 15 | database: filterameter_test 16 | -------------------------------------------------------------------------------- /spec/dummy/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/integer/time' 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # In the development environment your application's code is reloaded any time 7 | # it changes. This slows down response time but is perfect for development 8 | # since you don't have to restart the web server when you make code changes. 9 | config.cache_classes = false 10 | 11 | # Do not eager load code on boot. 12 | config.eager_load = false 13 | 14 | # Show full error reports. 15 | config.consider_all_requests_local = true 16 | 17 | # Enable/disable caching. By default caching is disabled. 18 | # Run rails dev:cache to toggle caching. 19 | if Rails.root.join('tmp', 'caching-dev.txt').exist? 20 | config.cache_store = :memory_store 21 | config.public_file_server.headers = { 22 | 'Cache-Control' => "public, max-age=#{2.days.to_i}" 23 | } 24 | else 25 | config.action_controller.perform_caching = false 26 | 27 | config.cache_store = :null_store 28 | end 29 | 30 | # Print deprecation notices to the Rails logger. 31 | config.active_support.deprecation = :log 32 | 33 | # Raise exceptions for disallowed deprecations. 34 | config.active_support.disallowed_deprecation = :raise 35 | 36 | # Tell Active Support which deprecation messages to disallow. 37 | config.active_support.disallowed_deprecation_warnings = [] 38 | 39 | # Raise an error on page load if there are pending migrations. 40 | config.active_record.migration_error = :page_load 41 | 42 | # Highlight code that triggered database queries in logs. 43 | config.active_record.verbose_query_logs = true 44 | 45 | # Raises error for missing translations. 46 | # config.i18n.raise_on_missing_translations = true 47 | 48 | # Annotate rendered view with file names. 49 | # config.action_view.annotate_rendered_view_with_filenames = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | 55 | # Uncomment if you wish to allow Action Cable access from any origin. 56 | # config.action_cable.disable_request_forgery_protection = true 57 | end 58 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/integer/time' 2 | 3 | Rails.application.configure do 4 | # Settings specified here will take precedence over those in config/application.rb. 5 | 6 | # Code is not reloaded between requests. 7 | config.cache_classes = true 8 | 9 | # Eager load code on boot. This eager loads most of Rails and 10 | # your application in memory, allowing both threaded web servers 11 | # and those relying on copy on write to perform better. 12 | # Rake tasks automatically ignore this option for performance. 13 | config.eager_load = true 14 | 15 | # Full error reports are disabled and caching is turned on. 16 | config.consider_all_requests_local = false 17 | 18 | # Ensures that a master key has been made available in either ENV["RAILS_MASTER_KEY"] 19 | # or in config/master.key. This key is used to decrypt credentials (and other encrypted files). 20 | # config.require_master_key = true 21 | 22 | # Disable serving static files from the `/public` folder by default since 23 | # Apache or NGINX already handles this. 24 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 25 | 26 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 27 | # config.asset_host = 'http://assets.example.com' 28 | 29 | # Specifies the header that your server uses for sending files. 30 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 31 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 32 | 33 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 34 | # config.force_ssl = true 35 | 36 | # Include generic and useful information about system operation, but avoid logging too much 37 | # information to avoid inadvertent exposure of personally identifiable information (PII). 38 | config.log_level = :info 39 | 40 | # Prepend all log lines with the following tags. 41 | config.log_tags = [:request_id] 42 | 43 | # Use a different cache store in production. 44 | # config.cache_store = :mem_cache_store 45 | 46 | # Use a real queuing backend for Active Job (and separate queues per environment). 47 | # config.active_job.queue_adapter = :resque 48 | # config.active_job.queue_name_prefix = "dummy_production" 49 | 50 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 51 | # the I18n.default_locale when a translation cannot be found). 52 | config.i18n.fallbacks = true 53 | 54 | # Send deprecation notices to registered listeners. 55 | config.active_support.deprecation = :notify 56 | 57 | # Log disallowed deprecations. 58 | config.active_support.disallowed_deprecation = :log 59 | 60 | # Tell Active Support which deprecation messages to disallow. 61 | config.active_support.disallowed_deprecation_warnings = [] 62 | 63 | # Use default logging formatter so that PID and timestamp are not suppressed. 64 | config.log_formatter = ::Logger::Formatter.new 65 | 66 | # Use a different logger for distributed setups. 67 | # require "syslog/logger" 68 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 69 | 70 | if ENV['RAILS_LOG_TO_STDOUT'].present? 71 | logger = ActiveSupport::Logger.new(STDOUT) 72 | logger.formatter = config.log_formatter 73 | config.logger = ActiveSupport::TaggedLogging.new(logger) 74 | end 75 | 76 | # Do not dump schema after migrations. 77 | config.active_record.dump_schema_after_migration = false 78 | 79 | # Inserts middleware to perform automatic connection switching. 80 | # The `database_selector` hash is used to pass options to the DatabaseSelector 81 | # middleware. The `delay` is used to determine how long to wait after a write 82 | # to send a subsequent read to the primary. 83 | # 84 | # The `database_resolver` class is used by the middleware to determine which 85 | # database is appropriate to use based on the time delay. 86 | # 87 | # The `database_resolver_context` class is used by the middleware to set 88 | # timestamps for the last write to the primary. The resolver uses the context 89 | # class timestamps to determine how long to wait before reading from the 90 | # replica. 91 | # 92 | # By default Rails will store a last write timestamp in the session. The 93 | # DatabaseSelector middleware is designed as such you can define your own 94 | # strategy for connection switching and pass that into the middleware through 95 | # these configuration options. 96 | # config.active_record.database_selector = { delay: 2.seconds } 97 | # config.active_record.database_resolver = ActiveRecord::Middleware::DatabaseSelector::Resolver 98 | # config.active_record.database_resolver_context = ActiveRecord::Middleware::DatabaseSelector::Resolver::Session 99 | end 100 | -------------------------------------------------------------------------------- /spec/dummy/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | require 'active_support/core_ext/integer/time' 2 | 3 | # The test environment is used exclusively to run your application's 4 | # test suite. You never need to work with it otherwise. Remember that 5 | # your test database is "scratch space" for the test suite and is wiped 6 | # and recreated between test runs. Don't rely on the data there! 7 | 8 | Rails.application.configure do 9 | # Settings specified here will take precedence over those in config/application.rb. 10 | 11 | config.cache_classes = true 12 | 13 | # Do not eager load code on boot. This avoids loading your whole application 14 | # just for the purpose of running a single test. If you are using a tool that 15 | # preloads Rails for running tests, you may have to set it to true. 16 | config.eager_load = false 17 | 18 | # Configure public file server for tests with Cache-Control for performance. 19 | config.public_file_server.enabled = true 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => "public, max-age=#{1.hour.to_i}" 22 | } 23 | 24 | # Show full error reports and disable caching. 25 | config.consider_all_requests_local = true 26 | config.action_controller.perform_caching = false 27 | config.cache_store = :null_store 28 | 29 | # Raise exceptions instead of rendering exception templates. 30 | if ActiveSupport.version >= Gem::Version.new("7.1.0") 31 | config.action_dispatch.show_exceptions = :none 32 | else 33 | config.action_dispatch.show_exceptions = false 34 | end 35 | 36 | # Disable request forgery protection in test environment. 37 | config.action_controller.allow_forgery_protection = false 38 | 39 | # Print deprecation notices to the stderr. 40 | config.active_support.deprecation = :stderr 41 | 42 | # Raise exceptions for disallowed deprecations. 43 | config.active_support.disallowed_deprecation = :raise 44 | 45 | # Tell Active Support which deprecation messages to disallow. 46 | config.active_support.disallowed_deprecation_warnings = [] 47 | 48 | # Raises error for missing translations. 49 | # config.i18n.raise_on_missing_translations = true 50 | 51 | # Annotate rendered view with file names. 52 | # config.action_view.annotate_rendered_view_with_filenames = true 53 | end 54 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ActiveSupport::Reloader.to_prepare do 4 | # ApplicationController.renderer.defaults.merge!( 5 | # http_host: 'example.org', 6 | # https: false 7 | # ) 8 | # end 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| /my_noisy_library/.match?(line) } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code 7 | # by setting BACKTRACE=1 before calling your invocation, like "BACKTRACE=1 ./bin/rails runner 'MyClass.perform'". 8 | Rails.backtrace_cleaner.remove_silencers! if ENV["BACKTRACE"] 9 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/cors.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Avoid CORS issues when API is called from the frontend app. 4 | # Handle Cross-Origin Resource Sharing (CORS) in order to accept cross-origin AJAX requests. 5 | 6 | # Read more: https://github.com/cyu/rack-cors 7 | 8 | # Rails.application.config.middleware.insert_before 0, Rack::Cors do 9 | # allow do 10 | # origins 'example.com' 11 | # 12 | # resource '*', 13 | # headers: :any, 14 | # methods: [:get, :post, :put, :patch, :delete, :options, :head] 15 | # end 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [ 5 | :passw, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn 6 | ] 7 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/new_framework_defaults_6_1.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 6.1 upgrade. 4 | # 5 | # Once upgraded flip defaults one by one to migrate to the new default. 6 | # 7 | # Read the Guide for Upgrading Ruby on Rails for more info on each option. 8 | 9 | # Support for inversing belongs_to -> has_many Active Record associations. 10 | # Rails.application.config.active_record.has_many_inversing = true 11 | 12 | # Track Active Storage variants in the database. 13 | # Rails.application.config.active_storage.track_variants = true 14 | 15 | # Apply random variation to the delay when retrying failed jobs. 16 | # Rails.application.config.active_job.retry_jitter = 0.15 17 | 18 | # Stop executing `after_enqueue`/`after_perform` callbacks if 19 | # `before_enqueue`/`before_perform` respectively halts with `throw :abort`. 20 | # Rails.application.config.active_job.skip_after_callbacks_if_terminated = true 21 | 22 | # Specify cookies SameSite protection level: either :none, :lax, or :strict. 23 | # 24 | # This change is not backwards compatible with earlier Rails versions. 25 | # It's best enabled when your entire app is migrated and stable on 6.1. 26 | # Rails.application.config.action_dispatch.cookies_same_site_protection = :lax 27 | 28 | # Generate CSRF tokens that are encoded in URL-safe Base64. 29 | # 30 | # This change is not backwards compatible with earlier Rails versions. 31 | # It's best enabled when your entire app is migrated and stable on 6.1. 32 | # Rails.application.config.action_controller.urlsafe_csrf_tokens = true 33 | 34 | # Specify whether `ActiveSupport::TimeZone.utc_to_local` returns a time with an 35 | # UTC offset or a UTC time. 36 | # ActiveSupport.utc_to_local_returns_utc_offset_times = true 37 | 38 | # Change the default HTTP status code to `308` when redirecting non-GET/HEAD 39 | # requests to HTTPS in `ActionDispatch::SSL` middleware. 40 | # Rails.application.config.action_dispatch.ssl_default_redirect_status = 308 41 | 42 | # Use new connection handling API. For most applications this won't have any 43 | # effect. For applications using multiple databases, this new API provides 44 | # support for granular connection swapping. 45 | # Rails.application.config.active_record.legacy_connection_handling = false 46 | 47 | # Make `form_with` generate non-remote forms by default. 48 | # Rails.application.config.action_view.form_with_generates_remote_forms = false 49 | 50 | # Set the default queue name for the analysis job to the queue adapter default. 51 | # Rails.application.config.active_storage.queues.analysis = nil 52 | 53 | # Set the default queue name for the purge job to the queue adapter default. 54 | # Rails.application.config.active_storage.queues.purge = nil 55 | 56 | # Set the default queue name for the incineration job to the queue adapter default. 57 | # Rails.application.config.action_mailbox.queues.incineration = nil 58 | 59 | # Set the default queue name for the routing job to the queue adapter default. 60 | # Rails.application.config.action_mailbox.queues.routing = nil 61 | 62 | # Set the default queue name for the mail deliver job to the queue adapter default. 63 | # Rails.application.config.action_mailer.deliver_later_queue_name = nil 64 | 65 | # Generate a `Link` header that gives a hint to modern browsers about 66 | # preloading assets when using `javascript_include_tag` and `stylesheet_link_tag`. 67 | # Rails.application.config.action_view.preload_links_header = true 68 | -------------------------------------------------------------------------------- /spec/dummy/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /spec/dummy/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # The following keys must be escaped otherwise they will not be retrieved by 20 | # the default I18n backend: 21 | # 22 | # true, false, on, off, yes, no 23 | # 24 | # Instead, surround them with single quotes. 25 | # 26 | # en: 27 | # 'true': 'foo' 28 | # 29 | # To learn more, please read the Rails Internationalization guide 30 | # available at https://guides.rubyonrails.org/i18n.html. 31 | 32 | en: 33 | hello: "Hello world" 34 | -------------------------------------------------------------------------------- /spec/dummy/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers: a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum; this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 } 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests; default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. 30 | # 31 | # preload_app! 32 | 33 | # Allow puma to be restarted by `rails restart` command. 34 | plugin :tmp_restart 35 | -------------------------------------------------------------------------------- /spec/dummy/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | resources :tasks, only: :index 3 | resources :activities, only: :index 4 | resources :projects, only: :index 5 | 6 | resources :legacy_projects, only: :index 7 | resources :active_activities, only: :index 8 | resources :active_tasks, only: :index 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w[ 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ].each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240528221556_create_projects.rb: -------------------------------------------------------------------------------- 1 | class CreateProjects < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :projects do |t| 4 | t.string :name 5 | t.integer :priority 6 | t.datetime :start_date 7 | t.datetime :end_date 8 | 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240528223335_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :users do |t| 4 | t.string :name 5 | t.boolean :active 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240528223943_create_activities.rb: -------------------------------------------------------------------------------- 1 | class CreateActivities < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :activities do |t| 4 | t.references :project, null: false, foreign_key: true 5 | t.references :activity_manager, null: false, foreign_key: { to_table: :users } 6 | t.string :name 7 | t.integer :task_count 8 | t.boolean :completed 9 | 10 | t.timestamps 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240528224644_create_activity_members.rb: -------------------------------------------------------------------------------- 1 | class CreateActivityMembers < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :activity_members do |t| 4 | t.references :activity, null: false, foreign_key: true 5 | t.references :user, null: false, foreign_key: true 6 | 7 | t.timestamps 8 | end 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/dummy/db/migrate/20240528224846_create_tasks.rb: -------------------------------------------------------------------------------- 1 | class CreateTasks < ActiveRecord::Migration[7.1] 2 | def change 3 | create_table :tasks do |t| 4 | t.references :activity, null: false, foreign_key: true 5 | t.string :description 6 | t.boolean :completed 7 | 8 | t.timestamps 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/dummy/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # This file is the source Rails uses to define your schema when running `bin/rails 6 | # db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to 7 | # be faster and is potentially less error prone than running all of your 8 | # migrations from scratch. Old migrations may fail to apply correctly if those 9 | # migrations use external dependencies or application code. 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 2024_05_28_224846) do 14 | # These are extensions that must be enabled in order to support this database 15 | enable_extension "plpgsql" 16 | 17 | create_table "activities", force: :cascade do |t| 18 | t.bigint "project_id", null: false 19 | t.bigint "activity_manager_id", null: false 20 | t.string "name" 21 | t.integer "task_count" 22 | t.boolean "completed" 23 | t.datetime "created_at", null: false 24 | t.datetime "updated_at", null: false 25 | t.index ["activity_manager_id"], name: "index_activities_on_activity_manager_id" 26 | t.index ["project_id"], name: "index_activities_on_project_id" 27 | end 28 | 29 | create_table "activity_members", force: :cascade do |t| 30 | t.bigint "activity_id", null: false 31 | t.bigint "user_id", null: false 32 | t.datetime "created_at", null: false 33 | t.datetime "updated_at", null: false 34 | t.index ["activity_id"], name: "index_activity_members_on_activity_id" 35 | t.index ["user_id"], name: "index_activity_members_on_user_id" 36 | end 37 | 38 | create_table "projects", force: :cascade do |t| 39 | t.string "name" 40 | t.integer "priority" 41 | t.datetime "start_date" 42 | t.datetime "end_date" 43 | t.datetime "created_at", null: false 44 | t.datetime "updated_at", null: false 45 | end 46 | 47 | create_table "tasks", force: :cascade do |t| 48 | t.bigint "activity_id", null: false 49 | t.string "description" 50 | t.boolean "completed" 51 | t.datetime "created_at", null: false 52 | t.datetime "updated_at", null: false 53 | t.index ["activity_id"], name: "index_tasks_on_activity_id" 54 | end 55 | 56 | create_table "users", force: :cascade do |t| 57 | t.string "name" 58 | t.boolean "active" 59 | t.datetime "created_at", null: false 60 | t.datetime "updated_at", null: false 61 | end 62 | 63 | add_foreign_key "activities", "projects" 64 | add_foreign_key "activities", "users", column: "activity_manager_id" 65 | add_foreign_key "activity_members", "activities" 66 | add_foreign_key "activity_members", "users" 67 | add_foreign_key "tasks", "activities" 68 | end 69 | -------------------------------------------------------------------------------- /spec/dummy/docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | db: 3 | container_name: filterameter_db 4 | image: postgres:16 5 | environment: 6 | - POSTGRES_USER=filterameter 7 | - POSTGRES_DB=filterameter 8 | - POSTGRES_PASSWORD=r!teoqA2bA 9 | ports: 10 | - '5432:5432' 11 | volumes: 12 | - postgres_data:/var/lib/postgresql/data 13 | 14 | volumes: 15 | postgres_data: 16 | -------------------------------------------------------------------------------- /spec/dummy/filterameter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | -------------------------------------------------------------------------------- /spec/dummy/log/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/RockSolt/filterameter/b52eebd6d7e2d0d223524a915f2e62ac68090ce8/spec/dummy/log/.keep -------------------------------------------------------------------------------- /spec/filterameter/configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Configuration do 6 | let(:config) { described_class.new } 7 | 8 | describe 'getters and setters' do 9 | %w[action_on_undeclared_parameters action_on_validation_failure].each do |attribute| 10 | it("##{attribute}=") { expect(config).to respond_to("#{attribute}=") } 11 | it("##{attribute}") { expect(config).to respond_to(attribute) } 12 | end 13 | end 14 | 15 | context 'when development environment' do 16 | before { allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('development')) } 17 | 18 | it '#action_on_undeclared_parameters' do 19 | expect(config.action_on_undeclared_parameters).to eq :log 20 | end 21 | 22 | it '#action_on_validation_failure' do 23 | expect(config.action_on_validation_failure).to eq :log 24 | end 25 | end 26 | 27 | context 'when test environment' do 28 | it '#action_on_undeclared_parameters' do 29 | expect(config.action_on_undeclared_parameters).to eq :raise 30 | end 31 | 32 | it '#action_on_validation_failure' do 33 | expect(config.action_on_validation_failure).to eq :raise 34 | end 35 | end 36 | 37 | context 'when production environment' do 38 | before { allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new('production')) } 39 | 40 | it '#action_on_undeclared_parameters' do 41 | expect(config.action_on_undeclared_parameters).to be false 42 | end 43 | 44 | it '#action_on_validation_failure' do 45 | expect(config.action_on_validation_failure).to be false 46 | end 47 | end 48 | 49 | describe '#filter_key' do 50 | it('defaults to :filter') { expect(config.filter_key).to eq :filter } 51 | 52 | it 'can be overrriden' do 53 | config.filter_key = :criteria 54 | expect(config.filter_key).to eq :criteria 55 | end 56 | 57 | it 'can be set to false' do 58 | config.filter_key = false 59 | expect(config.filter_key).to be false 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/filterameter/declarations_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::DeclarationsValidator do 6 | let(:not_an_attribute_error) do 7 | <<~ERROR.chomp 8 | 9 | Invalid filter for 'not_an_attribute': 10 | #{Filterameter::DeclarationErrors::NoSuchAttributeError.new('Activity', :not_an_attribute)} 11 | ERROR 12 | end 13 | let(:updated_at_typo_error) do 14 | <<~ERROR.chomp 15 | 16 | Invalid sort for 'updated_at_typo': 17 | Attribute 'updated_at_typo' does not exist on Activity 18 | ERROR 19 | end 20 | let(:validator) { described_class.new('activities', Activity, registry) } 21 | 22 | specify '#inspect' do 23 | activity_validator = described_class.new('activities', Activity, nil) 24 | expect(activity_validator.inspect).to eq 'filter declarations on ActivitiesController' 25 | end 26 | 27 | context 'with valid filters and sorts' do 28 | let(:registry) do 29 | Filterameter::Registries::Registry.new(Activity).tap do |r| 30 | r.add_filter(:name, {}) 31 | r.add_filter(:task_count, {}) 32 | r.add_sort(:created_at, {}) 33 | r.add_sort(:updated_at, {}) 34 | end 35 | end 36 | 37 | it 'is valid' do 38 | validator = described_class.new('activities', Activity, registry) 39 | expect(validator).to be_valid 40 | end 41 | end 42 | 43 | context 'with invalid filter' do 44 | let(:registry) do 45 | Filterameter::Registries::Registry.new(Activity).tap do |r| 46 | r.add_filter(:name, {}) 47 | r.add_filter(:not_an_attribute, {}) 48 | r.add_sort(:created_at, {}) 49 | r.add_sort(:updated_at, {}) 50 | end 51 | end 52 | 53 | it 'is not valid' do 54 | expect(validator).not_to be_valid 55 | end 56 | 57 | it 'reports error' do 58 | validator.valid? 59 | expect(validator.errors).to eq not_an_attribute_error 60 | end 61 | end 62 | 63 | context 'with invalid sort' do 64 | let(:registry) do 65 | Filterameter::Registries::Registry.new(Activity).tap do |r| 66 | r.add_filter(:name, {}) 67 | r.add_sort(:created_at, {}) 68 | r.add_sort(:updated_at_typo, {}) 69 | end 70 | end 71 | 72 | let(:validator) { described_class.new('activities', Activity, registry) } 73 | 74 | it 'is not valid' do 75 | expect(validator).not_to be_valid 76 | end 77 | 78 | it 'reports error' do 79 | validator.valid? 80 | expect(validator.errors).to eq updated_at_typo_error 81 | end 82 | end 83 | 84 | context 'with invalid filter and invalid sort' do 85 | let(:registry) do 86 | Filterameter::Registries::Registry.new(Activity).tap do |r| 87 | r.add_filter(:name, {}) 88 | r.add_filter(:not_an_attribute, {}) 89 | r.add_sort(:created_at, {}) 90 | r.add_sort(:updated_at_typo, {}) 91 | end 92 | end 93 | 94 | let(:validator) { described_class.new('activities', Activity, registry) } 95 | 96 | it 'is not valid' do 97 | expect(validator).not_to be_valid 98 | end 99 | 100 | it 'reports error' do 101 | validator.valid? 102 | expect(validator.errors).to eq [not_an_attribute_error, updated_at_typo_error].join("\n") 103 | end 104 | end 105 | 106 | context 'with inline scope that should be class method' do 107 | let(:registry) do 108 | Filterameter::Registries::Registry.new(Activity).tap do |r| 109 | r.add_filter(:inline_with_arg, {}) 110 | end 111 | end 112 | 113 | it 'is not valid' do 114 | expect(validator).not_to be_valid 115 | end 116 | 117 | it 'reports error' do 118 | validator.valid? 119 | expect(validator.errors).to eq <<~ERROR.chomp 120 | 121 | Invalid filter for 'inline_with_arg': 122 | #{Filterameter::DeclarationErrors::CannotBeInlineScopeError.new('Activity', :inline_with_arg)} 123 | ERROR 124 | end 125 | end 126 | 127 | describe 'filter factory error handling' do 128 | let(:registry) do 129 | Filterameter::Registries::Registry.new(Activity).tap do |r| 130 | r.add_filter(:name, {}) 131 | end 132 | end 133 | 134 | before { allow(registry).to receive(:fetch_filter).and_raise(error) } 135 | 136 | context 'when error is expected' do 137 | let(:error) { Filterameter::DeclarationErrors::NoSuchAttributeError.new('Activity', :name) } 138 | 139 | it 'is not valid' do 140 | expect(validator).not_to be_valid 141 | end 142 | 143 | it 'reports error' do 144 | validator.valid? 145 | expect(validator.errors).to eq <<~ERROR.chomp 146 | 147 | Invalid filter for 'name': 148 | #{described_class::FactoryErrors.new(error)} 149 | ERROR 150 | end 151 | end 152 | 153 | context 'when error is unexpected' do 154 | let(:error) { StandardError.new('That was unexpected!') } 155 | 156 | it 'is not valid' do 157 | expect(validator).not_to be_valid 158 | end 159 | 160 | it 'reports error' do 161 | validator.valid? 162 | expect(validator.errors).to eq <<~ERROR.chomp 163 | 164 | Invalid filter for 'name': 165 | #{Filterameter::DeclarationErrors::UnexpectedError.new(error)} 166 | ERROR 167 | end 168 | end 169 | end 170 | end 171 | -------------------------------------------------------------------------------- /spec/filterameter/declarative_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::DeclarativeFilters do 6 | describe '.filter_model' do 7 | let(:model_class) { controller.filter_coordinator.instance_variable_get('@model_class') } 8 | 9 | context 'with model as string' do 10 | let(:controller) do 11 | Class.new(ApplicationController) do 12 | @controller_name = 'bars' 13 | @controller_path = 'foo/bars' 14 | include Filterameter::DeclarativeFilters 15 | 16 | filter_model 'Project' 17 | end 18 | end 19 | 20 | it 'assigns model class' do 21 | expect(model_class).to be Project 22 | end 23 | end 24 | 25 | context 'with model as class' do 26 | let(:controller) do 27 | Class.new(ApplicationController) do 28 | @controller_name = 'bars' 29 | @controller_path = 'foo/bars' 30 | include Filterameter::DeclarativeFilters 31 | 32 | filter_model Project 33 | end 34 | end 35 | 36 | it 'assigns model class' do 37 | expect(model_class).to be Project 38 | end 39 | end 40 | end 41 | 42 | describe '.filter_query_var_name' do 43 | let(:controller) do 44 | Class.new(ApplicationController) do 45 | @controller_name = 'bars' 46 | @controller_path = 'foo/bars' 47 | include Filterameter::DeclarativeFilters 48 | 49 | filter_query_var_name :price_data 50 | end 51 | end 52 | 53 | it 'assigns model class' do 54 | expect(controller.filter_coordinator.query_variable_name).to be :price_data 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/filterameter/errors_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | RSpec.describe Filterameter::Errors do 6 | context 'without a validate implementation' do 7 | let(:filter) do 8 | Class.new do 9 | include Filterameter::Errors 10 | end.new 11 | end 12 | 13 | it 'is valid' do 14 | expect(filter).to be_valid 15 | end 16 | end 17 | 18 | context 'with a validate implementation' do 19 | let(:filter) do 20 | Class.new do 21 | include Filterameter::Errors 22 | 23 | private 24 | 25 | def validate(dummy_error) 26 | return if dummy_error.nil? 27 | 28 | @errors << dummy_error 29 | end 30 | end.new 31 | end 32 | 33 | it 'is valid with no error' do 34 | expect(filter).to be_valid 35 | end 36 | 37 | context 'with an error' do 38 | it 'is not valid' do 39 | expect(filter.valid?('error message')).to be false 40 | end 41 | 42 | it 'includes error message' do 43 | filter.valid?('error message') 44 | expect(filter.errors).to contain_exactly 'error message' 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/filterameter/exceptions/cannot_determine_model_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Filterameter::Exceptions::CannotDetermineModelError do 4 | let(:error) { described_class.new('foo', 'foo/bar') } 5 | 6 | it '#message' do 7 | expect(error.message).to eq 'Cannot determine model name from controller name (foo => Foo) ' \ 8 | 'or path (foo/bar => Foo::Bar). Declare the model explicitly with filter_model.' 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /spec/filterameter/exceptions/undeclared_parameter_error_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Exceptions::UndeclaredParameterError do 6 | let(:instance) { described_class.new(:foo) } 7 | 8 | it '#message' do 9 | expect(instance.message).to eq 'The following filter parameter has not been declared: foo' 10 | end 11 | 12 | it '#key' do 13 | expect(instance.key).to eq :foo 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/filterameter/filter_coordinator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::FilterCoordinator do 6 | describe 'constructor tries to populate model class from controller name and path' do 7 | let(:controller_name) { 'my_models' } 8 | let(:controller_path) { 'foo_bar/my_models' } 9 | let(:model) { instance_double(Class) } 10 | let(:result) { described_class.new(controller_name, controller_path).send(:model_class) } 11 | 12 | context 'with model MyModel' do 13 | before { stub_const('MyModel', model) } 14 | 15 | it { expect(result).to be model } 16 | end 17 | 18 | context 'with model FooBar::MyModel' do 19 | before { stub_const('FooBar::MyModel', model) } 20 | 21 | it { expect(result).to be model } 22 | end 23 | 24 | it 'raises error with no such model' do 25 | expect { result }.to raise_exception Filterameter::Exceptions::CannotDetermineModelError 26 | end 27 | end 28 | 29 | describe '#model_class=' do 30 | let(:instance) { described_class.new(nil, nil) } 31 | let(:result) { instance.send(:model_class) } 32 | 33 | it 'handles string' do 34 | instance.model_class = described_class.name 35 | expect(result).to eq described_class 36 | end 37 | 38 | it 'handles class' do 39 | instance.model_class = described_class 40 | expect(result).to eq described_class 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/filterameter/filter_declaration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::FilterDeclaration do 6 | let(:declaration) { described_class.new(:size, options) } 7 | 8 | context 'with no options' do 9 | let(:options) { {} } 10 | 11 | it('#parameter_name') { expect(declaration.parameter_name).to eq 'size' } 12 | it('#name') { expect(declaration.name).to eq 'size' } 13 | it('#nested?') { expect(declaration.nested?).to be false } 14 | it('#validations?') { expect(declaration.validations?).to be false } 15 | it('#partial_match?') { expect(declaration.partial_search?).to be false } 16 | it('#sortable?') { expect(declaration.sortable?).to be true } 17 | it('#to_s') { expect(declaration.to_s).to eq 'filter :size' } 18 | end 19 | 20 | context 'with name specified' do 21 | let(:options) { { name: :shirt_size } } 22 | 23 | it('#parameter_name') { expect(declaration.parameter_name).to eq 'size' } 24 | it('#name') { expect(declaration.name).to eq 'shirt_size' } 25 | it('#to_s') { expect(declaration.to_s).to eq 'filter :size, name: :shirt_size' } 26 | end 27 | 28 | context 'with association' do 29 | let(:options) { { association: :one } } 30 | 31 | it('#nested?') { expect(declaration.nested?).to be true } 32 | it('is an array') { expect(declaration.association).to be_a Array } 33 | it('#to_s') { expect(declaration.to_s).to eq 'filter :size, association: [:one]' } 34 | end 35 | 36 | context 'with multi-level association' do 37 | let(:options) { { association: %i[one two] } } 38 | 39 | it('#nested?') { expect(declaration.nested?).to be true } 40 | it('is an array') { expect(declaration.association).to be_a Array } 41 | it('#to_s') { expect(declaration.to_s).to eq 'filter :size, association: [:one, :two]' } 42 | end 43 | 44 | context 'with partial' do 45 | let(:options) { { partial: true } } 46 | 47 | it('#partial_match?') { expect(declaration.partial_search?).to be true } 48 | it('#parital_options') { expect(declaration.partial_options).to be_a(Filterameter::Options::PartialOptions) } 49 | it('#to_s') { expect(declaration.to_s).to eq 'filter :size, partial: true' } 50 | end 51 | 52 | context 'with range' do 53 | let(:options) { { range: true } } 54 | 55 | it('#range_enabled?') { expect(declaration.range_enabled?).to be true } 56 | it('#range?') { expect(declaration.range?).to be true } 57 | end 58 | 59 | context 'with range: :min_only' do 60 | let(:options) { { range: :min_only } } 61 | 62 | it('#range_enabled?') { expect(declaration.range_enabled?).to be true } 63 | it('#range?') { expect(declaration.range?).to be false } 64 | it('#min_only?') { expect(declaration.min_only?).to be true } 65 | it('#minimum_range?') { expect(declaration.minimum_range?).to be false } 66 | end 67 | 68 | context 'with range: :min_only and range_type: :minimum' do 69 | let(:declaration) { described_class.new(:size, { range: :min_only }, range_type: :minimum) } 70 | 71 | it('#range_enabled?') { expect(declaration.range_enabled?).to be true } 72 | it('#range?') { expect(declaration.range?).to be false } 73 | it('#min_only?') { expect(declaration.min_only?).to be true } 74 | it('#minimum_range?') { expect(declaration.minimum_range?).to be true } 75 | end 76 | 77 | context 'with range: :max_only' do 78 | let(:options) { { range: :max_only } } 79 | 80 | it('#range_enabled?') { expect(declaration.range_enabled?).to be true } 81 | it('#range?') { expect(declaration.range?).to be false } 82 | it('#max_only?') { expect(declaration.max_only?).to be true } 83 | it('#maximum_range?') { expect(declaration.maximum_range?).to be false } 84 | end 85 | 86 | context 'with range: :max_only and range_type: :maximum' do 87 | let(:declaration) { described_class.new(:size, { range: :max_only }, range_type: :maximum) } 88 | 89 | it('#range_enabled?') { expect(declaration.range_enabled?).to be true } 90 | it('#range?') { expect(declaration.range?).to be false } 91 | it('#max_only?') { expect(declaration.max_only?).to be true } 92 | it('#maximum_range?') { expect(declaration.maximum_range?).to be true } 93 | end 94 | 95 | context 'with invalid range' do 96 | let(:options) { { range: :min } } 97 | 98 | it('raise argument error') { expect { declaration }.to raise_error(ArgumentError) } 99 | end 100 | 101 | context 'without sort' do 102 | let(:options) { { sortable: false } } 103 | 104 | it('#sortable?') { expect(declaration.sortable?).to be false } 105 | end 106 | 107 | context 'with invalid option' do 108 | let(:options) { { invalid: 12 } } 109 | 110 | it 'raises argument error' do 111 | expect { declaration }.to raise_error(ArgumentError) 112 | end 113 | end 114 | end 115 | -------------------------------------------------------------------------------- /spec/filterameter/filter_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::FilterFactory do 6 | let(:factory) { described_class.new(Project) } 7 | let(:filter) { factory.build(declaration) } 8 | 9 | context 'with attribute declaration' do 10 | let(:declaration) { Filterameter::FilterDeclaration.new(:name, {}) } 11 | 12 | it('is an AttributeFilter') { expect(filter).to be_a Filterameter::Filters::AttributeFilter } 13 | end 14 | 15 | context 'with scope declaration' do 16 | let(:declaration) { Filterameter::FilterDeclaration.new(:in_progress, {}) } 17 | 18 | it('is a ScopeFilter') { expect(filter).to be_a Filterameter::Filters::ScopeFilter } 19 | end 20 | 21 | context 'with conditional scope declaration' do 22 | let(:declaration) { Filterameter::FilterDeclaration.new(:high_priority, {}) } 23 | 24 | it('is a ConditionalScopeFilter') { expect(filter).to be_a Filterameter::Filters::ConditionalScopeFilter } 25 | end 26 | 27 | context 'with a multi-argument scope declaration' do 28 | let(:declaration) { Filterameter::FilterDeclaration.new(:multi_arg_scope, {}) } 29 | 30 | it('raises FilterScopeArgumentError') do 31 | expect { filter }.to raise_error(Filterameter::DeclarationErrors::FilterScopeArgumentError) 32 | end 33 | end 34 | 35 | context 'with partial declaration' do 36 | let(:declaration) { Filterameter::FilterDeclaration.new(:name, { partial: true }) } 37 | 38 | it('is a MatchesFilter') { expect(filter).to be_a Filterameter::Filters::MatchesFilter } 39 | end 40 | 41 | context 'with minimum declaration' do 42 | let(:declaration) { Filterameter::FilterDeclaration.new(:priority_min, { range: :min_only }, range_type: :minimum) } 43 | 44 | it('is a MinimumFilter') { expect(filter).to be_a Filterameter::Filters::MinimumFilter } 45 | end 46 | 47 | context 'with maximum declaration' do 48 | let(:declaration) { Filterameter::FilterDeclaration.new(:priority_max, { range: :max_only }, range_type: :maximum) } 49 | 50 | it('is a MaximumFilter') { expect(filter).to be_a Filterameter::Filters::MaximumFilter } 51 | end 52 | 53 | context 'with range declaration' do 54 | let(:declaration) { Filterameter::FilterDeclaration.new(:priority, { range: true }) } 55 | 56 | it('is an AttributeFilter') { expect(filter).to be_a Filterameter::Filters::AttributeFilter } 57 | end 58 | 59 | context 'with singular association declaration' do 60 | let(:factory) { described_class.new(Activity) } 61 | let(:declaration) { Filterameter::FilterDeclaration.new(:name, { association: :project }) } 62 | 63 | it('is an NestedFilter') { expect(filter).to be_a Filterameter::Filters::NestedFilter } 64 | end 65 | 66 | context 'with collection association declaration' do 67 | let(:declaration) { Filterameter::FilterDeclaration.new(:name, { association: :activities }) } 68 | 69 | it('is an NestedCollectionFilter') { expect(filter).to be_a Filterameter::Filters::NestedCollectionFilter } 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/filterameter/filters/attribute_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::AttributeFilter do 6 | it 'applies filter to query' do 7 | filter = described_class.new(:color) 8 | query = class_spy(ActiveRecord::Base) 9 | 10 | filter.apply(query, 'blue') 11 | expect(query).to have_received(:where).with(color: 'blue') 12 | end 13 | 14 | it 'is valid with valid attribute' do 15 | filter = described_class.new(:name) 16 | expect(filter.valid?(Activity)).to be true 17 | end 18 | 19 | context 'with invalid attribute' do 20 | let(:filter) { described_class.new(:not_a_thing) } 21 | 22 | it 'is not valid' do 23 | expect(filter.valid?(Activity)).to be false 24 | end 25 | 26 | it 'reports errors' do 27 | filter.valid?(Activity) 28 | expect(filter.errors) 29 | .to contain_exactly Filterameter::DeclarationErrors::NoSuchAttributeError.new('Activity', :not_a_thing) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/filterameter/filters/conditional_scope_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::ConditionalScopeFilter do 6 | let(:filter) { described_class.new(:incomplete) } 7 | let(:query) { class_spy(Activity) } 8 | 9 | it 'applies filter to query when true' do 10 | filter.apply(query, true) 11 | expect(query).to have_received(:incomplete) 12 | end 13 | 14 | it 'does not apply filter to query when false' do 15 | filter.apply(query, false) 16 | expect(query).not_to have_received(:incomplete) 17 | end 18 | 19 | context 'with valid conditional scope' do 20 | let(:filter) { described_class.new(:incomplete) } 21 | 22 | it 'is valid' do 23 | expect(filter.valid?(Activity)).to be true 24 | end 25 | end 26 | 27 | context 'with a class method that is not a scope' do 28 | let(:filter) { described_class.new(:not_a_conditional_scope) } 29 | 30 | it 'is not valid' do 31 | expect(filter.valid?(Activity)).to be false 32 | end 33 | 34 | it 'reports error' do 35 | filter.valid?(Activity) 36 | expect(filter.errors).to contain_exactly( 37 | Filterameter::DeclarationErrors::NotAScopeError.new('Activity', :not_a_conditional_scope) 38 | ) 39 | end 40 | end 41 | 42 | context 'with inline scope that takes an argument' do 43 | let(:filter) { described_class.new(:inline_with_arg) } 44 | 45 | it 'is not valid' do 46 | expect(filter.valid?(Activity)).to be false 47 | end 48 | 49 | it 'reports error' do 50 | filter.valid?(Activity) 51 | expect(filter.errors).to contain_exactly( 52 | Filterameter::DeclarationErrors::CannotBeInlineScopeError.new('Activity', :inline_with_arg) 53 | ) 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/filterameter/filters/matches_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::MatchesFilter do 6 | let(:filter) { described_class.new(:name, options) } 7 | let(:query) { filter.apply(Activity.all, 'prepare') } 8 | let(:options) { Filterameter::Options::PartialOptions.new(true) } 9 | 10 | context 'with partial: true' do 11 | it 'valid sql' do 12 | expect { query.explain }.not_to raise_exception 13 | end 14 | 15 | it 'applies criteria' do 16 | expect(query.to_sql).to match(/name.*like.*%prepare%/i) 17 | end 18 | 19 | it 'is valid' do 20 | expect(filter.valid?(Activity)).to be true 21 | end 22 | end 23 | 24 | context 'with from start' do 25 | let(:options) { Filterameter::Options::PartialOptions.new(:from_start) } 26 | 27 | it 'valid sql' do 28 | expect { query.explain }.not_to raise_exception 29 | end 30 | 31 | it 'applies criteria' do 32 | expect(query.to_sql).to match(/name.*like.*prepare%/i) 33 | .and not_match(/name.*like.*%prepare%/i) 34 | end 35 | 36 | it 'is valid' do 37 | expect(filter.valid?(Activity)).to be true 38 | end 39 | end 40 | 41 | context 'with dynamic' do 42 | let(:options) { Filterameter::Options::PartialOptions.new(:dynamic) } 43 | 44 | it 'valid sql' do 45 | expect { query.explain }.not_to raise_exception 46 | end 47 | 48 | it 'applies criteria' do 49 | expect(query.to_sql).to match(/name.*like.*prepare/i) 50 | .and not_match(/name.*like.*prepare%/i) 51 | .and not_match(/name.*like.*%prepare%/i) 52 | end 53 | 54 | it 'is valid' do 55 | expect(filter.valid?(Activity)).to be true 56 | end 57 | end 58 | 59 | context 'with typo on attribute name' do 60 | let(:filter) { described_class.new(:namez, options) } 61 | 62 | it 'is not valid' do 63 | expect(filter.valid?(Activity)).to be false 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/filterameter/filters/maximum_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::MaximumFilter do 6 | let(:filter) { described_class.new(Activity, :task_count) } 7 | let(:query) { filter.apply(Activity.all, 42) } 8 | 9 | it 'valid sql' do 10 | expect { query.explain }.not_to raise_exception 11 | end 12 | 13 | it 'applies criteria' do 14 | expect(query.to_sql).to include '"activities"."task_count" <= 42' 15 | end 16 | 17 | it 'is valid' do 18 | expect(filter.valid?(Activity)).to be true 19 | end 20 | 21 | context 'with typo on attribute name' do 22 | let(:filter) { described_class.new(Activity, :namez) } 23 | 24 | it 'is not valid' do 25 | expect(filter.valid?(Activity)).to be false 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/filterameter/filters/minimum_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::MinimumFilter do 6 | let(:filter) { described_class.new(Activity, :task_count) } 7 | let(:query) { filter.apply(Activity.all, 42) } 8 | 9 | it 'valid sql' do 10 | expect { query.explain }.not_to raise_exception 11 | end 12 | 13 | it 'applies criteria' do 14 | expect(query.to_sql).to include '"activities"."task_count" >= 42' 15 | end 16 | 17 | it 'is valid' do 18 | expect(filter.valid?(Activity)).to be true 19 | end 20 | 21 | context 'with typo on attribute name' do 22 | let(:filter) { described_class.new(Activity, :namez) } 23 | 24 | it 'is not valid' do 25 | expect(filter.valid?(Activity)).to be false 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/filterameter/filters/nested_collection_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::NestedCollectionFilter do 6 | describe 'collection associations' do 7 | let(:filter) { described_class.new([:activities], Activity, Filterameter::Filters::AttributeFilter.new(:name)) } 8 | let(:result) { filter.apply(Project.all, 'The Activity Name') } 9 | 10 | it 'includes joins' do 11 | expect(result.joins_values).to contain_exactly(:activities) 12 | end 13 | 14 | it('is distinct') { expect(result.distinct_value).to be true } 15 | 16 | it 'applies nested filter' do 17 | expect(result.where_values_hash('activities')).to match('name' => 'The Activity Name') 18 | end 19 | 20 | it 'is valid' do 21 | expect(filter.valid?(Project)).to be true 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/filterameter/filters/nested_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::NestedFilter do 6 | let(:filter) { described_class.new([:activity], Activity, Filterameter::Filters::AttributeFilter.new(:name)) } 7 | let(:query) { filter.apply(Task.all, 'The Activity Name') } 8 | 9 | it 'includes joins' do 10 | expect(query.joins_values).to contain_exactly(:activity) 11 | end 12 | 13 | it 'applies nested filter' do 14 | expect(query.where_values_hash('activities')).to match('name' => 'The Activity Name') 15 | end 16 | 17 | it 'is valid' do 18 | expect(filter.valid?(Task)).to be true 19 | end 20 | 21 | describe 'multi-level associations' do 22 | context 'with attribute filter' do 23 | let(:filter) do 24 | described_class.new(%i[activity project], Project, Filterameter::Filters::AttributeFilter.new(:priority)) 25 | end 26 | let(:query) { filter.apply(Task.all, 'high') } 27 | 28 | it 'includes builds nested hash for joins' do 29 | expect(query.joins_values).to contain_exactly({ activity: { project: {} } }) 30 | end 31 | 32 | it 'generates valid sql' do 33 | expect { query.explain }.not_to raise_exception 34 | end 35 | 36 | it 'is valid' do 37 | expect(filter.valid?(Task)).to be true 38 | end 39 | end 40 | 41 | context 'with scope filter' do 42 | let(:filter) do 43 | described_class.new(%i[activities tasks], Task, 44 | Filterameter::Filters::ConditionalScopeFilter.new(:incomplete)) 45 | end 46 | let(:query) { filter.apply(Project.all, true) } 47 | 48 | it 'includes builds nested hash for joins' do 49 | expect(query.joins_values).to contain_exactly({ activities: { tasks: {} } }) 50 | end 51 | 52 | it 'generates valid sql' do 53 | expect { query.explain }.not_to raise_exception 54 | end 55 | 56 | it 'is valid' do 57 | expect(filter.valid?(Project)).to be true 58 | end 59 | end 60 | end 61 | 62 | context 'with typo on association name' do 63 | let(:filter) { described_class.new(%i[activity], Activity, Filterameter::Filters::AttributeFilter.new(:name)) } 64 | 65 | it 'is not valid' do 66 | expect(filter.valid?(Project)).to be false 67 | end 68 | end 69 | 70 | context 'with typo on attribute name' do 71 | let(:filter) { described_class.new(%i[activities], Activity, Filterameter::Filters::AttributeFilter.new(:namez)) } 72 | 73 | it 'is not valid' do 74 | expect(filter.valid?(Project)).to be false 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /spec/filterameter/filters/scope_filter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Filters::ScopeFilter do 6 | let(:filter) { described_class.new(:in_progress) } 7 | let(:query) { class_spy(Project) } 8 | 9 | it 'applies filter to query' do 10 | filter.apply(query, true) 11 | expect(query).to have_received(:in_progress).with(true) 12 | end 13 | 14 | context 'with valid scope' do 15 | let(:filter) { described_class.new(:in_progress) } 16 | 17 | it 'is valid' do 18 | expect(filter.valid?(Project)).to be true 19 | end 20 | end 21 | 22 | context 'with a class method that is not a scope' do 23 | let(:filter) { described_class.new(:not_a_scope) } 24 | 25 | it 'is not valid' do 26 | expect(filter.valid?(Activity)).to be false 27 | end 28 | 29 | it 'reports error' do 30 | filter.valid?(Activity) 31 | expect(filter.errors).to contain_exactly( 32 | Filterameter::DeclarationErrors::NotAScopeError.new('Activity', :not_a_scope) 33 | ) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/filterameter/helpers/declaration_with_model_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Helpers::DeclarationWithModel do 6 | describe '#scope?' do 7 | let(:inspector) { described_class.new(Task, declaration) } 8 | 9 | context 'with scope declaration' do 10 | let(:declaration) { Filterameter::FilterDeclaration.new(:incomplete, {}) } 11 | 12 | it('returns true') { expect(inspector).to be_a_scope } 13 | end 14 | 15 | context 'with an attribute declaration' do 16 | let(:declaration) { Filterameter::FilterDeclaration.new(:description, {}) } 17 | 18 | it('returns false') { expect(inspector).not_to be_a_scope } 19 | end 20 | 21 | context 'with a nested scope' do 22 | let(:declaration) { Filterameter::FilterDeclaration.new(:in_progress, { association: %i[activity project] }) } 23 | 24 | it('returns true') { expect(inspector).to be_a_scope } 25 | end 26 | 27 | context 'with a nested attribute' do 28 | let(:declaration) { Filterameter::FilterDeclaration.new(:name, { association: %i[activity project] }) } 29 | 30 | it('returns true') { expect(inspector).not_to be_a_scope } 31 | end 32 | end 33 | 34 | context 'with direct collection association' do 35 | let(:inspector) do 36 | described_class.new(Project, Filterameter::FilterDeclaration.new(:name, { association: :activities })) 37 | end 38 | 39 | it('#any_collections?') { expect(inspector.any_collections?).to be true } 40 | it('#model_from_association') { expect(inspector.model_from_association).to eq Activity } 41 | end 42 | 43 | context 'with direct singular association' do 44 | let(:inspector) do 45 | described_class.new(Activity, Filterameter::FilterDeclaration.new(:name, { association: :project })) 46 | end 47 | 48 | it('#any_collections?') { expect(inspector.any_collections?).to be false } 49 | it('#model_from_association') { expect(inspector.model_from_association).to eq Project } 50 | end 51 | 52 | context 'with nested collection association' do 53 | let(:inspector) do 54 | described_class.new(Task, 55 | Filterameter::FilterDeclaration.new(:name, { association: %i[activity activity_members] })) 56 | end 57 | 58 | it('#any_collections?') { expect(inspector.any_collections?).to be true } 59 | it('#model_from_association') { expect(inspector.model_from_association).to eq ActivityMember } 60 | end 61 | 62 | context 'with nested singular associations' do 63 | let(:inspector) do 64 | described_class.new(Task, Filterameter::FilterDeclaration.new(:name, { association: %i[activity project] })) 65 | end 66 | 67 | it('#any_collections?') { expect(inspector.any_collections?).to be false } 68 | it('#model_from_association') { expect(inspector.model_from_association).to eq Project } 69 | end 70 | 71 | context 'with invalid association' do 72 | let(:inspector) do 73 | described_class.new(Task, Filterameter::FilterDeclaration.new(:name, { association: %i[invalid_association] })) 74 | end 75 | 76 | it('raises error') do 77 | expect do 78 | inspector.model_from_association 79 | end.to raise_exception(Filterameter::Exceptions::InvalidAssociationDeclarationError) 80 | end 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/filterameter/helpers/joins_values_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Helpers::JoinsValuesBuilder do 6 | it 'returns association name with single entry' do 7 | expect(described_class.build(%i[the_name])).to eq :the_name 8 | end 9 | 10 | it 'returns nested hash with two entries' do 11 | expect(described_class.build(%i[a b])).to eq({ a: { b: {} } }) 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/filterameter/helpers/requested_sort_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Helpers::RequestedSort do 6 | context 'with no sign' do 7 | let(:result) { described_class.parse('created_at') } 8 | 9 | it('#name') { expect(result.name).to eq 'created_at' } 10 | it('#direction') { expect(result.direction).to eq :asc } 11 | end 12 | 13 | context 'with plus sign' do 14 | let(:result) { described_class.parse('+created_at') } 15 | 16 | it('#name') { expect(result.name).to eq 'created_at' } 17 | it('#direction') { expect(result.direction).to eq :asc } 18 | end 19 | 20 | context 'with minus sign' do 21 | let(:result) { described_class.parse('-created_at') } 22 | 23 | it('#name') { expect(result.name).to eq 'created_at' } 24 | it('#direction') { expect(result.direction).to eq :desc } 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /spec/filterameter/options/partial_options_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Options::PartialOptions do 6 | shared_examples 'matches anywhere' do 7 | it { expect(options).to be_match_anywhere } 8 | it { expect(options).not_to be_match_from_start } 9 | it { expect(options).not_to be_match_dynamically } 10 | end 11 | 12 | shared_examples 'matches from start' do 13 | it { expect(options).not_to be_match_anywhere } 14 | it { expect(options).to be_match_from_start } 15 | it { expect(options).not_to be_match_dynamically } 16 | end 17 | 18 | shared_examples 'matches dynamically' do 19 | it { expect(options).not_to be_match_anywhere } 20 | it { expect(options).not_to be_match_from_start } 21 | it { expect(options).to be_match_dynamically } 22 | end 23 | 24 | shared_examples 'not case sensitive' do 25 | it { expect(options).not_to be_case_sensitive } 26 | end 27 | 28 | shared_examples 'case sensitive' do 29 | it { expect(options).to be_case_sensitive } 30 | end 31 | 32 | describe 'partial: true' do 33 | let(:options) { described_class.new(true) } 34 | 35 | it_behaves_like 'matches anywhere' 36 | it_behaves_like 'not case sensitive' 37 | it('#to_s') { expect(options.to_s).to eq 'true' } 38 | end 39 | 40 | describe 'partial: :anywhere' do 41 | let(:options) { described_class.new(:anywhere) } 42 | 43 | it_behaves_like 'matches anywhere' 44 | it_behaves_like 'not case sensitive' 45 | it('#to_s') { expect(options.to_s).to eq 'true' } 46 | end 47 | 48 | describe 'partial: :from_start' do 49 | let(:options) { described_class.new(:from_start) } 50 | 51 | it_behaves_like 'matches from start' 52 | it_behaves_like 'not case sensitive' 53 | it('#to_s') { expect(options.to_s).to eq ':from_start' } 54 | end 55 | 56 | describe 'partial: :dynamic' do 57 | let(:options) { described_class.new(:dynamic) } 58 | 59 | it_behaves_like 'matches dynamically' 60 | it_behaves_like 'not case sensitive' 61 | it('#to_s') { expect(options.to_s).to eq ':dynamic' } 62 | end 63 | 64 | describe "partial: 'anywhere' (as string)" do 65 | let(:options) { described_class.new('anywhere') } 66 | 67 | it_behaves_like 'matches anywhere' 68 | it_behaves_like 'not case sensitive' 69 | end 70 | 71 | describe 'partial: { match: :from_start }' do 72 | let(:options) { described_class.new(match: :from_start) } 73 | 74 | it_behaves_like 'matches from start' 75 | it_behaves_like 'not case sensitive' 76 | it('#to_s') { expect(options.to_s).to eq ':from_start' } 77 | end 78 | 79 | describe 'partial: { case_sensitive: true }' do 80 | let(:options) { described_class.new(case_sensitive: true) } 81 | 82 | it_behaves_like 'matches anywhere' 83 | it_behaves_like 'case sensitive' 84 | it('#to_s') { expect(options.to_s).to eq '{ case_sensitive: true }' } 85 | end 86 | 87 | describe 'partial: { match: :from_start, case_sensitive: true }' do 88 | let(:options) { described_class.new(match: :from_start, case_sensitive: true) } 89 | 90 | it_behaves_like 'matches from start' 91 | it_behaves_like 'case sensitive' 92 | it('#to_s') { expect(options.to_s).to eq '{ match: :from_start, case_sensitive: true }' } 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/filterameter/parameters_base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::ParametersBase do 6 | let(:parameters) do 7 | Class.new(described_class).tap do |pc| 8 | pc.add_validation(:size, [inclusion: { in: %w[Small Medium Large] }]) 9 | pc.add_validation(:percent_reduced, [{ numericality: { greater_than: 0 } }, 10 | { numericality: { less_than: 100 } }]) 11 | end.new({}) 12 | end 13 | 14 | %i[size percent_reduced].each do |attribute| 15 | it "has getter for #{attribute}" do 16 | expect(parameters).to respond_to(attribute) 17 | end 18 | 19 | it "has setter for #{attribute}" do 20 | expect(parameters).to respond_to("#{attribute}=") 21 | end 22 | end 23 | 24 | context 'with valid size' do 25 | before { parameters.size = 'Small' } 26 | 27 | it { expect(parameters).to be_valid } 28 | end 29 | 30 | context 'with valid percent_reduced' do 31 | before { parameters.percent_reduced = 20 } 32 | 33 | it { expect(parameters).to be_valid } 34 | end 35 | 36 | context 'with invalid size' do 37 | before { parameters.size = 'Extra Small' } 38 | 39 | it { expect(parameters).not_to be_valid } 40 | 41 | it 'contains error' do 42 | parameters.valid? 43 | expect(parameters.errors.full_messages).to include('Size is not included in the list') 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /spec/filterameter/query_builder_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::QueryBuilder do 6 | let(:registry) do 7 | Filterameter::Registries::Registry.new(Activity).tap do |registry| 8 | registry.add_filter(:name, {}) 9 | registry.add_filter(:completed, {}) 10 | registry.add_filter(:task_count, range: true) 11 | registry.add_sort(:created_at, {}) 12 | end 13 | end 14 | 15 | context 'with default query' do 16 | let(:default_query) { Activity.where(activity_manager_id: 123) } 17 | let(:instance) { described_class.new(default_query, {}, registry) } 18 | let(:filter_params) { { name: 'The Activity Name' }.stringify_keys } 19 | let(:query) { instance.build_query(filter_params, nil) } 20 | 21 | it 'includes default criteria' do 22 | expect(query.where_values_hash).to include('activity_manager_id' => 123, 'name' => 'The Activity Name') 23 | end 24 | end 25 | 26 | context 'with starting query' do 27 | let(:starting_query) { Activity.where(activity_manager_id: 123) } 28 | let(:instance) { described_class.new(Activity.all, {}, registry) } 29 | let(:filter_params) { { name: 'The Activity Name' }.stringify_keys } 30 | let(:query) { instance.build_query(filter_params, starting_query) } 31 | 32 | it 'includes starting criteria' do 33 | expect(query.where_values_hash).to include('activity_manager_id' => 123, 'name' => 'The Activity Name') 34 | end 35 | end 36 | 37 | context 'with requested sort' do 38 | let(:instance) { described_class.new(Activity.all, nil, registry) } 39 | let(:filter_params) { { sort: 'name' }.stringify_keys } 40 | let(:query) { instance.build_query(filter_params, nil) } 41 | 42 | it 'includes sort' do 43 | expect(query.order_values).to eq Activity.order(name: :asc).order_values 44 | end 45 | end 46 | 47 | context 'when starting query already has sort and no sort requested' do 48 | let(:instance) { described_class.new(Activity.order(created_at: :desc), { name: :asc }, registry) } 49 | let(:query) { instance.build_query({}, nil) } 50 | 51 | it 'does not add default' do 52 | expect(query.order_values.map(&:to_sql)) 53 | .not_to include(*Activity.order(name: :desc).order_values.map(&:to_sql)) 54 | end 55 | end 56 | 57 | context 'when no sort requested, and no default declared, and starting query does not have a sort' do 58 | let(:instance) { described_class.new(Activity.all, nil, registry) } 59 | let(:query) { instance.build_query({}, nil) } 60 | 61 | it 'sorts by primary key desc' do 62 | expect(query.order_values.map(&:to_sql)).to match_array(Activity.order(id: :desc).order_values.map(&:to_sql)) 63 | end 64 | end 65 | 66 | context 'with min and max criteria' do 67 | let(:default_query) { Activity.where(activity_manager_id: 123) } 68 | let(:instance) { described_class.new(default_query, {}, registry) } 69 | let(:filter_params) { { task_count_min: 12, task_count_max: 34 }.stringify_keys } 70 | let(:query) { instance.build_query(filter_params, nil) } 71 | 72 | it 'includes price range' do 73 | expect(query.to_sql).to include('"activities"."task_count" BETWEEN 12 AND 34') 74 | end 75 | end 76 | 77 | describe 'sort defaulting rules' do 78 | let(:default_sort) { nil } 79 | let(:requested_sort) { {} } 80 | let(:instance) { described_class.new(Activity.all, default_sort, registry) } 81 | let(:query) { instance.build_query(requested_sort, nil) } 82 | 83 | context 'when there is a default and a requested sort' do 84 | let(:default_sort) { [Filterameter::Helpers::RequestedSort.new(:created_at, :desc)] } 85 | let(:requested_sort) { { sort: '+name' } } 86 | 87 | it 'includes requested sort' do 88 | expect(query).to sort_by(name: :asc) 89 | end 90 | 91 | it 'does not add default' do 92 | expect(query).not_to sort_by(created_at: :desc) 93 | end 94 | end 95 | 96 | context 'when starting query includes a sort and none requested' do 97 | let(:default_sort) { [Filterameter::Helpers::RequestedSort.new(:created_at, :desc)] } 98 | let(:query) { instance.build_query(requested_sort, Activity.order(task_count: :desc)) } 99 | 100 | it 'does not add default' do 101 | expect(query).not_to sort_by(created_at: :desc) 102 | end 103 | 104 | it 'does not add primary key' do 105 | expect(query).not_to sort_by(id: :desc) 106 | end 107 | end 108 | 109 | context 'with only default sort' do 110 | let(:default_sort) { [Filterameter::Helpers::RequestedSort.new(:created_at, :desc)] } 111 | 112 | it 'adds default' do 113 | expect(query).to sort_by(created_at: :desc) 114 | end 115 | 116 | it 'does not add primary key' do 117 | expect(query).not_to sort_by(id: :desc) 118 | end 119 | end 120 | 121 | context 'without a default' do 122 | it 'uses primary key' do 123 | expect(query).to sort_by(id: :desc) 124 | end 125 | end 126 | end 127 | 128 | describe 'undeclared parameters' do 129 | let(:filter_params) { { name: 'The Activity Name', not_a_filter: 42 }.stringify_keys } 130 | let(:query) { described_class.new(Activity.all, {}, registry).build_query(filter_params, Activity.all) } 131 | 132 | before { allow(Filterameter.configuration).to receive(:action_on_undeclared_parameters).and_return(action) } 133 | 134 | context 'with no action' do 135 | let(:action) { false } 136 | 137 | it 'builds query with valid parameter' do 138 | expect(query.where_values_hash).to match('name' => 'The Activity Name') 139 | end 140 | end 141 | 142 | context 'with action log' do 143 | let(:action) { :log } 144 | let(:subscriber) { Filterameter::LogSubscriber.new } 145 | 146 | before do 147 | allow(subscriber).to receive(:undeclared_parameters) 148 | Filterameter::LogSubscriber.attach_to :filterameter, subscriber 149 | end 150 | 151 | it 'builds query with valid parameter' do 152 | expect(query.where_values_hash).to match('name' => 'The Activity Name') 153 | end 154 | 155 | it 'notifies subscriber for undeclared_parameters.filterameter' do 156 | query 157 | expect(subscriber).to have_received(:undeclared_parameters) 158 | end 159 | end 160 | 161 | context 'with action raise' do 162 | let(:action) { :raise } 163 | 164 | it 'raises exception' do 165 | expect { query }.to raise_error(Filterameter::Exceptions::UndeclaredParameterError) 166 | end 167 | end 168 | end 169 | 170 | describe 'validation failure' do 171 | let(:registry) do 172 | Filterameter::Registries::Registry.new(Project).tap do |registry| 173 | registry.add_filter(:priority, validates: { inclusion: { in: Project.priorities } }) 174 | registry.add_filter(:name, {}) 175 | end 176 | end 177 | let(:filter_params) { { name: 'The Project Name', priority: 'Very Important' }.stringify_keys } 178 | let(:query) { described_class.new(Project.all, {}, registry).build_query(filter_params, Project.all) } 179 | 180 | before { allow(Filterameter.configuration).to receive(:action_on_validation_failure).and_return(action) } 181 | 182 | context 'with no action' do 183 | let(:action) { false } 184 | 185 | it 'builds query with valid parameter' do 186 | expect(query.where_values_hash).to match('name' => 'The Project Name') 187 | end 188 | end 189 | 190 | context 'with action log' do 191 | let(:action) { :log } 192 | let(:subscriber) { Filterameter::LogSubscriber.new } 193 | 194 | before do 195 | allow(subscriber).to receive(:validation_failure) 196 | Filterameter::LogSubscriber.attach_to :filterameter, subscriber 197 | end 198 | 199 | it 'builds query with valid parameter' do 200 | expect(query.where_values_hash).to match('name' => 'The Project Name') 201 | end 202 | 203 | it 'notifies subscriber for undeclared_parameters.filterameter' do 204 | query 205 | expect(subscriber).to have_received(:validation_failure) 206 | end 207 | end 208 | 209 | context 'with action raise' do 210 | let(:action) { :raise } 211 | 212 | it 'raises exception' do 213 | expect { query }.to raise_error( 214 | Filterameter::Exceptions::ValidationError, 215 | 'The following parameter(s) failed validation: ["Priority is not included in the list"]' 216 | ) 217 | end 218 | end 219 | end 220 | end 221 | -------------------------------------------------------------------------------- /spec/filterameter/registries/filter_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Registries::FilterRegistry do 6 | describe '#add' do 7 | let(:registry) { described_class.new(Filterameter::FilterFactory.new(Project)) } 8 | let(:filter_names) { registry.filter_declarations.map(&:parameter_name) } 9 | 10 | it 'stores two declarations' do 11 | registry.add(:color, {}) 12 | registry.add(:size, {}) 13 | expect(filter_names).to contain_exactly('color', 'size') 14 | end 15 | 16 | it 'adds range filters' do 17 | registry.add(:date, range: true) 18 | expect(filter_names).to contain_exactly('date', 'date_min', 'date_max') 19 | end 20 | 21 | it 'stores ranges filters with correct name' do 22 | registry.add(:date, range: true) 23 | expect(registry.filter_declarations.map(&:name).uniq).to contain_exactly('date') 24 | end 25 | 26 | context 'with min-only range' do 27 | before { registry.add(:date, range: :min_only) } 28 | 29 | it 'adds min filters' do 30 | expect(filter_names).to contain_exactly('date', 'date_min') 31 | end 32 | 33 | it 'builds attribute filter' do 34 | expect(registry.fetch('date')).to be_a Filterameter::Filters::AttributeFilter 35 | end 36 | 37 | it 'builds minimum filter' do 38 | expect(registry.fetch('date_min')).to be_a Filterameter::Filters::MinimumFilter 39 | end 40 | end 41 | 42 | context 'with max-only range' do 43 | before { registry.add(:date, range: :max_only) } 44 | 45 | it 'adds max filters' do 46 | expect(filter_names).to contain_exactly('date', 'date_max') 47 | end 48 | 49 | it 'builds attribute filter' do 50 | expect(registry.fetch('date')).to be_a Filterameter::Filters::AttributeFilter 51 | end 52 | 53 | it 'builds maximum filter' do 54 | expect(registry.fetch('date_max')).to be_a Filterameter::Filters::MaximumFilter 55 | end 56 | end 57 | 58 | context 'with name specified for range filter' do 59 | before { registry.add(:date, name: :start_date, range: true) } 60 | 61 | it 'adds range filters' do 62 | expect(filter_names).to contain_exactly('date', 'date_min', 'date_max') 63 | end 64 | 65 | it 'stores ranges filters with correct attribute name' do 66 | expect(registry.filter_declarations.map(&:name).uniq).to contain_exactly('start_date') 67 | end 68 | end 69 | end 70 | 71 | describe '#fetch' do 72 | let(:factory) { instance_spy(Filterameter::FilterFactory) } 73 | let(:registry) { described_class.new(factory).tap { |r| r.add(:color, {}) } } 74 | 75 | describe 'builds filter from factory' do 76 | it 'works with string' do 77 | registry.fetch('color') 78 | expect(factory).to have_received(:build) 79 | end 80 | 81 | it 'works with symbol' do 82 | registry.fetch(:color) 83 | expect(factory).to have_received(:build) 84 | end 85 | end 86 | 87 | it 'raises undeclared parameter error' do 88 | expect { registry.fetch(:foo) }.to raise_exception Filterameter::Exceptions::UndeclaredParameterError, 89 | 'The following filter parameter has not been declared: foo' 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/filterameter/registries/registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Registries::Registry do 6 | let(:registry) { described_class.new(nil) } 7 | 8 | context 'when filter is sortable (default)' do 9 | before { registry.add_filter(:size, {}) } 10 | 11 | it('adds sort') { expect { registry.fetch_sort(:size) }.not_to raise_exception } 12 | end 13 | 14 | context 'when filter is not sortable' do 15 | before { registry.add_filter(:size, { sortable: false }) } 16 | 17 | it 'adds sort' do 18 | expect { registry.fetch_sort(:size) }.to raise_exception(Filterameter::Exceptions::UndeclaredParameterError) 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/filterameter/registries/sort_registry_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Registries::SortRegistry do 6 | let(:echo_factory) do 7 | Class.new do 8 | def build(name) 9 | name 10 | end 11 | end.new 12 | end 13 | 14 | let(:declaration) do 15 | registry = described_class.new(echo_factory) 16 | registry.add(:foo, {}) 17 | registry.fetch(:foo) 18 | end 19 | 20 | it('is a sort declaration') { expect(declaration).to be_a Filterameter::SortDeclaration } 21 | it('has name') { expect(declaration.name).to eq 'foo' } 22 | end 23 | -------------------------------------------------------------------------------- /spec/filterameter/sort_declaration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::SortDeclaration do 6 | let(:declaration) { described_class.new(:size, options) } 7 | 8 | context 'with no options' do 9 | let(:options) { {} } 10 | 11 | it('#parameter_name') { expect(declaration.parameter_name).to eq 'size' } 12 | it('#name') { expect(declaration.name).to eq 'size' } 13 | it('#nested?') { expect(declaration.nested?).to be false } 14 | it('#to_s') { expect(declaration.to_s).to eq 'sort :size' } 15 | end 16 | 17 | context 'with name specified' do 18 | let(:options) { { name: :shirt_size } } 19 | 20 | it('#parameter_name') { expect(declaration.parameter_name).to eq 'size' } 21 | it('#name') { expect(declaration.name).to eq 'shirt_size' } 22 | it('#to_s') { expect(declaration.to_s).to eq 'sort :size, name: :shirt_size' } 23 | end 24 | 25 | context 'with association' do 26 | let(:options) { { association: :one } } 27 | 28 | it('#nested?') { expect(declaration.nested?).to be true } 29 | it('is an array') { expect(declaration.association).to be_a Array } 30 | it('#to_s') { expect(declaration.to_s).to eq 'sort :size, association: [:one]' } 31 | end 32 | 33 | context 'with multi-level association' do 34 | let(:options) { { association: %i[one two] } } 35 | 36 | it('#nested?') { expect(declaration.nested?).to be true } 37 | it('is an array') { expect(declaration.association).to be_a Array } 38 | it('#to_s') { expect(declaration.to_s).to eq 'sort :size, association: [:one, :two]' } 39 | end 40 | 41 | context 'with invalid option' do 42 | let(:options) { { invalid: 12 } } 43 | 44 | it 'raises argument error' do 45 | expect { declaration }.to raise_error(ArgumentError) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /spec/filterameter/sort_factory_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::SortFactory do 6 | let(:factory) { described_class.new(Project) } 7 | let(:sort) { factory.build(declaration) } 8 | 9 | context 'with attribute declaration' do 10 | let(:declaration) { Filterameter::SortDeclaration.new(:name, {}) } 11 | 12 | it('is an AttributeSort') { expect(sort).to be_a Filterameter::Sorts::AttributeSort } 13 | end 14 | 15 | context 'with scope declaration' do 16 | let(:declaration) { Filterameter::SortDeclaration.new(:by_created_at, {}) } 17 | 18 | it('is a ScopeFilter') { expect(sort).to be_a Filterameter::Filters::ScopeFilter } 19 | end 20 | 21 | context 'with singular association declaration' do 22 | let(:factory) { described_class.new(Activity) } 23 | let(:declaration) { Filterameter::SortDeclaration.new(:name, { association: :project }) } 24 | 25 | it('is an NestedFilter') { expect(sort).to be_a Filterameter::Filters::NestedFilter } 26 | end 27 | 28 | context 'with collection association declaration' do 29 | let(:declaration) { Filterameter::SortDeclaration.new(:name, { association: :activities }) } 30 | 31 | it 'raises exception' do 32 | expect { sort }.to raise_exception Filterameter::Exceptions::CollectionAssociationSortError 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/filterameter/sorts/attribute_sort_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Sorts::AttributeSort do 6 | let(:sort) { described_class.new(:color) } 7 | let(:query) { class_spy(ActiveRecord::Base) } 8 | 9 | it 'applies sort to query' do 10 | sort.apply(query, :asc) 11 | expect(query).to have_received(:order).with(color: :asc) 12 | end 13 | 14 | it 'is valid with valid attribute' do 15 | sort = described_class.new(:name) 16 | expect(sort.valid?(Activity)).to be true 17 | end 18 | 19 | context 'with invalid attribute' do 20 | let(:sort) { described_class.new(:not_a_thing) } 21 | 22 | it 'is not valid' do 23 | expect(sort.valid?(Activity)).to be false 24 | end 25 | 26 | it 'reports errors' do 27 | sort.valid?(Activity) 28 | expect(sort.errors) 29 | .to contain_exactly Filterameter::DeclarationErrors::NoSuchAttributeError.new(Activity, :not_a_thing) 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/filterameter/sorts/scope_sort_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Sorts::ScopeSort do 6 | let(:sort) { described_class.new(:by_task_count) } 7 | let(:query) { class_spy(Activity) } 8 | 9 | it 'applies sort to query' do 10 | sort.apply(query, :asc) 11 | expect(query).to have_received(:by_task_count).with(:asc) 12 | end 13 | 14 | it 'is valid with valid scope' do 15 | sort = described_class.new(:by_task_count) 16 | expect(sort.valid?(Activity)).to be true 17 | end 18 | 19 | context 'with a scope that is not a sort' do 20 | let(:sort) { described_class.new(:not_a_scope) } 21 | 22 | it 'is not valid' do 23 | expect(sort.valid?(Activity)).to be false 24 | end 25 | 26 | it 'reports error' do 27 | sort.valid?(Activity) 28 | expect(sort.errors).to contain_exactly( 29 | Filterameter::DeclarationErrors::NotAScopeError.new('Activity', :not_a_scope) 30 | ) 31 | end 32 | end 33 | 34 | context 'with inline sort with no arguments' do 35 | let(:sort) { described_class.new(:inline_sort_scope_with_no_args) } 36 | 37 | it 'is not valid' do 38 | expect(sort.valid?(Activity)).to be false 39 | end 40 | 41 | it 'reports errors' do 42 | sort.valid?(Activity) 43 | expect(sort.errors).to contain_exactly( 44 | Filterameter::DeclarationErrors::SortScopeRequiresOneArgumentError.new(Activity, 45 | :inline_sort_scope_with_no_args) 46 | ) 47 | end 48 | end 49 | 50 | context 'with class method with no aguments' do 51 | let(:sort) { described_class.new(:sort_scope_with_no_args) } 52 | 53 | it 'is not valid' do 54 | expect(sort.valid?(Activity)).to be false 55 | end 56 | 57 | it 'reports errors' do 58 | sort.valid?(Activity) 59 | expect(sort.errors).to contain_exactly( 60 | Filterameter::DeclarationErrors::SortScopeRequiresOneArgumentError.new(Activity, :sort_scope_with_no_args) 61 | ) 62 | end 63 | end 64 | 65 | context 'with class method with two arguments' do 66 | let(:sort) { described_class.new(:sort_scope_with_two_args) } 67 | 68 | it 'is not valid' do 69 | expect(sort.valid?(Activity)).to be false 70 | end 71 | 72 | it 'reports errors' do 73 | sort.valid?(Activity) 74 | expect(sort.errors).to contain_exactly( 75 | Filterameter::DeclarationErrors::SortScopeRequiresOneArgumentError.new(Activity, :sort_scope_with_two_args) 76 | ) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/filterameter/validators/inclusion_validator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe Filterameter::Validators::InclusionValidator do 6 | let(:record) do 7 | Class.new do 8 | include ActiveModel::Model 9 | attr_accessor :size 10 | 11 | def self.name 12 | 'InclusionValidatorTestModel' 13 | end 14 | end.new 15 | end 16 | 17 | shared_examples 'flags attribute as invalid' do 18 | specify(:aggregate_failures) do 19 | expect(record.errors).not_to be_empty 20 | expect(record.errors).to include(:size) 21 | expect(record.errors.full_messages).to contain_exactly('Size is not included in the list') 22 | end 23 | end 24 | 25 | context 'with single values' do 26 | let(:validator) { described_class.new(attributes: %i[size], in: %w[Small Medium Large]) } 27 | 28 | it 'valid with single value' do 29 | validator.validate_each(record, :size, 'Small') 30 | expect(record.errors).to be_empty 31 | end 32 | 33 | context 'with incorrect value' do 34 | before { validator.validate_each(record, :size, 'XL') } 35 | 36 | it_behaves_like 'flags attribute as invalid' 37 | end 38 | end 39 | 40 | context 'with multiple values allowed' do 41 | let(:validator) do 42 | described_class.new(attributes: %i[size], in: %w[Small Medium Large], allow_multiple_values: true) 43 | end 44 | 45 | it 'valid with single value' do 46 | validator.validate_each(record, :size, 'Small') 47 | expect(record.errors).to be_empty 48 | end 49 | 50 | context 'with incorrect value' do 51 | before { validator.validate_each(record, :size, 'XL') } 52 | 53 | it_behaves_like 'flags attribute as invalid' 54 | end 55 | 56 | it 'valid with two values' do 57 | validator.validate_each(record, :size, %w[Small Medium]) 58 | expect(record.errors).to be_empty 59 | end 60 | 61 | context 'with two values when one is wrong' do 62 | before { validator.validate_each(record, :size, %w[Large XL]) } 63 | 64 | it_behaves_like 'flags attribute as invalid' 65 | end 66 | 67 | context 'with multiple incorrect values' do 68 | before { validator.validate_each(record, :size, %w[Large XL XXL XXXL]) } 69 | 70 | it_behaves_like 'flags attribute as invalid' 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /spec/fixtures/activities.yml: -------------------------------------------------------------------------------- 1 | make_coffee: 2 | project: start_day 3 | name: 'Make coffee' 4 | activity_manager: french_roast 5 | task_count: 4 6 | completed: true 7 | 8 | good_breakfast: 9 | project: start_day 10 | name: 'Have a good breakfast' 11 | activity_manager: pancakes_mcgee 12 | task_count: 3 13 | completed: false 14 | 15 | look_sharp: 16 | project: start_day 17 | name: 'Get dressed' 18 | activity_manager: joe_jackson 19 | task_count: 1 20 | completed: false 21 | 22 | go_for_a_run: 23 | project: exercise 24 | name: 'Go for a run' 25 | activity_manager: coach 26 | task_count: 2 27 | completed: false 28 | 29 | go_for_a_hike: 30 | project: exercise 31 | name: 'Go for a hike' 32 | activity_manager: john_muir 33 | task_count: 3 34 | completed: false 35 | 36 | read_book: 37 | project: read_a_book 38 | name: 'Read book' 39 | activity_manager: pancakes_mcgee 40 | -------------------------------------------------------------------------------- /spec/fixtures/activity_members.yml: -------------------------------------------------------------------------------- 1 | breakfast_toast: 2 | activity: good_breakfast 3 | user: toast_guy 4 | 5 | breakfast_juice: 6 | activity: good_breakfast 7 | user: juice_guy 8 | 9 | breakfast_chef: 10 | activity: good_breakfast 11 | user: egg_chef 12 | 13 | 14 | exercise_steph: 15 | activity: lace_up_running_shoes 16 | user: step 17 | 18 | exercise_klay: 19 | activity: lace_up_running_shoes 20 | user: klay 21 | -------------------------------------------------------------------------------- /spec/fixtures/projects.yml: -------------------------------------------------------------------------------- 1 | start_day: 2 | name: 'Start day on the right foot' 3 | priority: 'high' 4 | start_date: <%= Time.zone.today %> 5 | end_date: <%= 1.week.from_now %> 6 | 7 | exercise: 8 | name: 'Get some exercise' 9 | priority: 'low' 10 | start_date: <%= 1.month.from_now %> 11 | end_date: <%= 1.year.from_now %> 12 | 13 | read_a_book: 14 | name: 'Read a book' 15 | priority: 'medium' 16 | start_date: <%= 1.month.ago %> 17 | end_date: <%= 1.week.ago %> 18 | -------------------------------------------------------------------------------- /spec/fixtures/tasks.yml: -------------------------------------------------------------------------------- 1 | boil_water: 2 | activity: make_coffee 3 | description: 'Boil water' 4 | completed: true 5 | grind_coffee_beans: 6 | activity: make_coffee 7 | description: 'Grind coffee beans' 8 | completed: true 9 | french_press: 10 | activity: make_coffee 11 | description: 'Add beans and water to french press' 12 | completed: true 13 | pour_coffee: 14 | activity: make_coffee 15 | description: 'Push the plunger, pour the coffee' 16 | completed: true 17 | 18 | pour_juice: 19 | activity: good_breakfast 20 | description: 'Pour freshly squeezed OJ' 21 | completed: true 22 | make_toast: 23 | activity: good_breakfast 24 | description: 'Make toast' 25 | completed: false 26 | scramble_eggs: 27 | activity: good_breakfast 28 | description: 'Scramble eggs' 29 | completed: false 30 | 31 | change_out_of_pajamas: 32 | activity: look_sharp 33 | description: 'Take off PJs, put on clothes' 34 | completed: false 35 | 36 | 37 | lace_up_running_shoes: 38 | activity: go_for_a_run 39 | description: 'Lace up those running shoes' 40 | completed: true 41 | 42 | out_the_door: 43 | activity: go_for_a_run 44 | description: 'Start running' 45 | completed: false 46 | 47 | 48 | put_on_boots: 49 | activity: go_for_a_hike 50 | description: 'Put on your boots' 51 | completed: false 52 | 53 | wear_sunscreen: 54 | activity: go_for_a_hike 55 | description: 'Apply sunscreen' 56 | completed: false 57 | 58 | carry_water: 59 | activity: go_for_a_hike 60 | description: 'Always carry water' 61 | completed: false 62 | 63 | 64 | pick_book: 65 | activity: read_book 66 | description: 'Pick a good book' 67 | completed: true 68 | 69 | read_book: 70 | activity: read_book 71 | description: 'Read the book' 72 | completed: true -------------------------------------------------------------------------------- /spec/fixtures/users.yml: -------------------------------------------------------------------------------- 1 | french_roast: 2 | name: French Roast 3 | active: true 4 | 5 | pancakes_mcgee: 6 | name: Pancakes McGee 7 | active: true 8 | 9 | toast_guy: 10 | name: Toasty Toasterson 11 | active: false 12 | 13 | juice_guy: 14 | name: Orange Julius 15 | active: true 16 | 17 | egg_chef: 18 | name: Scrambles McGee 19 | active: true 20 | 21 | joe_jackson: 22 | name: Joe Jackson 23 | active: true 24 | 25 | coach: 26 | name: Coach M. Up 27 | active: true 28 | 29 | steph: 30 | name: Stephen Curry 31 | active: true 32 | 33 | klay: 34 | name: Game Six 35 | active: true 36 | 37 | john_muir: 38 | name: John Muir 39 | active: false 40 | -------------------------------------------------------------------------------- /spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | ENV['RAILS_ENV'] ||= 'test' 5 | 6 | require File.expand_path('dummy/config/environment', __dir__) 7 | # Prevent database truncation if the environment is production 8 | abort('The Rails environment is running in production mode!') if Rails.env.production? 9 | require 'rspec/rails' 10 | 11 | begin 12 | ActiveRecord::Migration.maintain_test_schema! 13 | rescue ActiveRecord::PendingMigrationError => e 14 | puts e.to_s.strip 15 | exit 1 16 | end 17 | 18 | RSpec.configure do |config| 19 | config.fixture_path = 'spec/fixtures' 20 | config.infer_spec_type_from_file_location! 21 | config.filter_rails_from_backtrace! 22 | end 23 | -------------------------------------------------------------------------------- /spec/requests/attribute_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Attribute filters' do 6 | fixtures :activities, :users, :tasks 7 | 8 | context 'with no options' do 9 | before { get '/activities', params: { filter: { activity_manager_id: users(:joe_jackson).id } } } 10 | 11 | it 'returns the correct number of rows' do 12 | count = Activity.where(activity_manager_id: users(:joe_jackson).id).count 13 | expect(response.parsed_body.size).to eq count 14 | end 15 | 16 | it 'returns Get dressed' do 17 | expect(response).to have_http_status(:success) 18 | expect(response.parsed_body).to include_a_record_with('name' => activities(:look_sharp).name) 19 | end 20 | end 21 | 22 | context 'with name specified' do 23 | before { get '/activities', params: { filter: { manager_id: users(:joe_jackson).id } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Activity.where(activity_manager_id: users(:joe_jackson).id).count 27 | expect(response.parsed_body.size).to eq count 28 | end 29 | 30 | it 'returns Get dressed' do 31 | expect(response).to have_http_status(:success) 32 | expect(response.parsed_body).to include_a_record_with('name' => activities(:look_sharp).name) 33 | end 34 | 35 | it 'does not return Make coffee' do 36 | expect(response).to have_http_status(:success) 37 | expect(response.parsed_body).not_to include_a_record_with('name' => activities(:make_coffee).name) 38 | end 39 | end 40 | 41 | context 'with validations' do 42 | fixtures :projects 43 | before { get '/projects', params: { filter: { priority: 'high' } } } 44 | 45 | it 'returns the correct number of rows' do 46 | count = Project.high_priority.count 47 | expect(response.parsed_body.size).to eq count 48 | end 49 | 50 | it 'returns Start day on the right foot' do 51 | expect(response).to have_http_status(:success) 52 | expect(response.parsed_body).to include_a_record_with('name' => projects(:start_day).name) 53 | end 54 | end 55 | 56 | context 'with validations and invalid value' do 57 | fixtures :projects 58 | 59 | it 'raises validation error' do 60 | expect { get '/projects', params: { filter: { priority: 'top' } } } 61 | .to raise_exception(Filterameter::Exceptions::ValidationError) 62 | end 63 | end 64 | 65 | context 'with empty filters' do 66 | before { get '/activities', params: { filter: { manager_id: users(:joe_jackson).id, activity_manager_id: '' } } } 67 | 68 | it 'returns the correct number of rows' do 69 | count = Activity.where(activity_manager_id: users(:joe_jackson).id).count 70 | expect(response.parsed_body.size).to eq count 71 | end 72 | end 73 | 74 | context 'with boolean filter set to true' do 75 | before { get '/tasks', params: { filter: { completed: true } } } 76 | 77 | it 'returns the correct number of rows' do 78 | count = Task.where(completed: true).count 79 | expect(response.parsed_body.size).to eq count 80 | end 81 | 82 | it 'returns Grind coffee beans' do 83 | expect(response).to have_http_status(:success) 84 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:grind_coffee_beans).description) 85 | end 86 | 87 | it 'does not return Make toast' do 88 | expect(response).to have_http_status(:success) 89 | expect(response.parsed_body).not_to include_a_record_with('description' => tasks(:make_toast).description) 90 | end 91 | end 92 | 93 | context 'with boolean filter set to false' do 94 | before { get '/tasks', params: { filter: { completed: false } } } 95 | 96 | it 'returns the correct number of rows' do 97 | count = Task.where(completed: false).count 98 | expect(response.parsed_body.size).to eq count 99 | end 100 | 101 | it 'does not return Grind coffee beans' do 102 | expect(response).to have_http_status(:success) 103 | expect(response.parsed_body).not_to include_a_record_with('description' => tasks(:grind_coffee_beans).description) 104 | end 105 | 106 | it 'returns Make toast' do 107 | expect(response).to have_http_status(:success) 108 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:make_toast).description) 109 | end 110 | end 111 | end 112 | -------------------------------------------------------------------------------- /spec/requests/attribute_sorts_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Attribute sorts' do 6 | fixtures :projects, :activities, :users, :tasks 7 | 8 | context 'with filter declaration' do 9 | before { get '/activities', params: { filter: { sort: :activity_manager_id } } } 10 | 11 | it 'sorts' do 12 | expect(response).to have_http_status(:success) 13 | ordered = response.parsed_body.pluck('activity_manager_id') 14 | expect(ordered).to eq Activity.order(activity_manager_id: :asc).pluck(:activity_manager_id) 15 | end 16 | end 17 | 18 | context 'when plus sign specified' do 19 | before { get '/activities', params: { filter: { sort: '+activity_manager_id' } } } 20 | 21 | it 'sorts ascending' do 22 | expect(response).to have_http_status(:success) 23 | ordered = response.parsed_body.pluck('activity_manager_id') 24 | expect(ordered).to eq Activity.order(activity_manager_id: :asc).pluck(:activity_manager_id) 25 | end 26 | end 27 | 28 | context 'when minus sign specified' do 29 | before { get '/activities', params: { filter: { sort: '-activity_manager_id' } } } 30 | 31 | it 'sorts descending' do 32 | expect(response).to have_http_status(:success) 33 | ordered = response.parsed_body.pluck('activity_manager_id') 34 | expect(ordered).to eq Activity.order(activity_manager_id: :desc).pluck(:activity_manager_id) 35 | end 36 | end 37 | 38 | context 'with name specified' do 39 | before { get '/activities', params: { filter: { sort: :manager_id } } } 40 | 41 | it 'sorts correctly' do 42 | expect(response).to have_http_status(:success) 43 | ordered = response.parsed_body.pluck('activity_manager_id') 44 | expect(ordered).to eq Activity.order(activity_manager_id: :asc).pluck(:activity_manager_id) 45 | end 46 | end 47 | 48 | context 'when declared with `sort`' do 49 | before { get '/activities', params: { filter: { sort: :completed } } } 50 | 51 | it 'sorts' do 52 | expect(response).to have_http_status(:success) 53 | ordered = response.parsed_body.pluck('completed') 54 | expect(ordered).to eq Activity.order(completed: :asc).pluck(:completed) 55 | end 56 | end 57 | 58 | context 'when declared with `sorts`' do 59 | before { get '/tasks', params: { filter: { sort: :activity_id } } } 60 | 61 | it 'sorts' do 62 | expect(response).to have_http_status(:success) 63 | ordered = response.parsed_body.pluck('activity_id') 64 | expect(ordered).to eq Task.order(activity_id: :asc).pluck(:activity_id) 65 | end 66 | end 67 | 68 | context 'with an array of sorts' do 69 | before { get '/activities', params: { filter: { sort: %i[completed -project_id] } } } 70 | 71 | it 'sorts' do 72 | expect(response).to have_http_status(:success) 73 | ordered = response.parsed_body.pluck('id') 74 | expect(ordered).to eq Activity.order(completed: :asc, project_id: :desc).pluck(:id) 75 | end 76 | end 77 | 78 | context 'with singular association' do 79 | before { get '/activities', params: { filter: { sort: :project_priority } } } 80 | 81 | it 'sorts' do 82 | expect(response).to have_http_status(:success) 83 | ordered = response.parsed_body.pluck('id') 84 | expect(ordered).to eq Activity.joins(:project).merge(Project.order(priority: :asc)).pluck(:id) 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /spec/requests/controller_overrides_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Controller overrides' do 6 | fixtures :projects, :activities 7 | 8 | context 'with nested key overridden' do 9 | before { get '/legacy_projects', params: { criteria: { priority: 'high' } } } 10 | 11 | it 'returns the correct number of rows' do 12 | count = Project.high_priority.count 13 | expect(response.parsed_body.size).to eq count 14 | end 15 | 16 | it 'high priority projects include Start day on the right foot' do 17 | expect(response).to have_http_status(:success) 18 | expect(response.parsed_body).to include_a_record_with('name' => projects(:start_day).name) 19 | end 20 | end 21 | 22 | context 'with starting query' do 23 | before { get '/active_activities', params: { filter: { project_id: projects(:start_day).id } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Activity.where(completed: false, project_id: projects(:start_day).id).count 27 | expect(response.parsed_body.size).to eq count 28 | end 29 | 30 | it 'active activities on Start Day project include Good Breakfast' do 31 | expect(response).to have_http_status(:success) 32 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 33 | end 34 | end 35 | 36 | context 'with filter_parameters overridden' do 37 | fixtures :tasks 38 | 39 | before { get '/active_tasks', params: { filter: { activity_id: activities(:good_breakfast).id } } } 40 | 41 | it 'returns the correct number of rows' do 42 | count = Task.where(completed: false, activity_id: activities(:good_breakfast).id).count 43 | expect(response.parsed_body.size).to eq count 44 | end 45 | 46 | it 'active tasks on Good Breakfast include Make Toast' do 47 | expect(response).to have_http_status(:success) 48 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:make_toast).description) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /spec/requests/default_sorts_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Default sorts' do 6 | fixtures :projects, :activities 7 | 8 | context 'with no default specified' do 9 | before { get '/projects' } 10 | 11 | it 'sorts by primary key desc' do 12 | ordered = response.parsed_body.pluck('id') 13 | expect(ordered).to eq Project.order(id: :desc).pluck(:id) 14 | end 15 | end 16 | 17 | context 'with default specified' do 18 | before { get '/activities' } 19 | 20 | it 'sorts by default' do 21 | ordered = response.parsed_body.pluck('project_id') 22 | expect(ordered).to eq Activity.order(project_id: :desc).pluck(:project_id) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/requests/filter_key_configuration_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Filter Key configuration' do 6 | fixtures :activities 7 | 8 | before { allow(Filterameter).to receive(:configuration).and_return(custom_config) } 9 | 10 | context 'with key overriden to f' do 11 | let(:custom_config) { Filterameter::Configuration.new.tap { |c| c.filter_key = :f } } 12 | 13 | it 'applies filter' do 14 | get '/activities', params: { f: { incomplete: true } } 15 | count = Activity.incomplete.count 16 | expect(response.parsed_body.size).to eq count 17 | 18 | names = Activity.incomplete.pluck(:name) 19 | expect(response.parsed_body.pluck('name')).to match_array(names) 20 | end 21 | 22 | it 'applies sort' do 23 | get '/activities', params: { f: { sort: :project_id } } 24 | 25 | ordered = Activity.order(:project_id).pluck(:project_id) 26 | expect(response.parsed_body.pluck('project_id')).to eq ordered 27 | end 28 | 29 | it 'fails on undeclared params (default for test env)' do 30 | expect { get '/activities', params: { f: { not_a_filter: true } } } 31 | .to raise_error(Filterameter::Exceptions::UndeclaredParameterError) 32 | end 33 | end 34 | 35 | context 'without nesting' do 36 | let(:custom_config) { Filterameter::Configuration.new.tap { |c| c.filter_key = false } } 37 | 38 | it 'applies filter' do 39 | get '/activities', params: { incomplete: true } 40 | count = Activity.incomplete.count 41 | expect(response.parsed_body.size).to eq count 42 | 43 | names = Activity.incomplete.pluck(:name) 44 | expect(response.parsed_body.pluck('name')).to match_array(names) 45 | end 46 | 47 | it 'applies sort' do 48 | get '/activities', params: { sort: :project_id } 49 | 50 | ordered = Activity.order(:project_id).pluck(:project_id) 51 | expect(response.parsed_body.pluck('project_id')).to eq ordered 52 | end 53 | 54 | it 'ignores undeclared params' do 55 | get '/activities', params: { not_a_filter: true } 56 | expect(response).to be_successful 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/requests/multi_level_nested_attribute_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Multi-level nested attribute filters' do 6 | fixtures :projects, :activities, :tasks, :users, :activity_members 7 | 8 | context 'with singular-to-singular association' do 9 | before { get '/tasks', params: { filter: { project_priority: 'high' } } } 10 | 11 | it 'returns the correct number of rows' do 12 | count = Task.joins(activity: :project).merge(Project.where(priority: 'high')).count 13 | expect(response.parsed_body.size).to eq count 14 | end 15 | 16 | it 'tasks on high priority projects include Pour coffee' do 17 | expect(response).to have_http_status(:success) 18 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:pour_coffee).description) 19 | end 20 | end 21 | 22 | context 'with singular-to-collection association' do 23 | before { get '/tasks', params: { filter: { by_activity_member: users(:egg_chef).id } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Task.joins(activity: :activity_members) 27 | .merge(ActivityMember.where(user_id: users(:egg_chef).id)) 28 | .distinct.count 29 | expect(response.parsed_body.size).to eq count 30 | end 31 | 32 | it 'tasks on activities with Scrambles McGee include Make toast' do 33 | expect(response).to have_http_status(:success) 34 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:make_toast).description) 35 | end 36 | end 37 | 38 | context 'with collection-to-singular association' do 39 | before { get '/projects', params: { filter: { activity_manager_active: false } } } 40 | 41 | it 'returns the correct number of rows' do 42 | count = Project.joins(activities: :activity_manager).merge(User.where(active: false)).distinct.count 43 | expect(response.parsed_body.size).to eq count 44 | end 45 | 46 | it 'projects with an inactive activity manager include Get some exercise' do 47 | expect(response).to have_http_status(:success) 48 | expect(response.parsed_body).to include_a_record_with('name' => projects(:exercise).name) 49 | end 50 | end 51 | 52 | context 'with collection-to-collection association' do 53 | before { get '/projects', params: { filter: { tasks_completed: false } } } 54 | 55 | it 'returns the correct number of rows' do 56 | count = Project.joins(activities: :tasks).merge(Task.where(completed: false)).distinct.count 57 | expect(response.parsed_body.size).to eq count 58 | end 59 | 60 | it 'projects with incomplete tasks do not include Read a book' do 61 | expect(response).to have_http_status(:success) 62 | expect(response.parsed_body).not_to include_a_record_with('name' => projects(:read_a_book).name) 63 | end 64 | end 65 | 66 | context 'with singular-to-collection-to-singular' do 67 | before { get '/tasks', params: { filter: { activity_member_active: false } } } 68 | 69 | it 'returns the correct number of rows' do 70 | count = Task.joins(activity: { activity_members: :user }).merge(User.where(active: false)).distinct.count 71 | expect(response.parsed_body.size).to eq count 72 | end 73 | 74 | it 'tasks with inactive activity members include Make toast' do 75 | expect(response).to have_http_status(:success) 76 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:make_toast).description) 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /spec/requests/multi_level_nested_scope_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Nested scope filters' do 6 | fixtures :projects, :activities, :tasks, :users, :activity_members 7 | 8 | context 'with singular-to-singular association' do 9 | before { get '/tasks', params: { filter: { low_priority_projects: 'low' } } } 10 | 11 | it 'returns the correct number of rows' do 12 | count = Task.joins(activity: :project).merge(Project.where(priority: 'low')).count 13 | expect(response.parsed_body.size).to eq count 14 | end 15 | 16 | it 'tasks on low priority projects include Apply sunscreen' do 17 | expect(response).to have_http_status(:success) 18 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:wear_sunscreen).description) 19 | end 20 | end 21 | 22 | context 'with singular-to-collection association' do 23 | before { get '/tasks', params: { filter: { with_inactive_activity_member: true } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Task.joins(activity: :activity_members) 27 | .merge(ActivityMember.inactive) 28 | .distinct.count 29 | expect(response.parsed_body.size).to eq count 30 | end 31 | 32 | it 'tasks on activities with an inactive member include Make toast' do 33 | expect(response).to have_http_status(:success) 34 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:make_toast).description) 35 | end 36 | end 37 | 38 | context 'with collection-to-singular association' do 39 | before { get '/projects', params: { filter: { with_inactive_activity_manager: true } } } 40 | 41 | it 'returns the correct number of rows' do 42 | count = Project.joins(activities: :activity_manager).merge(User.where(active: false)).distinct.count 43 | expect(response.parsed_body.size).to eq count 44 | end 45 | 46 | it 'projects with an inactive activity manager include Get some exercise' do 47 | expect(response).to have_http_status(:success) 48 | expect(response.parsed_body).to include_a_record_with('name' => projects(:exercise).name) 49 | end 50 | end 51 | 52 | context 'with collection-to-collection association' do 53 | before { get '/projects', params: { filter: { with_incomplete_tasks: true } } } 54 | 55 | it 'returns the correct number of rows' do 56 | count = Project.joins(activities: :tasks).merge(Task.where(completed: false)).distinct.count 57 | expect(response.parsed_body.size).to eq count 58 | end 59 | 60 | it 'projects with incomplete tasks do not include Read a book' do 61 | expect(response).to have_http_status(:success) 62 | expect(response.parsed_body).not_to include_a_record_with('name' => projects(:read_a_book).name) 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/requests/nested_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Nested filters' do 6 | describe 'attribute filters' do 7 | fixtures :projects, :activities, :tasks 8 | 9 | context 'with singular association' do 10 | before { get '/activities', params: { filter: { project_priority: 'high' } } } 11 | 12 | it 'returns the correct number of rows' do 13 | count = Activity.joins(:project).merge(Project.where(priority: 'high')).count 14 | expect(response.parsed_body.size).to eq count 15 | end 16 | 17 | it 'returns Make coffee' do 18 | expect(response).to have_http_status(:success) 19 | expect(response.parsed_body).to include_a_record_with('name' => activities(:make_coffee).name) 20 | end 21 | end 22 | 23 | context 'with collection association' do 24 | before { get '/activities', params: { filter: { tasks_completed: true } } } 25 | 26 | it 'returns the correct number of rows' do 27 | count = Activity.joins(:tasks).merge(Task.where(completed: true)).distinct.count 28 | expect(response.parsed_body.size).to eq count 29 | end 30 | 31 | it 'returns Make coffee' do 32 | expect(response).to have_http_status(:success) 33 | expect(response.parsed_body).to include_a_record_with('name' => activities(:make_coffee).name) 34 | end 35 | end 36 | end 37 | 38 | describe 'scope filters' do 39 | fixtures :projects, :activities, :tasks 40 | 41 | context 'with singular association' do 42 | before { get '/activities', params: { filter: { high_priority: 'true' } } } 43 | 44 | it 'returns the correct number of rows' do 45 | count = Activity.joins(:project).merge(Project.where(priority: 'high')).count 46 | expect(response.parsed_body.size).to eq count 47 | end 48 | 49 | it 'returns Make coffee' do 50 | expect(response).to have_http_status(:success) 51 | expect(response.parsed_body).to include_a_record_with('name' => activities(:make_coffee).name) 52 | end 53 | end 54 | 55 | context 'with conditional scope that is false' do 56 | before { get '/activities', params: { filter: { high_priority: 'false' } } } 57 | 58 | it 'does not apply the scope' do 59 | count = Activity.count 60 | expect(response.parsed_body.size).to eq count 61 | end 62 | end 63 | 64 | context 'with collection association' do 65 | before { get '/activities', params: { filter: { incomplete_tasks: true } } } 66 | 67 | it 'returns the correct number of rows' do 68 | count = Activity.joins(:tasks).merge(Task.where(completed: false)).distinct.count 69 | expect(response.parsed_body.size).to eq count 70 | end 71 | 72 | it 'returns Have a good breakfast' do 73 | expect(response).to have_http_status(:success) 74 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /spec/requests/partial_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Partial filters' do 6 | fixtures :tasks 7 | 8 | context 'with defaults' do 9 | before { get '/tasks', params: { filter: { description: 'beans' } } } 10 | 11 | it 'returns the correct number of rows' do 12 | count = Task.where("description like '%beans%'").count 13 | expect(response.parsed_body.size).to eq count 14 | end 15 | 16 | it 'returns Grind coffee beans' do 17 | expect(response).to have_http_status(:success) 18 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:grind_coffee_beans).description) 19 | end 20 | end 21 | 22 | context 'with from start option' do 23 | before { get '/tasks', params: { filter: { description_starts_with: 'Add beans' } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Task.where("description like 'Add beans%'").count 27 | expect(response.parsed_body.size).to eq count 28 | end 29 | 30 | it 'returns Add beans and water to french press' do 31 | expect(response).to have_http_status(:success) 32 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:french_press).description) 33 | end 34 | end 35 | 36 | context 'with case sensitive option' do 37 | before { get '/tasks', params: { filter: { description_case_sensitive: 'Add beans' } } } 38 | 39 | it 'returns the correct number of rows' do 40 | count = Task.where("description ilike 'Add beans%'").count 41 | expect(response.parsed_body.size).to eq count 42 | end 43 | 44 | it 'returns Add beans and water to french press' do 45 | expect(response).to have_http_status(:success) 46 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:french_press).description) 47 | end 48 | end 49 | 50 | context 'with case sensitive option and no match' do 51 | before { get '/tasks', params: { filter: { description_case_sensitive: 'add beans' } } } 52 | 53 | it 'returns the no rows' do 54 | expect(response.parsed_body.size).to be_zero 55 | end 56 | end 57 | 58 | context 'with dynamic option' do 59 | before { get '/tasks', params: { filter: { description_dynamic: '%beans%' } } } 60 | 61 | it 'returns the correct number of rows' do 62 | count = Task.where("description like '%beans%'").count 63 | expect(response.parsed_body.size).to eq count 64 | end 65 | 66 | it 'returns Grind coffee beans' do 67 | expect(response).to have_http_status(:success) 68 | expect(response.parsed_body).to include_a_record_with('description' => tasks(:grind_coffee_beans).description) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/requests/range_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Range filters' do 6 | fixtures :activities 7 | 8 | context 'when range: true' do 9 | context 'with exact value' do 10 | before { get '/activities', params: { filter: { task_count: 3 } } } 11 | 12 | it 'returns the correct number of rows' do 13 | count = Activity.where(task_count: 3).count 14 | expect(response.parsed_body.size).to eq count 15 | end 16 | 17 | it 'returns Have a good breakfast' do 18 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 19 | end 20 | end 21 | 22 | context 'with minimum value' do 23 | before { get '/activities', params: { filter: { task_count_min: 3 } } } 24 | 25 | it 'returns the correct number of rows' do 26 | count = Activity.where('task_count >= 3').count 27 | expect(response.parsed_body.size).to eq count 28 | end 29 | 30 | it 'returns Have a good breakfast' do 31 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 32 | end 33 | end 34 | 35 | context 'with maximum value' do 36 | before { get '/activities', params: { filter: { task_count_max: 3 } } } 37 | 38 | it 'returns the correct number of rows' do 39 | count = Activity.where('task_count <= 3').count 40 | expect(response.parsed_body.size).to eq count 41 | end 42 | 43 | it 'returns Get dressed' do 44 | expect(response.parsed_body).to include_a_record_with('name' => activities(:look_sharp).name) 45 | end 46 | end 47 | end 48 | 49 | context 'when range: :min_only' do 50 | context 'with exact value' do 51 | before { get '/activities', params: { filter: { min_only_task_count: 3 } } } 52 | 53 | it 'returns the correct number of rows' do 54 | count = Activity.where(task_count: 3).count 55 | expect(response.parsed_body.size).to eq count 56 | end 57 | 58 | it 'returns Have a good breakfast' do 59 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 60 | end 61 | end 62 | 63 | context 'with minimum value' do 64 | before { get '/activities', params: { filter: { min_only_task_count_min: 3 } } } 65 | 66 | it 'returns the correct number of rows' do 67 | count = Activity.where('task_count >= 3').count 68 | expect(response.parsed_body.size).to eq count 69 | end 70 | 71 | it 'returns Have a good breakfast' do 72 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 73 | end 74 | end 75 | end 76 | 77 | context 'when range: :max_only' do 78 | context 'with exact value' do 79 | before { get '/activities', params: { filter: { max_only_task_count: 3 } } } 80 | 81 | it 'returns the correct number of rows' do 82 | count = Activity.where(task_count: 3).count 83 | expect(response.parsed_body.size).to eq count 84 | end 85 | 86 | it 'returns Have a good breakfast' do 87 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 88 | end 89 | end 90 | 91 | context 'with maximum value' do 92 | before { get '/activities', params: { filter: { max_only_task_count_max: 3 } } } 93 | 94 | it 'returns the correct number of rows' do 95 | count = Activity.where('task_count <= 3').count 96 | expect(response.parsed_body.size).to eq count 97 | end 98 | 99 | it 'returns Get dressed' do 100 | expect(response.parsed_body).to include_a_record_with('name' => activities(:look_sharp).name) 101 | end 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /spec/requests/scope_filters_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Scope filters' do 6 | context 'when conditional' do 7 | fixtures :activities, :users 8 | 9 | context 'when true' do 10 | before { get '/activities', params: { filter: { incomplete: true } } } 11 | 12 | it 'returns the correct number of rows' do 13 | count = Activity.where(completed: false).count 14 | expect(response.parsed_body.size).to eq count 15 | end 16 | 17 | it 'returns Have a good breakfast' do 18 | expect(response).to have_http_status(:success) 19 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 20 | end 21 | end 22 | 23 | context 'when false' do 24 | before { get '/activities', params: { filter: { incomplete: false } } } 25 | 26 | it 'does not apply the scope' do 27 | count = Activity.count 28 | expect(response.parsed_body.size).to eq count 29 | end 30 | end 31 | 32 | context 'with name specified' do 33 | before { get '/activities', params: { filter: { in_progress: true } } } 34 | 35 | it 'returns the correct number of rows' do 36 | count = Activity.where(completed: false).count 37 | expect(response.parsed_body.size).to eq count 38 | end 39 | 40 | it 'returns Have a good breakfast' do 41 | expect(response).to have_http_status(:success) 42 | expect(response.parsed_body).to include_a_record_with('name' => activities(:good_breakfast).name) 43 | end 44 | end 45 | 46 | context 'with name specified and false' do 47 | before { get '/activities', params: { filter: { in_progress: false } } } 48 | 49 | it 'does not apply the scope' do 50 | count = Activity.count 51 | expect(response.parsed_body.size).to eq count 52 | end 53 | end 54 | end 55 | 56 | context 'with arguments' do 57 | fixtures :projects 58 | 59 | before { get '/projects', params: { filter: { in_progress: 1.day.from_now } } } 60 | 61 | it 'returns the correct number of rows' do 62 | count = Project.in_progress(1.day.from_now).count 63 | expect(response.parsed_body.size).to eq count 64 | end 65 | 66 | it 'returns Start day on the right foot' do 67 | expect(response).to have_http_status(:success) 68 | expect(response.parsed_body).to include_a_record_with('name' => projects(:start_day).name) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /spec/requests/scope_sorts_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rails_helper' 4 | 5 | RSpec.describe 'Scope sorts' do 6 | fixtures :projects 7 | 8 | context 'with class method scope' do 9 | before { get '/projects', params: { filter: { sort: :by_created_at } } } 10 | 11 | it 'returns successfully' do 12 | expect(response).to have_http_status(:success) 13 | end 14 | end 15 | 16 | context 'with inline scope' do 17 | before { get '/projects', params: { filter: { sort: 'by_project_id' } } } 18 | 19 | it 'sorts projects by id asc' do 20 | ordered = response.parsed_body.pluck('id') 21 | expect(ordered).to eq Project.order(id: :asc).pluck(:id) 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'filterameter' 4 | require 'simplecov' 5 | 6 | SimpleCov.start 7 | 8 | RSpec.configure do |config| 9 | config.expect_with :rspec do |expectations| 10 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 11 | end 12 | 13 | config.mock_with :rspec do |mocks| 14 | mocks.verify_partial_doubles = true 15 | end 16 | 17 | config.shared_context_metadata_behavior = :apply_to_host_groups 18 | 19 | config.disable_monkey_patching! 20 | 21 | config.default_formatter = 'doc' if config.files_to_run.one? 22 | 23 | config.order = :random 24 | Kernel.srand config.seed 25 | 26 | RSpec::Matchers.define :include_a_record_with do |expected_attributes| 27 | match do |records| 28 | expect(records).to include(a_hash_including(expected_attributes)) 29 | end 30 | end 31 | 32 | RSpec::Matchers.define :sort_by do |expected| 33 | # this works on the assumption that adding an order that is already on the query does not change it 34 | match do |query| 35 | @expected = expected 36 | query == query.order(expected) 37 | end 38 | 39 | failure_message do |query| 40 | "Expected \n\t#{query.to_sql}\nto include the sort #{@expected}" 41 | end 42 | 43 | failure_message_when_negated do 44 | "Expected \n\t#{query.to_sql}\nnot to include the sort #{@expected}" 45 | end 46 | end 47 | end 48 | 49 | RSpec::Matchers.define_negated_matcher :not_match, :match 50 | --------------------------------------------------------------------------------