├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .tm_properties ├── Gemfile ├── LICENSE ├── README.md ├── Rakefile ├── activerecord-filter.gemspec ├── lib └── active_record │ ├── filter.rb │ └── filter │ ├── alias_tracker_extension.rb │ ├── filter_clause_factory.rb │ ├── predicate_builder_extension.rb │ ├── query_methods_extension.rb │ ├── relation_extension.rb │ ├── spawn_methods_extension.rb │ ├── unkown_filter_error.rb │ └── version.rb └── test ├── database.rb ├── factories.rb ├── filter_column_test ├── array_test.rb ├── boolean_test.rb ├── datetime_test.rb ├── geometry_test.rb ├── integer_test.rb ├── json_test.rb └── string_test.rb ├── filter_relationship_test ├── belongs_to_polymorphic_test.rb ├── belongs_to_test.rb ├── has_and_belongs_to_many_test.rb ├── has_many_test.rb ├── has_many_through_test.rb └── has_one_test.rb ├── filter_test.rb └── test_helper.rb /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | types: [opened] 7 | 8 | jobs: 9 | sunstone: 10 | name: ActiveRecord::Filter Test 11 | runs-on: ubuntu-24.04 12 | 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | rails: ['7.2.2.1', '8.0.2'] 17 | ruby-version: ['3.2', '3.3', '3.4', '3.5.0-preview1'] 18 | postgres-version: ['17'] 19 | 20 | steps: 21 | - name: Install Postgresql 22 | run: | 23 | sudo apt-get -y --purge remove $(sudo apt list --installed | grep postgresql | awk '{print $1}') 24 | sudo apt-get install curl ca-certificates gnupg 25 | curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 26 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 27 | sudo apt-get update 28 | sudo apt-get -y install postgresql-${{ matrix.postgres-version }}-postgis-3 29 | sudo systemctl start postgresql@${{ matrix.postgres-version }}-main.service 30 | sudo systemctl status postgresql@${{ matrix.postgres-version }}-main.service 31 | sudo pg_lsclusters 32 | sudo -u postgres createuser runner --superuser 33 | sudo -u postgres psql -c "ALTER USER runner WITH PASSWORD 'runner';" 34 | 35 | - uses: actions/checkout@v4 36 | 37 | - name: Fix activerecord-postgis-adapter 38 | if: ${{ matrix.rails == '8.0.1' }} 39 | run: | 40 | echo "gem 'activerecord-postgis-adapter', github: 'rgeo/activerecord-postgis-adapter'" >> Gemfile 41 | 42 | - uses: ruby/setup-ruby@v1 43 | with: 44 | ruby-version: ${{ matrix.ruby-version }} 45 | bundler-cache: true # runs 'bundle install' and caches installed gems automatically 46 | 47 | - run: | 48 | sed -i -e "s/spec.add_runtime_dependency *'activerecord', *'>= [[:digit:]]\+\(\.[[:digit:]]\+\)*'/spec.add_runtime_dependency 'activerecord', '${{ matrix.rails }}'/" activerecord-filter.gemspec 49 | cat activerecord-filter.gemspec 50 | rm Gemfile.lock 51 | bundle 52 | 53 | - run: bundle exec rake test 54 | 55 | ar-postgresql: 56 | name: ActiveRecord PostgresQL Test 57 | runs-on: ubuntu-24.04 58 | 59 | strategy: 60 | fail-fast: false 61 | matrix: 62 | rails: ['7.1.5.1', '7.2.2.1', '8.0.2'] 63 | ruby-version: ['3.4'] 64 | postgres-version: ['17'] 65 | exclude: 66 | - rails: '7.1.5.1' 67 | ruby-version: '3.4' 68 | postgres-version: '17' 69 | 70 | steps: 71 | - name: Install Postgresql 72 | run: | 73 | sudo apt-get -y --purge remove $(sudo apt list --installed | grep postgresql | awk '{print $1}') 74 | sudo apt-get install curl ca-certificates gnupg 75 | curl https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - 76 | sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' 77 | sudo apt-get update 78 | sudo apt-get -y install postgresql-${{ matrix.postgres-version }}-postgis-3 79 | sudo systemctl start postgresql@${{ matrix.postgres-version }}-main.service 80 | sudo systemctl status postgresql@${{ matrix.postgres-version }}-main.service 81 | sudo pg_lsclusters 82 | sudo -u postgres createuser runner --superuser 83 | sudo -u postgres psql -c "ALTER USER runner WITH PASSWORD 'runner';" 84 | 85 | - uses: actions/checkout@v4 86 | 87 | - uses: ruby/setup-ruby@v1 88 | with: 89 | ruby-version: ${{ matrix.ruby-version }} 90 | bundler-cache: false # runs 'bundle install' and caches installed gems automatically 91 | 92 | - name: Download Rails 93 | run: | 94 | git clone --branch v${{ matrix.rails }} https://github.com/rails/rails.git ~/rails 95 | pushd ~/rails 96 | cat /home/runner/work/_temp/*.sh 97 | sed -i "s/Gem.ruby, '-w'/Gem.ruby, '-w0'/" ~/rails/activerecord/Rakefile 98 | sed -i "s/t.warning = true/t.warning = false/g" ~/rails/activerecord/Rakefile 99 | sed -i "/require \"active_record\"/a \$LOAD_PATH.unshift\(File.expand_path\(ENV['GITHUB_WORKSPACE']\)\)\nrequire 'active_record/filter'" ~/rails/activerecord/test/cases/helper.rb 100 | rm ~/rails/Gemfile.lock 101 | sed -i "/# Active Record./a gem 'activerecord-filter', require: 'active_record/filter', path: File.expand_path\(ENV['GITHUB_WORKSPACE']\)" ~/rails/Gemfile 102 | cat ~/rails/Gemfile 103 | bundle update --jobs=3 --retry=3 104 | 105 | - name: Fix Weird Test Cases 106 | run: | 107 | sed -i 's|} - \[:|} - \[:distinct_on, :uniq_on, :|' ~/rails/activerecord/test/cases/relation/delegation_test.rb 108 | 109 | - run: | 110 | pushd ~/rails/activerecord 111 | bundle exec rake db:postgresql:rebuild postgresql:test 112 | bundle exec rake db:postgresql:rebuild postgresql:isolated_test 113 | 114 | ar-sqlite: 115 | name: ActiveRecord SQLite Test 116 | runs-on: ubuntu-24.04 117 | 118 | 119 | strategy: 120 | fail-fast: false 121 | matrix: 122 | rails: ['7.1.5.1', '7.2.2.1', '8.0.2'] 123 | ruby-version: ['3.4'] 124 | exclude: 125 | - rails: '7.1.5.1' 126 | ruby-version: '3.4' 127 | 128 | steps: 129 | - uses: actions/checkout@v4 130 | 131 | - uses: ruby/setup-ruby@v1 132 | with: 133 | ruby-version: ${{ matrix.ruby-version }} 134 | bundler-cache: false # runs 'bundle install' and caches installed gems automatically 135 | 136 | - name: Download Rails 137 | run: | 138 | git clone --branch v${{ matrix.rails }} https://github.com/rails/rails.git ~/rails 139 | pushd ~/rails 140 | cat /home/runner/work/_temp/*.sh 141 | sed -i "s/Gem.ruby, '-w'/Gem.ruby, '-w0'/" ~/rails/activerecord/Rakefile 142 | sed -i "s/t.warning = true/t.warning = false/g" ~/rails/activerecord/Rakefile 143 | sed -i "/require \"active_record\"/a \$LOAD_PATH.unshift\(File.expand_path\(ENV['GITHUB_WORKSPACE']\)\)\nrequire 'active_record/filter'" ~/rails/activerecord/test/cases/helper.rb 144 | rm ~/rails/Gemfile.lock 145 | sed -i "/# Active Record./a gem 'activerecord-filter', require: 'active_record/filter', path: File.expand_path\(ENV['GITHUB_WORKSPACE']\)" ~/rails/Gemfile 146 | cat ~/rails/Gemfile 147 | bundle update --jobs=3 --retry=3 148 | 149 | - name: Fix Weird Test Cases 150 | run: | 151 | sed -i 's|} - \[:|} - \[:distinct_on, :uniq_on, :|' ~/rails/activerecord/test/cases/relation/delegation_test.rb 152 | 153 | - run: | 154 | pushd ~/rails/activerecord 155 | bundle exec rake sqlite3:test 156 | rm test/db/*.sqlite3 test/fixtures/*.sqlite3 157 | bundle exec rake sqlite3:isolated_test 158 | rm test/db/*.sqlite3 test/fixtures/*.sqlite3 159 | bundle exec rake sqlite3_mem:test 160 | 161 | ar-mysql: 162 | name: ActiveRecord MySQL Test 163 | runs-on: ubuntu-24.04 164 | 165 | strategy: 166 | fail-fast: false 167 | matrix: 168 | rails: ['7.1.5.1', '7.2.2.1', '8.0.2'] 169 | ruby-version: ['3.4'] 170 | exclude: 171 | - rails: '7.1.5.1' 172 | ruby-version: '3.4' 173 | 174 | steps: 175 | - name: Install MySQL 176 | run: | 177 | sudo /etc/init.d/mysql start 178 | mysql -uroot -proot -e "CREATE USER 'rails'@'%';" 179 | mysql -uroot -proot -e "GRANT ALL PRIVILEGES ON *.* TO 'rails'@'%' WITH GRANT OPTION;" 180 | 181 | - uses: actions/checkout@v4 182 | 183 | - uses: ruby/setup-ruby@v1 184 | with: 185 | ruby-version: ${{ matrix.ruby-version }} 186 | bundler-cache: false # runs 'bundle install' and caches installed gems automatically 187 | 188 | - name: Download Rails 189 | run: | 190 | git clone --branch v${{ matrix.rails }} https://github.com/rails/rails.git ~/rails 191 | pushd ~/rails 192 | cat /home/runner/work/_temp/*.sh 193 | sed -i "s/Gem.ruby, '-w'/Gem.ruby, '-w0'/" ~/rails/activerecord/Rakefile 194 | sed -i "s/t.warning = true/t.warning = false/g" ~/rails/activerecord/Rakefile 195 | sed -i "/require \"active_record\"/a \$LOAD_PATH.unshift\(File.expand_path\(ENV['GITHUB_WORKSPACE']\)\)\nrequire 'active_record/filter'" ~/rails/activerecord/test/cases/helper.rb 196 | rm ~/rails/Gemfile.lock 197 | sed -i "/# Active Record./a gem 'activerecord-filter', require: 'active_record/filter', path: File.expand_path\(ENV['GITHUB_WORKSPACE']\)" ~/rails/Gemfile 198 | cat ~/rails/Gemfile 199 | bundle update --jobs=3 --retry=3 200 | 201 | - name: Fix Weird Test Cases 202 | run: | 203 | sed -i 's|} - \[:|} - \[:distinct_on, :uniq_on, :|' ~/rails/activerecord/test/cases/relation/delegation_test.rb 204 | 205 | - run: | 206 | pushd ~/rails/activerecord 207 | bundle exec rake db:mysql:rebuild mysql2:test 208 | bundle exec rake db:mysql:rebuild mysql2:isolated_test 209 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | *.gem 3 | .byebug_history 4 | Gemfile.lock 5 | -------------------------------------------------------------------------------- /.tm_properties: -------------------------------------------------------------------------------- 1 | exclude = '{$exclude,log,tmp,.tm_properties,public/system,coverage,*.gem}' 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | git_source(:github) { |repo| "https://github.com/#{repo}.git" } 3 | 4 | # Specify your gem's dependencies in sunstone.gemspec 5 | gemspec 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Jon Bracy 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 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveRecord::Filter 2 | 3 | `ActiveRecord::Filter` provides and easy way to accept user input and filter a query by the input. 4 | 5 | Installtion 6 | ----------- 7 | 8 | - Add `gem 'activerecord-filter', require: 'active_record/filter'` 9 | - Run `bundle install` 10 | 11 | Examples 12 | -------- 13 | 14 | Normal columns: 15 | 16 | ```ruby 17 | Property.filter(id: 5).to_sql 18 | Property.filter(id: {eq: 5}).to_sql 19 | Property.filter(id: {equal_to: 5}).to_sql 20 | # => "... WHERE properties.id = 5 ..." 21 | 22 | Property.filter(id: {not: 5}).to_sql 23 | Property.filter(id: {neq: 5}).to_sql 24 | Property.filter(id: {not_equal: 5}).to_sql 25 | # => "... WHERE properties.id != 5 ..." 26 | 27 | Property.filter(id: [5, 10, 15]).to_sql 28 | # => "... WHERE properties.id IN (5, 10, 15) ..." 29 | 30 | Property.filter(id: {in: [5, 10, 15]}).to_sql 31 | # => "... WHERE properties.id IN (5, 10, 15) ..." 32 | 33 | Property.filter(id: {not_in: [5, 10, 15]}).to_sql 34 | # => "... WHERE properties.id NOT IN (5, 10, 15) ..." 35 | 36 | Property.filter(id: {gt: 5}).to_sql 37 | Property.filter(id: {greater_than: 5}).to_sql 38 | # => "... WHERE properties.id > 5 ..." 39 | 40 | Property.filter(id: {gte: 5}).to_sql 41 | Property.filter(id: {gteq: 5}).to_sql 42 | Property.filter(id: {greater_than_or_equal_to: 5}).to_sql 43 | # => "... WHERE properties.id >= 5 ..." 44 | 45 | Property.filter(id: {lt: 5}).to_sql 46 | Property.filter(id: {less_than: 5}).to_sql 47 | # => "... WHERE properties.id < 5 ..." 48 | 49 | Property.filter(id: {lte: 5}).to_sql 50 | Property.filter(id: {lteq: 5}).to_sql 51 | Property.filter(id: {less_than_or_equal_to: 5}).to_sql 52 | # => "... WHERE properties.id <= 5 ..." 53 | 54 | Property.filter(address_id: nil).to_sql 55 | # => "... WHERE properties.address_id IS NULL ..." 56 | 57 | Property.filter(address_id: false).to_sql 58 | # => "... WHERE properties.address_id IS NULL ..." 59 | 60 | Property.filter(boolean_column: false).to_sql 61 | # => "... WHERE properties.boolean_column = FALSE ..." 62 | 63 | Property.filter(address_id: true).to_sql 64 | # => "... WHERE properties.address_id IS NOT NULL ..." 65 | 66 | Property.filter(boolean_column: true).to_sql 67 | # => "... WHERE properties.boolean_column = TRUE ..." 68 | ``` 69 | 70 | String columns: 71 | 72 | ```ruby 73 | Property.filter(name: {like: 'nam%'}).to_sql 74 | # => "... WHERE properties.name LIKE 'nam%' ..." 75 | 76 | Property.filter(name: {ilike: 'nam%'}).to_sql 77 | # => "... WHERE properties.name ILIKE 'nam%' ..." 78 | 79 | Property.filter(name: {ts_match: 'name'}).to_sql 80 | # => "... WHERE to_tsvector("properties"."name") @@ to_tsquery('name') ..." 81 | ``` 82 | 83 | It can also work with array columns: 84 | 85 | ```ruby 86 | Property.filter(tags: 'Skyscraper').to_sql 87 | # => "...WHERE properties.tags = '{'Skyscraper'}'..." 88 | 89 | Property.filter(tags: ['Skyscraper', 'Brick']).to_sql 90 | # => "...WHERE properties.tags = '{"Skyscraper", "Brick"}'..." 91 | 92 | Property.filter(tags: {overlaps: ['Skyscraper', 'Brick']}).to_sql 93 | # => "...WHERE properties.tags && '{"Skyscraper", "Brick"}'..." 94 | 95 | Property.filter(tags: {contains: ['Skyscraper', 'Brick']}).to_sql 96 | # => "...WHERE accounts.tags @> '{"Skyscraper", "Brick"}'..." 97 | 98 | Property.filter(tags: {excludes: ['Skyscraper', 'Brick']}).to_sql 99 | # => "...WHERE NOT (accounts.tags @> '{"Skyscraper", "Brick"}')..." 100 | 101 | Property.filter(tags: {contained_by: ['Skyscraper', 'Brick']}).to_sql 102 | # => "...WHERE accounts.tags <@ '{"Skyscraper", "Brick"}'..." 103 | ``` 104 | 105 | And JSON columns: 106 | 107 | ```ruby 108 | Property.filter(metadata: { eq: { key: 'value' } }).to_sql 109 | # => "...WHERE "properties"."metadata" = '{\"key\":\"value\"}'..." 110 | 111 | Property.filter(metadata: { contains: { key: 'value' } }).to_sql 112 | # => "...WHERE "properties"."metadata" @> '{\"key\":\"value\"}'..." 113 | 114 | Property.filter(metadata: { has_key: 'key' }).to_sql 115 | # => "...WHERE "properties"."metadata" ? 'key'..." 116 | 117 | Property.filter(metadata: { has_keys: ['key1', 'key2'] }).to_sql 118 | # => "...WHERE "properties"."metadata" ?& array['key1', 'key2']..." 119 | 120 | Property.filter(metadata: { has_any_key: ['key1', 'key2'] }).to_sql 121 | # => "...WHERE "properties"."metadata" ?| array['key1', 'key2']..." 122 | 123 | Property.filter("metadata.key": { eq: 'value' }).to_sql 124 | # => "...WHERE "properties"."metadata" #> '{key}' = 'value'..." 125 | ``` 126 | 127 | It can also sort on relations: 128 | 129 | ```ruby 130 | Photo.filter(property: {name: 'Empire State'}).to_sql 131 | # => "... LEFT OUTER JOIN properties ON properties.id = photos.property_id ... 132 | # => "... WHERE properties.name = 'Empire State'" 133 | ``` 134 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require "bundler/gem_tasks" 3 | Bundler.require(:development) 4 | 5 | require 'fileutils' 6 | require "rake/testtask" 7 | 8 | # Test Task 9 | Rake::TestTask.new do |t| 10 | t.libs << 'lib' << 'test' 11 | t.test_files = FileList[ARGV[1] ? ARGV[1] : 'test/**/*_test.rb'] 12 | t.warning = true 13 | t.verbose = true 14 | end 15 | 16 | # require "sdoc" 17 | # RDoc::Task.new do |rdoc| 18 | # rdoc.main = 'README.md' 19 | # rdoc.title = 'Wankel API' 20 | # rdoc.rdoc_dir = 'doc' 21 | # 22 | # rdoc.rdoc_files.include('README.md') 23 | # rdoc.rdoc_files.include('logo.png') 24 | # rdoc.rdoc_files.include('lib/**/*.rb') 25 | # rdoc.rdoc_files.include('ext/**/*.{h,c}') 26 | # 27 | # rdoc.options << '-f' << 'sdoc' 28 | # rdoc.options << '-T' << '42floors' 29 | # rdoc.options << '--charset' << 'utf-8' 30 | # rdoc.options << '--line-numbers' 31 | # rdoc.options << '--github' 32 | # end -------------------------------------------------------------------------------- /activerecord-filter.gemspec: -------------------------------------------------------------------------------- 1 | require File.expand_path("../lib/active_record/filter/version", __FILE__) 2 | 3 | Gem::Specification.new do |spec| 4 | spec.name = "activerecord-filter" 5 | spec.version = ActiveRecord::Filter::VERSION 6 | spec.licenses = ['MIT'] 7 | spec.authors = ["Jon Bracy"] 8 | spec.email = ["jonbracy@gmail.com"] 9 | spec.homepage = "https://github.com/malomalo/activerecord-filter" 10 | spec.description = %q{A safe way to accept user parameters and query against your ActiveRecord Models} 11 | spec.summary = %q{A safe way to accept user parameters and query against your ActiveRecord Models} 12 | 13 | spec.extra_rdoc_files = %w(README.md) 14 | spec.rdoc_options.concat ['--main', 'README.md'] 15 | 16 | spec.files = `git ls-files -- README.md {lib,ext}/*`.split("\n") 17 | spec.test_files = `git ls-files -- {test}/*`.split("\n") 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_runtime_dependency 'activerecord', '>= 7.2.0' 21 | spec.add_runtime_dependency 'arel-extensions', '>= 7.2.0' 22 | 23 | spec.add_development_dependency 'pg' 24 | spec.add_development_dependency 'actionpack', '>= 7.2.0' 25 | spec.add_development_dependency "bundler" 26 | spec.add_development_dependency "rake" 27 | spec.add_development_dependency 'minitest' 28 | spec.add_development_dependency 'minitest-reporters' 29 | spec.add_development_dependency "simplecov" 30 | spec.add_development_dependency "railties", '>= 7.2.0' 31 | spec.add_development_dependency "faker" 32 | spec.add_development_dependency "byebug" 33 | spec.add_development_dependency "activerecord-postgis-adapter" 34 | # spec.add_development_dependency 'sdoc', '~> 0.4' 35 | # spec.add_development_dependency 'sdoc-templates-42floors', '~> 0.3' 36 | end 37 | -------------------------------------------------------------------------------- /lib/active_record/filter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_record' 4 | require 'arel/extensions' 5 | require 'active_record/filter/unkown_filter_error' 6 | 7 | module ActiveRecord::Filter 8 | 9 | autoload :QueryMethodsExtension, 'active_record/filter/query_methods_extension' 10 | autoload :AliasTrackerExtension, 'active_record/filter/alias_tracker_extension' 11 | autoload :FilterClauseFactory, 'active_record/filter/filter_clause_factory' 12 | autoload :RelationExtension, 'active_record/filter/relation_extension' 13 | autoload :PredicateBuilderExtension, 'active_record/filter/predicate_builder_extension' 14 | autoload :SpawnMethodsExtension, 'active_record/filter/spawn_methods_extension' 15 | 16 | delegate :filter, :filter_for, to: :all 17 | 18 | def inherited(subclass) 19 | super 20 | subclass.instance_variable_set('@filters', HashWithIndifferentAccess.new) 21 | end 22 | 23 | def filters 24 | @filters 25 | end 26 | 27 | def filter_on(name, dependent_joins=nil, &block) 28 | @filters[name.to_s] = { joins: dependent_joins, block: block } 29 | end 30 | 31 | end 32 | 33 | 34 | ActiveRecord::QueryMethods.prepend(ActiveRecord::Filter::QueryMethodsExtension) 35 | ActiveRecord::Base.extend(ActiveRecord::Filter) 36 | ActiveRecord::Relation.prepend(ActiveRecord::Filter::RelationExtension) 37 | ActiveRecord::SpawnMethods.extend(ActiveRecord::Filter::SpawnMethodsExtension) 38 | ActiveRecord::PredicateBuilder.include(ActiveRecord::Filter::PredicateBuilderExtension) 39 | ActiveRecord::Associations::AliasTracker.prepend(ActiveRecord::Filter::AliasTrackerExtension) -------------------------------------------------------------------------------- /lib/active_record/filter/alias_tracker_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::Filter::AliasTrackerExtension 4 | 5 | def initialize(*, **) 6 | super 7 | @relation_trail = {} 8 | end 9 | 10 | def aliased_table_for_relation(trail, arel_table, &block) 11 | @relation_trail[trail] ||= aliased_table_for(arel_table, &block) 12 | end 13 | 14 | end -------------------------------------------------------------------------------- /lib/active_record/filter/filter_clause_factory.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveRecord::Filter::FilterClauseFactory 4 | 5 | def initialize(model, predicate_builder) 6 | @klass = model 7 | @predicate_builder = predicate_builder 8 | end 9 | 10 | def build(filters, alias_tracker) 11 | if filters.is_a?(Hash) || filters.is_a?(Array) 12 | parts = [predicate_builder.build_from_filter_hash(filters, [], alias_tracker)] 13 | else 14 | raise ArgumentError, "Unsupported argument type: #{filters.inspect} (#{filters.class})" 15 | end 16 | 17 | ActiveRecord::Relation::WhereClause.new(parts) 18 | end 19 | 20 | protected 21 | 22 | attr_reader :klass, :predicate_builder 23 | 24 | end 25 | -------------------------------------------------------------------------------- /lib/active_record/filter/predicate_builder_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "active_support/concern" 4 | 5 | module ActiveRecord::Filter::PredicateBuilderExtension 6 | 7 | extend ActiveSupport::Concern 8 | 9 | class_methods do 10 | def filter_joins(klass, filters) 11 | custom = [] 12 | [build_filter_joins(klass, filters, [], custom), custom] 13 | end 14 | 15 | def build_filter_joins(klass, filters, relations=[], custom=[]) 16 | if filters.is_a?(Array) 17 | filters.each { |f| build_filter_joins(klass, f, relations, custom) }.compact 18 | elsif filters.is_a?(Hash) 19 | filters.each do |key, value| 20 | if klass.filters.has_key?(key.to_sym) 21 | js = klass.filters.dig(key.to_sym, :joins) 22 | 23 | if js.is_a?(Array) 24 | js.each do |j| 25 | if j.is_a?(String) 26 | custom << j 27 | else 28 | relations << j 29 | end 30 | end 31 | elsif js 32 | if js.is_a?(String) 33 | custom << js 34 | else 35 | relations << js 36 | end 37 | end 38 | elsif reflection = klass._reflections[key.to_sym] 39 | if value.is_a?(Hash) 40 | relations << if reflection.polymorphic? 41 | value = value.dup 42 | join_klass = value.delete(:as).safe_constantize 43 | right_table = join_klass.arel_table 44 | left_table = reflection.active_record.arel_table 45 | 46 | on = right_table[join_klass.primary_key]. 47 | eq(left_table[reflection.foreign_key]). 48 | and(left_table[reflection.foreign_type].eq(join_klass.name)) 49 | 50 | cross_boundry_joins = join_klass.left_outer_joins(ActiveRecord::PredicateBuilder.filter_joins(join_klass, value).flatten).send(:build_joins, []) 51 | 52 | [ 53 | left_table.join(right_table, Arel::Nodes::OuterJoin).on(on).join_sources, 54 | cross_boundry_joins 55 | ] 56 | else 57 | { 58 | key => build_filter_joins(reflection.klass, value, [], custom) 59 | } 60 | end 61 | elsif value.is_a?(Array) 62 | value.each do |v| 63 | relations << { 64 | key => build_filter_joins(reflection.klass, v, [], custom) 65 | } 66 | end 67 | elsif value != true && value != false && value != 'true' && value != 'false' && !value.nil? 68 | relations << key 69 | end 70 | elsif !klass.columns_hash.has_key?(key.to_s) && key.to_s.end_with?('_ids') && reflection = klass._reflections[key.to_s.gsub(/_ids$/, 's').to_sym] 71 | relations << reflection.name 72 | elsif reflection = klass.reflect_on_all_associations(:has_and_belongs_to_many).find {|r| r.join_table == key.to_s && value.keys.first.to_s == r.association_foreign_key.to_s } 73 | reflection = klass._reflections[klass._reflections[reflection.name].send(:delegate_reflection).options[:through]] 74 | relations << { reflection.name => build_filter_joins(reflection.klass, value) } 75 | else 76 | {key => value} 77 | end 78 | end 79 | end 80 | 81 | relations 82 | end 83 | end 84 | 85 | def build_from_filter_hash(attributes, relation_trail, alias_tracker) 86 | if attributes.is_a?(Array) 87 | node = build_from_filter_hash(attributes.shift, relation_trail, alias_tracker) 88 | 89 | n = attributes.shift(2) 90 | while !n.empty? 91 | n[1] = build_from_filter_hash(n[1], relation_trail, alias_tracker) 92 | if n[0] == 'AND' 93 | if node.is_a?(Arel::Nodes::And) 94 | node.children.push(n[1]) 95 | else 96 | node = node.and(n[1]) 97 | end 98 | elsif n[0] == 'OR' 99 | node = Arel::Nodes::Grouping.new(node).or(Arel::Nodes::Grouping.new(n[1])) 100 | elsif !n[0].is_a?(String) 101 | n[0] = build_from_filter_hash(n[0], relation_trail, alias_tracker) 102 | if node.is_a?(Arel::Nodes::And) 103 | node.children.push(n[0]) 104 | else 105 | node = node.and(n[0]) 106 | end 107 | else 108 | raise 'lll' 109 | end 110 | n = attributes.shift(2) 111 | end 112 | 113 | node 114 | elsif attributes.is_a?(Hash) 115 | expand_from_filter_hash(attributes, relation_trail, alias_tracker) 116 | else 117 | expand_from_filter_hash({id: attributes}, relation_trail, alias_tracker) 118 | end 119 | end 120 | 121 | def expand_from_filter_hash(attributes, relation_trail, alias_tracker) 122 | klass = table.send(:klass) 123 | 124 | children = attributes.flat_map do |key, value| 125 | if custom_filter = klass.filters[key] 126 | self.instance_exec(klass, table, key, value, relation_trail, alias_tracker, &custom_filter[:block]) 127 | elsif column = klass.columns_hash[key.to_s] || klass.columns_hash[key.to_s.split('.').first] 128 | expand_filter_for_column(key, column, value, relation_trail) 129 | elsif relation = klass.reflect_on_association(key) 130 | expand_filter_for_relationship(relation, value, relation_trail, alias_tracker) 131 | elsif key.to_s.end_with?('_ids') && relation = klass.reflect_on_association(key.to_s.gsub(/_ids$/, 's')) 132 | expand_filter_for_relationship(relation, {id: value}, relation_trail, alias_tracker) 133 | elsif relation = klass.reflect_on_all_associations(:has_and_belongs_to_many).find {|r| r.join_table == key.to_s && value.keys.first.to_s == r.association_foreign_key.to_s } 134 | expand_filter_for_join_table(relation, value, relation_trail, alias_tracker) 135 | else 136 | raise ActiveRecord::UnkownFilterError.new("Unkown filter \"#{key}\" for #{klass}.") 137 | end 138 | end 139 | 140 | children.compact! 141 | if children.size > 1 142 | Arel::Nodes::And.new(children) 143 | else 144 | children.first 145 | end 146 | end 147 | 148 | def expand_filter_for_column(key, column, value, relation_trail) 149 | attribute = table.arel_table[column.name] 150 | relation_trail.each do |rt| 151 | attribute = Arel::Attributes::Relation.new(attribute, rt) 152 | end 153 | 154 | if column.type == :json || column.type == :jsonb 155 | names = key.to_s.split('.') 156 | names.shift 157 | attribute = attribute.dig(names) 158 | elsif column.type == :geometry 159 | value = if value.is_a?(Hash) 160 | value.transform_values { |v| geometry_from_value(v) } 161 | else 162 | geometry_from_value(value) 163 | end 164 | end 165 | 166 | if value.is_a?(Hash) 167 | nodes = value.map do |subkey, subvalue| 168 | expand_filter_for_arel_attribute(column, attribute, subkey, subvalue) 169 | end 170 | nodes.inject { |c, n| c.nil? ? n : c.and(n) } 171 | elsif value == nil 172 | attribute.eq(nil) 173 | elsif value == true || value == 'true' 174 | column.type == :boolean ? attribute.eq(true) : attribute.not_eq(nil) 175 | elsif value == false || value == 'false' 176 | column.type == :boolean ? attribute.eq(false) : attribute.eq(nil) 177 | elsif value.is_a?(Array) && !column.array 178 | attribute.in(value) 179 | elsif column.type != :json && column.type != :jsonb 180 | converted_value = column.array ? Array(value) : value 181 | attribute.eq(converted_value) 182 | else 183 | raise ActiveRecord::UnkownFilterError.new("Unkown type for #{column}. (type #{value.class})") 184 | end 185 | 186 | end 187 | 188 | # TODO determine if SRID sent and cast to correct SRID 189 | def geometry_from_value(value) 190 | if value.is_a?(Array) 191 | value.map { |g| geometry_from_value(g) } 192 | elsif value.is_a?(Hash) 193 | Arel::Nodes::NamedFunction.new('ST_SetSRID', [Arel::Nodes::NamedFunction.new('ST_GeomFromGeoJSON', [Arel::Nodes.build_quoted(JSON.generate(value))]), 4326]) 194 | elsif value[0,1] == "\x00" || value[0,1] == "\x01" 195 | Arel::Nodes::NamedFunction.new('ST_SetSRID', [Arel::Nodes::NamedFunction.new('ST_GeomFromEWKB', [Arel::Nodes::BinaryValue.new(value)]), 4326]) 196 | elsif value[0,4] =~ /[0-9a-fA-F]{4}/ 197 | Arel::Nodes::NamedFunction.new('ST_SetSRID', [Arel::Nodes::NamedFunction.new('ST_GeomFromEWKB', [Arel::Nodes::HexEncodedBinaryValue.new(value)]), 4326]) 198 | else 199 | Arel::Nodes::NamedFunction.new('ST_SetSRID', [Arel::Nodes::NamedFunction.new('ST_GeomFromText', [Arel::Nodes.build_quoted(value)]), 4326]) 200 | end 201 | end 202 | 203 | def expand_filter_for_arel_attribute(column, attribute, key, value) 204 | case key.to_sym 205 | when :contains 206 | case column.type 207 | when :geometry 208 | Arel::Nodes::NamedFunction.new('ST_Contains', [attribute, value]) 209 | else 210 | attribute.contains(Arel::Nodes::Casted.new(column.array ? Array(value) : value, attribute)) 211 | end 212 | when :contained_by 213 | attribute.contained_by(Arel::Nodes::Casted.new(column.array ? Array(value) : value, attribute)) 214 | when :equal_to, :eq 215 | case column.type 216 | when :geometry 217 | Arel::Nodes::NamedFunction.new('ST_Equals', [attribute, value]) 218 | else 219 | attribute.eq(value) 220 | end 221 | when :excludes 222 | attribute.excludes(Arel::Nodes::Casted.new(column.array ? Array(value) : value, attribute)) 223 | when :greater_than, :gt 224 | attribute.gt(value) 225 | when :greater_than_or_equal_to, :gteq, :gte 226 | attribute.gteq(value) 227 | when :has_key 228 | attribute.has_key(value) 229 | when :has_keys 230 | attribute.has_keys(*Array(value).map { |x| Arel::Nodes.build_quoted(x) }) 231 | when :has_any_key 232 | attribute.has_any_key(*Array(value).map { |x| Arel::Nodes.build_quoted(x) }) 233 | when :in 234 | attribute.in(value) 235 | when :intersects 236 | attribute.intersects(value) 237 | when :less_than, :lt 238 | attribute.lt(value) 239 | when :less_than_or_equal_to, :lteq, :lte 240 | attribute.lteq(value) 241 | when :like 242 | attribute.matches(value, nil, true) 243 | when :ilike 244 | attribute.matches(value, nil, false) 245 | when :not, :not_equal, :neq 246 | attribute.not_eq(value) 247 | when :not_in 248 | attribute.not_in(value) 249 | when :overlaps 250 | case column.type 251 | in :geometry 252 | attribute.overlaps(value) 253 | else 254 | attribute.overlaps(Arel::Nodes::Casted.new(column.array ? Array(value) : value, attribute)) 255 | end 256 | when :not_overlaps 257 | attribute.not_overlaps(value) 258 | when :ts_match 259 | if value.is_a?(Array) 260 | attribute.ts_query(*value) 261 | else 262 | attribute.ts_query(value) 263 | end 264 | when :within 265 | attribute.within(Arel::Nodes.build_quoted(value)) 266 | else 267 | raise "Not Supported: #{key.to_sym} on column \"#{column.name}\" of type #{column.type}" 268 | end 269 | end 270 | 271 | def expand_filter_for_relationship(relation, value, relation_trail, alias_tracker) 272 | case relation.macro 273 | when :has_many 274 | if value == true || value == 'true' 275 | counter_cache_column_name = relation.counter_cache_column || "#{relation.plural_name}_count" 276 | if relation.active_record.column_names.include?(counter_cache_column_name.to_s) 277 | return table.arel_table[counter_cache_column_name.to_sym].gt(0) 278 | else 279 | raise "Not Supported: #{relation.name}" 280 | end 281 | elsif value == false || value == 'false' 282 | counter_cache_column_name = relation.counter_cache_column || "#{relation.plural_name}_count" 283 | if relation.active_record.column_names.include?(counter_cache_column_name.to_s) 284 | return table.arel_table[counter_cache_column_name.to_sym].eq(0) 285 | else 286 | raise "Not Supported: #{relation.name}" 287 | end 288 | end 289 | 290 | when :belongs_to 291 | if value == true || value == 'true' 292 | return table.arel_table[relation.foreign_key].not_eq(nil) 293 | elsif value == false || value == 'false' || value.nil? 294 | return table.arel_table[relation.foreign_key].eq(nil) 295 | end 296 | end 297 | 298 | if relation.polymorphic? 299 | value = value.dup 300 | klass = value.delete(:as).safe_constantize 301 | 302 | builder = self.class.new(ActiveRecord::TableMetadata.new( 303 | klass, 304 | alias_tracker.aliased_table_for_relation(relation_trail + ["#{klass.table_name}_as_#{relation.name}"], klass.arel_table) { klass.arel_table.name }, 305 | relation 306 | )) 307 | builder.build_from_filter_hash(value, relation_trail + ["#{klass.table_name}_as_#{relation.name}"], alias_tracker) 308 | else 309 | builder = self.class.new(ActiveRecord::TableMetadata.new( 310 | relation.klass, 311 | alias_tracker.aliased_table_for_relation(relation_trail + [relation.name], relation.klass.arel_table) { relation.alias_candidate(table.arel_table.name || relation.klass.arel_table) }, 312 | relation 313 | )) 314 | builder.build_from_filter_hash(value, relation_trail + [relation.name], alias_tracker) 315 | end 316 | 317 | end 318 | 319 | def expand_filter_for_join_table(relation, value, relation_trail, alias_tracker) 320 | relation = relation.active_record._reflections[relation.active_record._reflections[relation.name].send(:delegate_reflection).options[:through]] 321 | builder = self.class.new(ActiveRecord::TableMetadata.new( 322 | relation.klass, 323 | alias_tracker.aliased_table_for_relation(relation_trail + [relation.name], relation.klass.arel_table) { relation.alias_candidate(table.arel_table.name || relation.klass.arel_table) }, 324 | relation 325 | )) 326 | builder.build_from_filter_hash(value, relation_trail + [relation.name], alias_tracker) 327 | end 328 | 329 | end -------------------------------------------------------------------------------- /lib/active_record/filter/query_methods_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::Filter::QueryMethodsExtension 4 | private 5 | def build_join_buckets 6 | buckets = Hash.new { |h, k| h[k] = [] } 7 | 8 | unless left_outer_joins_values.empty? 9 | stashed_left_joins = [] 10 | left_joins = select_named_joins(left_outer_joins_values, stashed_left_joins) do |left_join| 11 | if left_join.is_a?(ActiveRecord::QueryMethods::CTEJoin) 12 | buckets[:join_node] << build_with_join_node(left_join.name, Arel::Nodes::OuterJoin) 13 | # Add this elsif becasuse PR https://github.com/rails/rails/pull/46843 14 | # Changed a line https://github.com/rails/rails/blob/ae2983a75ca658d84afa414dea8eaf1cca87aa23/activerecord/lib/active_record/relation/query_methods.rb#L1769 15 | # that was probably a bug beforehand but allowed nodes to be joined 16 | # which I think was and still is supported? 17 | elsif left_join.is_a?(Arel::Nodes::OuterJoin) 18 | buckets[:join_node] << left_join 19 | else 20 | raise ArgumentError, "only Hash, Symbol and Array are allowed" 21 | end 22 | end 23 | 24 | if joins_values.empty? 25 | buckets[:named_join] = left_joins 26 | buckets[:stashed_join] = stashed_left_joins 27 | return buckets, Arel::Nodes::OuterJoin 28 | else 29 | stashed_left_joins.unshift construct_join_dependency(left_joins, Arel::Nodes::OuterJoin) 30 | end 31 | end 32 | 33 | joins = joins_values.dup 34 | if joins.last.is_a?(ActiveRecord::Associations::JoinDependency) 35 | stashed_eager_load = joins.pop if joins.last.base_klass == model 36 | end 37 | 38 | joins.each_with_index do |join, i| 39 | joins[i] = Arel::Nodes::StringJoin.new(Arel.sql(join.strip)) if join.is_a?(String) 40 | end 41 | 42 | while joins.first.is_a?(Arel::Nodes::Join) 43 | join_node = joins.shift 44 | if !join_node.is_a?(Arel::Nodes::LeadingJoin) && (stashed_eager_load || stashed_left_joins) 45 | buckets[:join_node] << join_node 46 | else 47 | buckets[:leading_join] << join_node 48 | end 49 | end 50 | 51 | buckets[:named_join] = select_named_joins(joins, buckets[:stashed_join]) do |join| 52 | if join.is_a?(Arel::Nodes::Join) 53 | buckets[:join_node] << join 54 | elsif join.is_a?(ActiveRecord::QueryMethods::CTEJoin) 55 | buckets[:join_node] << build_with_join_node(join.name) 56 | else 57 | raise "unknown class: %s" % join.class.name 58 | end 59 | end 60 | 61 | buckets[:stashed_join].concat stashed_left_joins if stashed_left_joins 62 | buckets[:stashed_join] << stashed_eager_load if stashed_eager_load 63 | 64 | return buckets, Arel::Nodes::InnerJoin 65 | end 66 | 67 | end -------------------------------------------------------------------------------- /lib/active_record/filter/relation_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::Filter::RelationExtension 4 | 5 | def initialize(*, **) 6 | @filters = [] 7 | super 8 | end 9 | 10 | def initialize_copy(other) 11 | @filters = @filters.deep_dup 12 | super 13 | end 14 | 15 | def clean_filters(value) 16 | if value.class.name == 'ActionController::Parameters'.freeze 17 | value.to_unsafe_h 18 | elsif value.is_a?(Array) 19 | value.map { |v| clean_filters(v) } 20 | else 21 | value 22 | end 23 | end 24 | 25 | def filter(filters) 26 | filters = clean_filters(filters) 27 | 28 | if filters.nil? || filters.empty? 29 | self 30 | else 31 | spawn.filter!(filters) 32 | end 33 | end 34 | 35 | def filter!(filters) 36 | js = ActiveRecord::PredicateBuilder.filter_joins(klass, filters) 37 | js.flatten.each do |j| 38 | if j.is_a?(String) 39 | joins!(j) 40 | elsif j.is_a?(Arel::Nodes::Join) 41 | joins!(j) 42 | elsif j.present? 43 | left_outer_joins!(j) 44 | end 45 | end 46 | @filters << filters 47 | self 48 | end 49 | 50 | def filter_clause_factory 51 | @filter_clause_factory ||= ActiveRecord::Filter::FilterClauseFactory.new(klass, predicate_builder) 52 | end 53 | 54 | if ActiveRecord.version >= "7.2" 55 | def build_arel(connection, aliases = nil) 56 | arel = super 57 | my_alias_tracker = ActiveRecord::Associations::AliasTracker.create(model.connection_pool, table.name, []) 58 | build_filters(arel, my_alias_tracker) 59 | arel 60 | end 61 | else 62 | def build_arel(aliases = nil) 63 | arel = super 64 | my_alias_tracker = ActiveRecord::Associations::AliasTracker.create(connection, table.name, []) 65 | build_filters(arel, my_alias_tracker) 66 | arel 67 | end 68 | end 69 | 70 | def build_filters(manager, alias_tracker) 71 | @filters.each do |filters| 72 | manager.where(filter_clause_factory.build(filters, alias_tracker).ast) 73 | end 74 | end 75 | 76 | end -------------------------------------------------------------------------------- /lib/active_record/filter/spawn_methods_extension.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord::Filter::SpawnMethodsExtension 4 | 5 | def except(*skips) 6 | r = relation_with values.except(*skips) 7 | if !skips.include?(:where) 8 | r.instance_variable_set(:@filters, instance_variable_get(:@filters)) 9 | end 10 | r 11 | end 12 | 13 | end -------------------------------------------------------------------------------- /lib/active_record/filter/unkown_filter_error.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ActiveRecord::UnkownFilterError < NoMethodError 4 | end -------------------------------------------------------------------------------- /lib/active_record/filter/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ActiveRecord 4 | module Filter 5 | VERSION = '8.0.0' 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /test/database.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.establish_connection({ 2 | adapter: "postgis", 3 | database: "activerecord-filter-test", 4 | encoding: "utf8" 5 | }) 6 | 7 | db_config = ActiveRecord::Base.connection_db_config 8 | task = ActiveRecord::Tasks::PostgreSQLDatabaseTasks.new(db_config) 9 | task.drop 10 | task.create 11 | 12 | ActiveRecord::Migration.suppress_messages do 13 | ActiveRecord::Schema.define do 14 | 15 | create_table "accounts", force: :cascade do |t| 16 | t.string "name", limit: 255 17 | t.integer 'photos_count', null: false, default: 0 18 | end 19 | 20 | create_table "photos", force: :cascade do |t| 21 | t.integer "account_id" 22 | t.integer "property_id" 23 | t.string "format", limit: 255 24 | end 25 | 26 | create_table "properties", force: :cascade do |t| 27 | t.string "name", limit: 255 28 | t.string "aliases", default: [], array: true 29 | t.text "description" 30 | t.integer "constructed" 31 | t.decimal "size" 32 | # t.json "amenities", default: {}, null: false 33 | t.datetime "created_at", null: false 34 | # t.geometry "location", limit: {:type=>"Point", :srid=>"4326"} 35 | t.boolean "active", default: false 36 | end 37 | 38 | create_table "regions", force: :cascade do |t| 39 | end 40 | 41 | create_table "properties_regions", id: false, force: :cascade do |t| 42 | t.integer "property_id", null: false 43 | t.integer "region_id", null: false 44 | end 45 | 46 | create_table "regions_regions", id: false, force: :cascade do |t| 47 | t.integer "parent_id", null: false 48 | t.integer "child_id", null: false 49 | end 50 | 51 | create_table "views", force: :cascade do |t| 52 | t.string "subject_type" 53 | t.integer "subject_id" 54 | end 55 | 56 | end 57 | end 58 | 59 | class Account < ActiveRecord::Base 60 | 61 | has_many :photos 62 | 63 | end 64 | 65 | class Photo < ActiveRecord::Base 66 | 67 | belongs_to :account, :counter_cache => true 68 | has_and_belongs_to_many :properties 69 | 70 | end 71 | 72 | class View < ActiveRecord::Base 73 | belongs_to :subject, polymorphic: true 74 | end 75 | 76 | class Property < ActiveRecord::Base 77 | 78 | has_many :photos 79 | 80 | has_and_belongs_to_many :regions 81 | 82 | filter_on :state, ->(v) { 83 | filter(:name => v.upcase) 84 | } 85 | 86 | end 87 | 88 | class Region < ActiveRecord::Base 89 | 90 | has_and_belongs_to_many :properties 91 | has_and_belongs_to_many :parents, :join_table => 'regions_regions', :class_name => 'Region', :foreign_key => 'child_id', :association_foreign_key => 'parent_id' 92 | has_and_belongs_to_many :children, :join_table => 'regions_regions', :class_name => 'Region', :foreign_key => 'parent_id', :association_foreign_key => 'child_id' 93 | 94 | end -------------------------------------------------------------------------------- /test/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryGirl.define do 2 | 3 | factory :account do 4 | name { Faker::Name.name } 5 | end 6 | 7 | factory :photo do 8 | format { ['jpg', 'png', 'tiff'].sample } 9 | end 10 | 11 | factory :property do 12 | name { Faker::Lorem.words(Kernel.rand(1..4)).join(' ') } 13 | description { Faker::Lorem.paragraphs.join("\n\n") } 14 | constructed { Kernel.rand(1800..(Time.now.year - 2)) } 15 | size { Kernel.rand(1000..10000000).to_f / 100 } 16 | active { [true, false].sample } 17 | end 18 | 19 | factory :region do 20 | 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /test/filter_column_test/array_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class ArrayColumnFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "properties", force: :cascade do |t| 7 | t.string "aliases", default: [], array: true 8 | t.integer "region_ids", default: [], array: true 9 | end 10 | end 11 | 12 | class Property < ActiveRecord::Base 13 | has_many :regions 14 | end 15 | 16 | test "::filter :string_array_column => STRING" do 17 | query = Property.filter(aliases: 'Skyscraper 1') 18 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 19 | SELECT properties.* 20 | FROM properties 21 | WHERE properties.aliases = '{Skyscraper 1}' 22 | SQL 23 | end 24 | 25 | test "::filter :string_array_column => [STRING, STRING]" do 26 | query = Property.filter(aliases: ['Skyscraper 1', 'Skyscraper 2']) 27 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 28 | SELECT properties.* 29 | FROM properties 30 | WHERE properties.aliases = '{Skyscraper 1,Skyscraper 2}' 31 | SQL 32 | end 33 | 34 | test "::filter :string_array_column => {contains: STRING}" do 35 | query = Property.filter(aliases: {contains: 'Skyscraper 1'}) 36 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 37 | SELECT properties.* 38 | FROM properties 39 | WHERE properties.aliases @> '{Skyscraper 1}' 40 | SQL 41 | end 42 | 43 | test "::filter :string_array_column => {contains: [STRING, STRING]}" do 44 | query = Property.filter(aliases: {contains: ['Skyscraper 1']}) 45 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 46 | SELECT properties.* 47 | FROM properties 48 | WHERE properties.aliases @> '{Skyscraper 1}' 49 | SQL 50 | end 51 | 52 | test "::filter :string_array_column => {contained_by: STRING}" do 53 | query = Property.filter(aliases: {contained_by: 'Skyscraper 1'}) 54 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 55 | SELECT properties.* 56 | FROM properties 57 | WHERE properties.aliases <@ '{Skyscraper 1}' 58 | SQL 59 | end 60 | 61 | test "::filter :string_array_column => {contained_by: [STRING, STRING]}" do 62 | query = Property.filter(aliases: {contained_by: ['Skyscraper 1', 'Skyscraper']}) 63 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 64 | SELECT properties.* 65 | FROM properties 66 | WHERE properties.aliases <@ '{Skyscraper 1,Skyscraper}' 67 | SQL 68 | end 69 | 70 | test "::filter :string_array_column => {overlaps: [STRING, STRING]}" do 71 | query = Property.filter(aliases: {overlaps: ['Skyscraper 2', 'Skyscraper']}) 72 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 73 | SELECT properties.* 74 | FROM properties 75 | WHERE properties.aliases && '{Skyscraper 2,Skyscraper}' 76 | SQL 77 | end 78 | 79 | test "::filter :string_array_column => {contains: [STRING]}" do 80 | query = Property.filter(aliases: {contains: ['Skyscraper']}) 81 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 82 | SELECT properties.* 83 | FROM properties 84 | WHERE properties.aliases @> '{Skyscraper}' 85 | SQL 86 | end 87 | 88 | test "::filter :string_array_column => {excludes: STRING}" do 89 | query = Property.filter(aliases: {excludes: 'Skyscraper'}) 90 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 91 | SELECT properties.* 92 | FROM properties 93 | WHERE NOT (properties.aliases @> '{Skyscraper}') 94 | SQL 95 | end 96 | 97 | test "::filter :string_array_column => {excludes: [STRING]}" do 98 | query = Property.filter(aliases: {excludes: ['Skyscraper']}) 99 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 100 | SELECT properties.* 101 | FROM properties 102 | WHERE NOT (properties.aliases @> '{Skyscraper}') 103 | SQL 104 | end 105 | 106 | test "::filter :int_array_column => {overlaps: [INT]}" do 107 | query = Property.filter(region_ids: {overlaps: [10]}) 108 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 109 | SELECT properties.* 110 | FROM properties 111 | WHERE properties.region_ids && '{10}' 112 | SQL 113 | end 114 | 115 | end -------------------------------------------------------------------------------- /test/filter_column_test/boolean_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BooleanFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "properties", force: :cascade do |t| 7 | t.boolean "active", default: false 8 | end 9 | end 10 | 11 | class Property < ActiveRecord::Base 12 | end 13 | 14 | test "::filter :boolean_column => boolean" do 15 | query = Property.filter(active: true) 16 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 17 | SELECT properties.* 18 | FROM properties 19 | WHERE properties.active = TRUE 20 | SQL 21 | 22 | query = Property.filter(active: "true") 23 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 24 | SELECT properties.* 25 | FROM properties 26 | WHERE properties.active = TRUE 27 | SQL 28 | 29 | 30 | query = Property.filter(active: false) 31 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 32 | SELECT properties.* 33 | FROM properties 34 | WHERE properties.active = FALSE 35 | SQL 36 | 37 | query = Property.filter(active: "false") 38 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 39 | SELECT properties.* 40 | FROM properties 41 | WHERE properties.active = FALSE 42 | SQL 43 | end 44 | 45 | test "::filter :boolean_column => nil" do 46 | query = Property.filter(active: nil) 47 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 48 | SELECT properties.* 49 | FROM properties 50 | WHERE properties.active IS NULL 51 | SQL 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /test/filter_column_test/datetime_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class DatetimeFilterTest < ActiveSupport::TestCase 4 | schema do 5 | create_table "properties", force: :cascade do |t| 6 | t.datetime "created_at", null: false 7 | end 8 | end 9 | 10 | class Property < ActiveRecord::Base 11 | end 12 | 13 | def format_time(value) 14 | value.utc.iso8601(6).sub(/T/, ' ').sub(/Z$/, '') 15 | end 16 | 17 | test "::filter :datetime_column => {:gt => date, :lt => date}" do 18 | t1 = 5.days.ago 19 | t2 = 4.days.ago 20 | t3 = 1.day.ago 21 | t4 = 1.day.from_now 22 | 23 | query = Property.filter(created_at: {gte: t2, lte: t3}) 24 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 25 | SELECT properties.* 26 | FROM properties 27 | WHERE properties.created_at >= '#{format_time(t2)}' 28 | AND properties.created_at <= '#{format_time(t3)}' 29 | SQL 30 | 31 | 32 | query = Property.filter(:created_at => {gt: t1, lt: t2}) 33 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 34 | SELECT properties.* 35 | FROM properties 36 | WHERE properties.created_at > '#{format_time(t1)}' 37 | AND properties.created_at < '#{format_time(t2)}' 38 | SQL 39 | 40 | query = Property.filter(:created_at => {gte: t3, lte: t4}) 41 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 42 | SELECT properties.* 43 | FROM properties 44 | WHERE properties.created_at >= '#{format_time(t3)}' 45 | AND properties.created_at <= '#{format_time(t4)}' 46 | SQL 47 | end 48 | 49 | test "::filter :datetime_column => date" do 50 | time = Time.now 51 | 52 | query = Property.filter(created_at: time) 53 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 54 | SELECT properties.* 55 | FROM properties 56 | WHERE properties.created_at = '#{format_time(time)}' 57 | SQL 58 | end 59 | 60 | end 61 | -------------------------------------------------------------------------------- /test/filter_column_test/geometry_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class GeometryColumnFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | enable_extension 'postgis' 7 | create_table "properties", force: :cascade do |t| 8 | t.geometry "geo" 9 | end 10 | end 11 | 12 | class Property < ActiveRecord::Base 13 | has_many :regions 14 | end 15 | 16 | test "::filter :geometry_column => EWKT" do 17 | query = Property.filter(geo: 'POINT (28.182869232095754 11.073276002261096)') 18 | assert_sql(<<-SQL, query) 19 | SELECT properties.* 20 | FROM properties 21 | WHERE properties.geo = ST_SetSRID(ST_GeomFromText('POINT (28.182869232095754 11.073276002261096)'), 4326) 22 | SQL 23 | end 24 | 25 | test "::filter :geometry_column => unencoded EWKB" do 26 | query = Property.filter(geo: "\x01\x01\x00\x00\x00\xC0K\x9B\x84\xD0.<@\b\x96\xA2n\x84%&@") 27 | assert_sql(<<-SQL, query) 28 | SELECT properties.* 29 | FROM properties 30 | WHERE properties.geo = ST_SetSRID(ST_GeomFromEWKB('\\x0101000000c04b9b84d02e3c400896a26e84252640'), 4326) 31 | SQL 32 | end 33 | 34 | test "::filter :geometry_column => hex encoded EWKB" do 35 | query = Property.filter(geo: "0101000000c04b9b84d02e3c400896a26e84252640") 36 | assert_sql(<<-SQL, query) 37 | SELECT properties.* 38 | FROM properties 39 | WHERE properties.geo = ST_SetSRID(ST_GeomFromEWKB('\\x0101000000c04b9b84d02e3c400896a26e84252640'), 4326) 40 | SQL 41 | end 42 | 43 | test "::filter :geometry_column => {equals: geoJSON}" do 44 | query = Property.filter(geo: {eq: {"type":"Point","coordinates":[28.182869232095754,11.073276002261096]}}) 45 | assert_sql(<<-SQL, query) 46 | SELECT properties.* 47 | FROM properties 48 | WHERE ST_Equals(properties.geo, ST_SetSRID(ST_GeomFromGeoJSON('{type:Point,coordinates:[28.182869232095754,11.073276002261096]}'), 4326)) 49 | SQL 50 | end 51 | 52 | test "::filter :geometry_column => [EWKT, EWKB, hex EWKB, geoJSON]" do 53 | query = Property.filter(geo: [ 54 | 'POINT (28.182869232095754 11.073276002261096)', 55 | "\x01\x01\x00\x00\x00\xC0K\x9B\x84\xD0.<@\b\x96\xA2n\x84%&@", 56 | "0101000000c04b9b84d02e3c400896a26e84252640", 57 | {"type":"Point","coordinates":[28.182869232095754,11.073276002261096]} 58 | ]) 59 | assert_sql(<<-SQL, query) 60 | SELECT properties.* 61 | FROM properties 62 | WHERE properties.geo IN ( 63 | ST_SetSRID(ST_GeomFromText('POINT (28.182869232095754 11.073276002261096)'), 4326), 64 | ST_SetSRID(ST_GeomFromEWKB('\\x0101000000c04b9b84d02e3c400896a26e84252640'), 4326), 65 | ST_SetSRID(ST_GeomFromEWKB('\\x0101000000c04b9b84d02e3c400896a26e84252640'), 4326), 66 | ST_SetSRID(ST_GeomFromGeoJSON('{"type":"Point","coordinates":[28.182869232095754,11.073276002261096]}'), 4326) 67 | ) 68 | SQL 69 | end 70 | 71 | test "::filter geometry_column: {contians: EWKT}" do 72 | query = Property.filter(geo: {contains: 'POINT (28.182869232095754 11.073276002261096)'}) 73 | assert_sql(<<-SQL, query) 74 | SELECT properties.* 75 | FROM properties 76 | WHERE ST_Contains(properties.geo, ST_SetSRID(ST_GeomFromText('POINT (28.182869232095754 11.073276002261096)'), 4326)) 77 | SQL 78 | end 79 | 80 | test "::filter geometry_column: {within: EWKT}" do 81 | query = Property.filter(geo: {within: 'POINT (28.182869232095754 11.073276002261096)'}) 82 | assert_sql(<<-SQL, query) 83 | SELECT properties.* 84 | FROM properties 85 | WHERE ST_Within(properties.geo, ST_SetSRID(ST_GeomFromText('POINT (28.182869232095754 11.073276002261096)'), 4326)) 86 | SQL 87 | end 88 | 89 | test "::filter geometry_column: { overlaps: EWKT }" do 90 | query = Property.filter(geo: { overlaps: 'POINT (28.182869232095754 11.073276002261096)' }) 91 | assert_sql(<<-SQL, query) 92 | SELECT properties.* 93 | FROM properties 94 | WHERE properties.geo && ST_SetSRID(ST_GeomFromText('POINT (28.182869232095754 11.073276002261096)'), 4326) 95 | SQL 96 | end 97 | 98 | end 99 | -------------------------------------------------------------------------------- /test/filter_column_test/integer_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class IntegerFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "properties", force: :cascade do |t| 7 | t.integer "constructed" 8 | end 9 | end 10 | 11 | class Property < ActiveRecord::Base 12 | end 13 | 14 | test "::filter :integer_column => {:gt => x}" do 15 | query = Property.filter(constructed: { gt: 1 }) 16 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 17 | SELECT properties.* 18 | FROM properties 19 | WHERE properties.constructed > 1 20 | SQL 21 | 22 | query = Property.filter(constructed: { greater_than: 1 }) 23 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 24 | SELECT properties.* 25 | FROM properties 26 | WHERE properties.constructed > 1 27 | SQL 28 | end 29 | 30 | test "::filter :integer_column => {:gteq => x}" do 31 | query = Property.filter(constructed: { gte: 1 }) 32 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 33 | SELECT properties.* 34 | FROM properties 35 | WHERE properties.constructed >= 1 36 | SQL 37 | 38 | query = Property.filter(constructed: { gteq: 1 }) 39 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 40 | SELECT properties.* 41 | FROM properties 42 | WHERE properties.constructed >= 1 43 | SQL 44 | 45 | query = Property.filter(constructed: { greater_than_or_equal_to: 1 }) 46 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 47 | SELECT properties.* 48 | FROM properties 49 | WHERE properties.constructed >= 1 50 | SQL 51 | end 52 | 53 | test "::filter :integer_column => {:lt => x}" do 54 | query = Property.filter(constructed: { lt: 1 }) 55 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 56 | SELECT properties.* 57 | FROM properties 58 | WHERE properties.constructed < 1 59 | SQL 60 | 61 | query = Property.filter(constructed: { less_than: 1 }) 62 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 63 | SELECT properties.* 64 | FROM properties 65 | WHERE properties.constructed < 1 66 | SQL 67 | end 68 | 69 | test "::filter :integer_column => {:lteq => x}" do 70 | query = Property.filter(constructed: { lte: 1 }) 71 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 72 | SELECT properties.* 73 | FROM properties 74 | WHERE properties.constructed <= 1 75 | SQL 76 | 77 | query = Property.filter(constructed: { lteq: 1 }) 78 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 79 | SELECT properties.* 80 | FROM properties 81 | WHERE properties.constructed <= 1 82 | SQL 83 | 84 | query = Property.filter(constructed: { less_than_or_equal_to: 1 }) 85 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 86 | SELECT properties.* 87 | FROM properties 88 | WHERE properties.constructed <= 1 89 | SQL 90 | end 91 | 92 | test "::filter :integer_column => int " do 93 | query = Property.filter(constructed: 1) 94 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 95 | SELECT properties.* 96 | FROM properties 97 | WHERE properties.constructed = 1 98 | SQL 99 | end 100 | 101 | test "::filter :integer_column => str" do 102 | query = Property.filter(constructed: '1') 103 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 104 | SELECT properties.* 105 | FROM properties 106 | WHERE properties.constructed = 1 107 | SQL 108 | end 109 | 110 | test "::filter :integer_column => bool " do 111 | query = Property.filter(constructed: true) 112 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 113 | SELECT properties.* 114 | FROM properties 115 | WHERE properties.constructed IS NOT NULL 116 | SQL 117 | 118 | query = Property.filter(constructed: "true") 119 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 120 | SELECT properties.* 121 | FROM properties 122 | WHERE properties.constructed IS NOT NULL 123 | SQL 124 | 125 | query = Property.filter(constructed: false) 126 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 127 | SELECT properties.* 128 | FROM properties 129 | WHERE properties.constructed IS NULL 130 | SQL 131 | 132 | query = Property.filter(constructed: "false") 133 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 134 | SELECT properties.* 135 | FROM properties 136 | WHERE properties.constructed IS NULL 137 | SQL 138 | end 139 | 140 | end 141 | -------------------------------------------------------------------------------- /test/filter_column_test/json_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class JsonFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "properties", force: :cascade do |t| 7 | t.jsonb 'metadata' 8 | end 9 | end 10 | 11 | class Property < ActiveRecord::Base 12 | end 13 | 14 | test "::filter json_column: STRING throws an error" do 15 | assert_raises(ActiveRecord::UnkownFilterError) do 16 | Property.filter(metadata: 'string').load 17 | end 18 | end 19 | 20 | test "::filter json_column: {eq: JSON_HASH}" do 21 | query = Property.filter(metadata: {eq: {json: 'string'}}) 22 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 23 | SELECT "properties".* 24 | FROM "properties" 25 | WHERE "properties"."metadata" = '{\"json\":\"string\"}' 26 | SQL 27 | end 28 | 29 | test "::filter json_column: {contains: JSON_HASH}" do 30 | query = Property.filter(metadata: {contains: {json: 'string'}}) 31 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 32 | SELECT "properties".* 33 | FROM "properties" 34 | WHERE "properties"."metadata" @> '{\"json\":\"string\"}' 35 | SQL 36 | end 37 | 38 | test "::filter json_column: {contained_by: JSON_HASH}" do 39 | query = Property.filter(metadata: {contained_by: {json: 'string'}}) 40 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 41 | SELECT "properties".* 42 | FROM "properties" 43 | WHERE "properties"."metadata" <@ '{\"json\":\"string\"}' 44 | SQL 45 | end 46 | 47 | test "::filter json_column: {has_key: STRING}" do 48 | query = Property.filter(metadata: {has_key: 'string'}) 49 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 50 | SELECT "properties".* 51 | FROM "properties" 52 | WHERE "properties"."metadata" ? 'string' 53 | SQL 54 | end 55 | 56 | test "::filter json_column.subkey: {eq: JSON_HASH}" do 57 | query = Property.filter("metadata.subkey" => {eq: 'string'}) 58 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 59 | SELECT "properties".* 60 | FROM "properties" 61 | WHERE "properties"."metadata"#>'{subkey}' = 'string' 62 | SQL 63 | end 64 | 65 | test "::filter json_column: BOOLEAN" do 66 | query = Property.filter(metadata: true) 67 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 68 | SELECT "properties".* 69 | FROM "properties" 70 | WHERE "properties"."metadata" IS NOT NULL 71 | SQL 72 | 73 | query = Property.filter(metadata: "true") 74 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 75 | SELECT "properties".* 76 | FROM "properties" 77 | WHERE "properties"."metadata" IS NOT NULL 78 | SQL 79 | 80 | query = Property.filter(metadata: false) 81 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 82 | SELECT "properties".* 83 | FROM "properties" 84 | WHERE "properties"."metadata" IS NULL 85 | SQL 86 | 87 | query = Property.filter(metadata: "false") 88 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip) 89 | SELECT "properties".* 90 | FROM "properties" 91 | WHERE "properties"."metadata" IS NULL 92 | SQL 93 | end 94 | 95 | end 96 | -------------------------------------------------------------------------------- /test/filter_column_test/string_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class StringFilterTest < ActiveSupport::TestCase 4 | schema do 5 | create_table "properties", force: :cascade do |t| 6 | t.string "name", limit: 255 7 | end 8 | end 9 | 10 | class Property < ActiveRecord::Base 11 | end 12 | 13 | test "::filter :string_column => string" do 14 | query = Property.filter(name: 'b') 15 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 16 | SELECT properties.* 17 | FROM properties 18 | WHERE properties.name = 'b' 19 | SQL 20 | end 21 | 22 | test "::filter :string_column => nil" do 23 | query = Property.filter(name: nil) 24 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 25 | SELECT properties.* 26 | FROM properties 27 | WHERE properties.name IS NULL 28 | SQL 29 | end 30 | 31 | test "::filter :string_column => boolean" do 32 | query = Property.filter(name: true) 33 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 34 | SELECT properties.* 35 | FROM properties 36 | WHERE properties.name IS NOT NULL 37 | SQL 38 | 39 | query = Property.filter(name: false) 40 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 41 | SELECT properties.* 42 | FROM properties 43 | WHERE properties.name IS NULL 44 | SQL 45 | end 46 | 47 | test "::filter :string_column => {:not => STRING}" do 48 | query = Property.filter(name: {not: 'b'}) 49 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 50 | SELECT properties.* 51 | FROM properties 52 | WHERE properties.name != 'b' 53 | SQL 54 | 55 | query = Property.filter(name: {not_equal: 'b'}) 56 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 57 | SELECT properties.* 58 | FROM properties 59 | WHERE properties.name != 'b' 60 | SQL 61 | 62 | query = Property.filter(name: {neq: 'b'}) 63 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 64 | SELECT properties.* 65 | FROM properties 66 | WHERE properties.name != 'b' 67 | SQL 68 | end 69 | 70 | test "::filter :array_column => {:not_in => [STRING, STRING]}" do 71 | query = Property.filter(name: {not_in: ['b', 'c']}) 72 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 73 | SELECT properties.* 74 | FROM properties 75 | WHERE properties.name NOT IN ('b', 'c') 76 | SQL 77 | end 78 | 79 | test "::filter array_column: {like: STRING}" do 80 | query = Property.filter(name: {like: 'nam%'}) 81 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 82 | SELECT properties.* 83 | FROM properties 84 | WHERE properties.name LIKE 'nam%' 85 | SQL 86 | end 87 | 88 | test "::filter array_column: {ilike: STRING}" do 89 | query = Property.filter(name: {ilike: 'nam%'}) 90 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 91 | SELECT properties.* 92 | FROM properties 93 | WHERE properties.name ILIKE 'nam%' 94 | SQL 95 | end 96 | 97 | end -------------------------------------------------------------------------------- /test/filter_relationship_test/belongs_to_polymorphic_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BelongsToPolymorphicFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "views", force: :cascade do |t| 7 | t.string "subject_type" 8 | t.integer "subject_id" 9 | t.integer "account_id" 10 | end 11 | 12 | create_table "accounts" do |t| 13 | t.string "name", limit: 255 14 | end 15 | 16 | create_table "properties" do |t| 17 | t.string "name", limit: 255 18 | t.integer "account_id" 19 | t.integer "region_id" 20 | t.string "region_type" 21 | end 22 | 23 | create_table "countries" do |t| 24 | t.string "name", limit: 255 25 | end 26 | end 27 | 28 | class View < ActiveRecord::Base 29 | belongs_to :subject, polymorphic: true 30 | belongs_to :account 31 | end 32 | 33 | class Account < ActiveRecord::Base 34 | belongs_to :friend, class_name: 'BelongsToPolymorphicFilterTest::Account' 35 | belongs_to :other_friend, class_name: 'BelongsToPolymorphicFilterTest::Account' 36 | end 37 | 38 | class Property < ActiveRecord::Base 39 | belongs_to :account 40 | belongs_to :region, polymorphic: true 41 | end 42 | 43 | class Country < ActiveRecord::Base 44 | has_many :properties 45 | end 46 | 47 | test "::filter :belongs_to => {ID: VALUE}" do 48 | query = View.filter(subject: {as: "BelongsToPolymorphicFilterTest::Property", name: 'Name'}) 49 | assert_sql(<<-SQL, query) 50 | SELECT views.* 51 | FROM views 52 | LEFT OUTER JOIN properties 53 | ON properties.id = views.subject_id AND views.subject_type = 'BelongsToPolymorphicFilterTest::Property' 54 | WHERE properties.name = 'Name' 55 | SQL 56 | end 57 | 58 | test '::filter with seperate joins' do 59 | query = View.filter(subject: {as: "BelongsToPolymorphicFilterTest::Property", name: 'Name'}, account: {name: 'Account'}) 60 | assert_sql(<<-SQL, query) 61 | SELECT views.* FROM views 62 | LEFT OUTER JOIN accounts 63 | ON accounts.id = views.account_id 64 | LEFT OUTER JOIN properties 65 | ON properties.id = views.subject_id AND views.subject_type = 'BelongsToPolymorphicFilterTest::Property' 66 | WHERE properties.name = 'Name' AND accounts.name = 'Account' 67 | SQL 68 | end 69 | 70 | test '::filter beyond polymorphic boundary' do 71 | query = View.filter({ 72 | subject: { 73 | as: "BelongsToPolymorphicFilterTest::Property", 74 | account: {name: 'Name'} 75 | } 76 | }) 77 | 78 | assert_sql(<<-SQL, query) 79 | SELECT views.* FROM views 80 | LEFT OUTER JOIN properties 81 | ON properties.id = views.subject_id 82 | AND views.subject_type = 'BelongsToPolymorphicFilterTest::Property' 83 | LEFT OUTER JOIN accounts 84 | ON accounts.id = properties.account_id 85 | WHERE accounts.name = 'Name' 86 | SQL 87 | end 88 | 89 | test '::filter nested polymorphic' do 90 | query = View.filter({ 91 | subject: { 92 | as: "BelongsToPolymorphicFilterTest::Property", 93 | region: { 94 | as: 'BelongsToPolymorphicFilterTest::Country', 95 | name: 'USA' 96 | } 97 | } 98 | }) 99 | 100 | assert_sql(<<-SQL, query) 101 | SELECT views.* FROM views 102 | LEFT OUTER JOIN properties 103 | ON properties.id = views.subject_id 104 | AND views.subject_type = 'BelongsToPolymorphicFilterTest::Property' 105 | LEFT OUTER JOIN countries 106 | ON countries.id = properties.region_id 107 | AND properties.region_type = 'BelongsToPolymorphicFilterTest::Country' 108 | WHERE countries.name = 'USA' 109 | SQL 110 | end 111 | 112 | test '::filter beyond polymorphic boundary with the same table twice' do 113 | query = View.filter({ 114 | subject: { 115 | as: "BelongsToPolymorphicFilterTest::Account", 116 | friend: {name: 'Name'}, 117 | other_friend: {name: 'Name2'} 118 | } 119 | }) 120 | 121 | assert_sql(<<-SQL, query) 122 | SELECT views.* FROM views 123 | LEFT OUTER JOIN accounts 124 | ON accounts.id = views.subject_id 125 | AND views.subject_type = 'BelongsToPolymorphicFilterTest::Account' 126 | LEFT OUTER JOIN accounts friends_accounts 127 | ON friends_accounts.id = accounts.friend_id 128 | LEFT OUTER JOIN accounts other_friends_accounts 129 | ON other_friends_accounts.id = accounts.other_friend_id 130 | WHERE 131 | friends_accounts.name = 'Name' 132 | AND other_friends_accounts.name = 'Name2' 133 | SQL 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/filter_relationship_test/belongs_to_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class BelongsToFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "accounts", force: :cascade do |t| 7 | t.string "name", limit: 255 8 | t.integer 'photos_count', null: false, default: 0 9 | end 10 | 11 | create_table "photos", force: :cascade do |t| 12 | t.integer "account_id" 13 | t.integer "property_id" 14 | t.string "format", limit: 255 15 | end 16 | end 17 | 18 | class Account < ActiveRecord::Base 19 | has_many :photos 20 | end 21 | 22 | class Photo < ActiveRecord::Base 23 | belongs_to :account, counter_cache: true 24 | end 25 | 26 | test "::filter :belongs_to => BOOL" do 27 | query = Photo.filter(account: true) 28 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 29 | SELECT photos.* 30 | FROM photos 31 | WHERE photos.account_id IS NOT NULL 32 | SQL 33 | 34 | query = Photo.filter(account: "true") 35 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 36 | SELECT photos.* 37 | FROM photos 38 | WHERE photos.account_id IS NOT NULL 39 | SQL 40 | 41 | query = Photo.filter(account: false) 42 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 43 | SELECT photos.* 44 | FROM photos 45 | WHERE photos.account_id IS NULL 46 | SQL 47 | 48 | query = Photo.filter(account: "false") 49 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 50 | SELECT photos.* 51 | FROM photos 52 | WHERE photos.account_id IS NULL 53 | SQL 54 | end 55 | 56 | test "::filter :belongs_to => NIL" do 57 | query = Photo.filter(account: nil) 58 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 59 | SELECT photos.* 60 | FROM photos 61 | WHERE photos.account_id IS NULL 62 | SQL 63 | end 64 | 65 | test "::filter :belongs_to => FILTER" do 66 | query = Photo.filter(account: {name: 'Minx'}) 67 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 68 | SELECT photos.* 69 | FROM photos 70 | LEFT OUTER JOIN accounts ON accounts.id = photos.account_id 71 | WHERE accounts.name = 'Minx' 72 | SQL 73 | end 74 | 75 | test "::filter :belonts_to with OR" do 76 | query = Photo.filter([ 77 | {account: {name: 'Batman'}}, 78 | 'OR', 79 | {account: {name: 'Robin'}} 80 | ]) 81 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 82 | SELECT photos.* 83 | FROM photos 84 | LEFT OUTER JOIN accounts ON accounts.id = photos.account_id 85 | WHERE ((accounts.name = 'Batman') OR (accounts.name = 'Robin')) 86 | SQL 87 | end 88 | 89 | # test "::filter on model and belongs_to_association" do 90 | # a1 = create(:property, photos_count: 1) 91 | # a2 = create(:property, photos_count: 3) 92 | # a3 = create(:property, photos_count: 1) 93 | # a4 = create(:property, photos_count: 3) 94 | # l1 = create(:listing, :property => a1) 95 | # l2 = create(:listing, :property => a2) 96 | # l3 = create(:listing, :property => a3, :authorized => false) 97 | # l4 = create(:listing, :property => a4, :authorized => false) 98 | # 99 | # assert_equal [l2], Listing.filter(:authorized => true, :property => { photos_count: { :gteq => 2 }}) 100 | # assert_equal [l1, l2], Listing.filter(:authorized => true, :property => { photos_count: { :gteq => 1 }}).order(:id) 101 | # end 102 | 103 | # test "::filter :belongs_to_association => { :boolean_column => boolean } " do 104 | # a1 = create(:property, photos_count: 1) 105 | # a2 = create(:property, photos_count: 0) 106 | # l1 = create(:listing, :property => a1) 107 | # l2 = create(:listing, :property => a2) 108 | # 109 | # assert_equal [l1], Listing.filter(:property => { :photos => true }) 110 | # assert_equal [l2], Listing.filter(:property => { :photos => false }) 111 | # end 112 | # 113 | # test "::filter belongs_to_association with lambda" do 114 | # a1 = create(:property, addresses: [create(:address, location: 'POINT(0 0)')]).address 115 | # a2 = create(:property, addresses: [create(:address, location: 'POINT(5 5)')]).address 116 | # assert_equal [a1], Address.filter(:property => { :bounds => [1, 1, -1, -1] }) 117 | # end 118 | 119 | 120 | end 121 | -------------------------------------------------------------------------------- /test/filter_relationship_test/has_and_belongs_to_many_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HABTMTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "properties", force: :cascade do |t| 7 | t.string "name", limit: 255 8 | end 9 | 10 | create_table "regions", force: :cascade do |t| 11 | t.string 'name', limit: 255 12 | end 13 | 14 | create_table "properties_regions", id: false, force: :cascade do |t| 15 | t.integer "property_id", null: false 16 | t.integer "region_id", null: false 17 | end 18 | 19 | create_table "regions_regions", id: false, force: :cascade do |t| 20 | t.integer "parent_id", null: false 21 | t.integer "child_id", null: false 22 | end 23 | end 24 | 25 | class Property < ActiveRecord::Base 26 | has_and_belongs_to_many :regions 27 | end 28 | 29 | class Region < ActiveRecord::Base 30 | has_and_belongs_to_many :properties 31 | has_and_belongs_to_many :parents, join_table: 'regions_regions', class_name: 'Region', foreign_key: 'child_id', association_foreign_key: 'parent_id' 32 | has_and_belongs_to_many :children, join_table: 'regions_regions', class_name: 'Region', foreign_key: 'parent_id', association_foreign_key: 'child_id' 33 | end 34 | 35 | # test '::filter :habtm => INT' do 36 | # r1 = create(:region) 37 | # r2 = create(:region) 38 | # r3 = create(:region) 39 | # p1 = create(:property) 40 | # p2 = create(:property, :regions => [r1, r3]) 41 | # 42 | # assert_equal [p2], Property.filter(:regions => r1.id) 43 | # assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), Property.filter(:regions => 1).to_sql.strip.gsub('"', '')) 44 | # SELECT properties.* FROM properties 45 | # INNER JOIN properties_regions ON properties_regions.property_id = properties.id 46 | # WHERE properties_regions.region_id = 1 47 | # SQL 48 | # end 49 | 50 | # test '::filter :habtm_with_with_self => INT' do 51 | # r1 = create(:region) 52 | # r2 = create(:region, :parents => [r1]) 53 | # r3 = create(:region, :parents => [r1, r2]) 54 | # 55 | # assert_equal [r1].map(&:id), Region.filter(:children => r2.id).map(&:id) 56 | # query = Region.filter(:children => r1.id) 57 | # assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 58 | # SELECT regions.* FROM regions 59 | # INNER JOIN regions_regions regions_children ON regions_children.parent_id = regions.id 60 | # WHERE regions_children.child_id = #{r1.id} 61 | # SQL 62 | # 63 | # assert_equal [r2, r3].map(&:id), Region.filter(:parents => r1.id).map(&:id) 64 | # query = Region.filter(:parents => r1.id) 65 | # assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 66 | # SELECT regions.* FROM regions 67 | # INNER JOIN regions_regions regions_parents ON regions_parents.child_id = regions.id 68 | # WHERE regions_parents.parent_id = #{r1.id} 69 | # SQL 70 | # end 71 | 72 | test '::filter :habtm_with_with_self => FILTER' do 73 | query = Region.filter(properties: {name: 'Property'}) 74 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 75 | SELECT regions.* FROM regions 76 | LEFT OUTER JOIN properties_regions ON properties_regions.region_id = regions.id 77 | LEFT OUTER JOIN properties ON properties.id = properties_regions.property_id 78 | WHERE properties.name = 'Property' 79 | SQL 80 | end 81 | 82 | test '::filter :habtm_with_with_self => FILTER ON JOIN TABLE' do 83 | query = Region.filter(regions_regions: {parent_id: 42}) 84 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 85 | SELECT regions.* FROM regions 86 | LEFT OUTER JOIN regions_regions ON regions_regions.child_id = regions.id 87 | WHERE regions_regions.parent_id = 42 88 | SQL 89 | 90 | query = Region.filter(regions_regions: {child_id: 42}) 91 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 92 | SELECT regions.* FROM regions 93 | LEFT OUTER JOIN regions_regions ON regions_regions.parent_id = regions.id 94 | WHERE regions_regions.child_id = 42 95 | SQL 96 | end 97 | 98 | test '::filter :habtm_with_with_self => FILTER ON TABLE AND JOIN TABLE' do 99 | query = Region.filter(regions_regions: {parent_id: 42}, name: 'name') 100 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 101 | SELECT regions.* FROM regions 102 | LEFT OUTER JOIN regions_regions ON regions_regions.child_id = regions.id 103 | WHERE regions_regions.parent_id = 42 104 | AND regions.name = 'name' 105 | SQL 106 | end 107 | 108 | test '::filter :habtm_with_with_self => FILTER ON JOIN TABLE RELATION' do 109 | query = Region.filter(parents: { id: 42 }) 110 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 111 | SELECT regions.* FROM regions 112 | LEFT OUTER JOIN regions_regions ON regions_regions.child_id = regions.id 113 | LEFT OUTER JOIN regions parents_regions ON parents_regions.id = regions_regions.parent_id 114 | 115 | WHERE parents_regions.id = 42 116 | SQL 117 | end 118 | 119 | end -------------------------------------------------------------------------------- /test/filter_relationship_test/has_many_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HasManyFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "accounts", force: :cascade do |t| 7 | t.string "name", limit: 255 8 | t.integer 'photos_count', null: false, default: 0 9 | end 10 | 11 | create_table "photos", force: :cascade do |t| 12 | t.integer "account_id" 13 | t.integer "property_id" 14 | t.string "format", limit: 255 15 | t.string "tags", array: true, default: [], null: false 16 | end 17 | create_table "properties" do |t| 18 | t.string "name", limit: 255 19 | t.string "state", limit: 255 20 | end 21 | end 22 | 23 | class Account < ActiveRecord::Base 24 | has_many :photos 25 | end 26 | 27 | class Photo < ActiveRecord::Base 28 | belongs_to :account, counter_cache: true 29 | belongs_to :property 30 | filter_on :no_properties_where_state_is_null, "LEFT OUTER JOIN \"properties\" ON \"properties\".\"id\" = \"photos\".\"property_id\" AND \"properties\".\"state\" IS NULL" do |klass, table, key, value, join_dependency| 31 | Property.arel_table['id'].eq(nil) 32 | end 33 | end 34 | 35 | class Property < ActiveRecord::Base 36 | has_many :photos 37 | end 38 | 39 | test "::filter has_many: BOOL (with counter_cache)" do 40 | query = Account.filter(photos: true) 41 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 42 | SELECT accounts.* FROM accounts 43 | WHERE accounts.photos_count > 0 44 | SQL 45 | 46 | query = Account.filter(photos: "true") 47 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 48 | SELECT accounts.* FROM accounts 49 | WHERE accounts.photos_count > 0 50 | SQL 51 | 52 | 53 | query = Account.filter(photos: false) 54 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 55 | SELECT accounts.* FROM accounts 56 | WHERE accounts.photos_count = 0 57 | SQL 58 | 59 | query = Account.filter(photos: "false") 60 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 61 | SELECT accounts.* FROM accounts 62 | WHERE accounts.photos_count = 0 63 | SQL 64 | end 65 | 66 | test "::filter has_many: FILTER" do 67 | query = Account.filter(photos: {format: 'jpg'}) 68 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 69 | SELECT accounts.* FROM accounts 70 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 71 | WHERE photos.format = 'jpg' 72 | SQL 73 | 74 | query = Account.filter(photos: {tags: {overlaps: ['cute']}}) 75 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 76 | SELECT accounts.* FROM accounts 77 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 78 | WHERE photos.tags && '{cute}' 79 | SQL 80 | end 81 | 82 | test "::filter nested relationships" do 83 | query = Account.filter(photos: {property: {name: 'Name'}}) 84 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 85 | SELECT accounts.* FROM accounts 86 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 87 | LEFT OUTER JOIN properties ON properties.id = photos.property_id 88 | WHERE properties.name = 'Name' 89 | SQL 90 | 91 | query = Account.filter(photos: [ { property: { name: 'Name' } } ]) 92 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 93 | SELECT accounts.* FROM accounts 94 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 95 | LEFT OUTER JOIN properties ON properties.id = photos.property_id 96 | WHERE properties.name = 'Name' 97 | SQL 98 | 99 | query = Account.filter(photos: [ { property: { name: 'Name' } }, { account: { name: 'Person' } } ]) 100 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 101 | SELECT accounts.* FROM accounts 102 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 103 | LEFT OUTER JOIN properties ON properties.id = photos.property_id 104 | LEFT OUTER JOIN accounts accounts_photos ON accounts_photos.id = photos.account_id 105 | WHERE properties.name = 'Name' 106 | AND accounts_photos.name = 'Person' 107 | SQL 108 | end 109 | 110 | test "::filter has_many: INT" do 111 | query = Account.filter(photos: 1) 112 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 113 | SELECT accounts.* FROM accounts 114 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 115 | WHERE photos.id = 1 116 | SQL 117 | end 118 | 119 | test "::filter has_many_ids: INT" do 120 | query = Account.filter(photo_ids: 1) 121 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 122 | SELECT accounts.* FROM accounts 123 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 124 | WHERE photos.id = 1 125 | SQL 126 | end 127 | 128 | test "::filter has_many_ids: [INT]" do 129 | query = Account.filter(photo_ids: [1, 2]) 130 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 131 | SELECT accounts.* FROM accounts 132 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 133 | WHERE photos.id IN (1, 2) 134 | SQL 135 | end 136 | 137 | test "::filter filter_on" do 138 | query = Photo.filter(no_properties_where_state_is_null: true) 139 | 140 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 141 | SELECT photos.* FROM photos 142 | LEFT OUTER JOIN properties ON properties.id = photos.property_id AND properties.state IS NULL 143 | WHERE properties.id IS NULL 144 | SQL 145 | end 146 | 147 | test "::filter has_many filter_on" do 148 | query = Account.filter(photos: {no_properties_where_state_is_null: true}) 149 | 150 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 151 | SELECT accounts.* FROM accounts 152 | LEFT OUTER JOIN photos ON photos.account_id = accounts.id 153 | LEFT OUTER JOIN properties ON properties.id = photos.property_id AND properties.state IS NULL 154 | WHERE properties.id IS NULL 155 | SQL 156 | end 157 | 158 | # test "::filter :has_many with lambda" do 159 | # a1 = create(:property) 160 | # a2 = create(:property) 161 | # create(:lease, :property => a1) 162 | # create(:sublease, :property => a2) 163 | # 164 | # assert_equal [a1], Property.filter(:listings => { :type => 'lease'} ) 165 | # assert_equal [a2], Property.filter(:listings => { :type => 'sublease'} ) 166 | # end 167 | 168 | end 169 | -------------------------------------------------------------------------------- /test/filter_relationship_test/has_many_through_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HasManyThroughFilterTest < ActiveSupport::TestCase 4 | 5 | schema do 6 | create_table "accounts", force: :cascade do |t| 7 | t.string "name", limit: 255 8 | end 9 | 10 | create_table "localities", force: :cascade do |t| 11 | t.string "record_type" 12 | t.integer "record_id" 13 | t.integer "region_id" 14 | end 15 | 16 | create_table "regions" do |t| 17 | t.string "name", limit: 255 18 | end 19 | end 20 | 21 | class Account < ActiveRecord::Base 22 | has_many :localities, as: :record 23 | has_many :regions, through: :localities 24 | end 25 | 26 | class Locality < ActiveRecord::Base 27 | belongs_to :subject, polymorphic: true 28 | belongs_to :region 29 | end 30 | 31 | class Region < ActiveRecord::Base 32 | has_many :localities 33 | end 34 | 35 | # TODO: Optimize this test to use the foreign_key instead of doing the extra join when where is only on id 36 | test "::filter has_many_through_polymorhic_ids: id" do 37 | query = Account.filter(region_ids: 10) 38 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 39 | SELECT accounts.* FROM accounts 40 | LEFT OUTER JOIN localities ON 41 | localities.record_type = 'HasManyThroughFilterTest::Account' 42 | AND localities.record_id = accounts.id 43 | LEFT OUTER JOIN regions ON 44 | regions.id = localities.region_id 45 | WHERE regions.id = 10 46 | SQL 47 | end 48 | 49 | end -------------------------------------------------------------------------------- /test/filter_relationship_test/has_one_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | 3 | class HasOneTest < ActiveSupport::TestCase 4 | 5 | # Currently has_one is just and alias for has_many 6 | # Not Supported, works on has_many because of counter_cache 7 | # test "::filter :has_one => true" do 8 | # test "::filter :has_one => false" 9 | # test "::filter has_one_association with lambda" do 10 | # test "::filter on model and has_one_association" 11 | 12 | end -------------------------------------------------------------------------------- /test/filter_test.rb: -------------------------------------------------------------------------------- 1 | require 'test_helper' 2 | require 'action_controller/metal/strong_parameters' 3 | 4 | class FilterTest < ActiveSupport::TestCase 5 | 6 | schema do 7 | create_table "properties" do |t| 8 | t.string "name", limit: 255 9 | t.string "state", limit: 255 10 | 11 | t.integer 'score' 12 | t.datetime 'touched_at' 13 | end 14 | 15 | create_table "photos", force: :cascade do |t| 16 | t.integer "property_id" 17 | end 18 | end 19 | 20 | class Property < ActiveRecord::Base 21 | has_many :photos 22 | end 23 | 24 | class Photo < ActiveRecord::Base 25 | belongs_to :property 26 | end 27 | 28 | test 'Aliasing a table name' do 29 | skip if ActiveRecord.version <= '7.3' # Bug in <= 7.2 30 | table_alias = Property.arel_table.alias("x") 31 | arel = ActiveRecord::Relation.create(Property, table: table_alias) 32 | 33 | assert_equal <<-SQL.strip.tr("\n", '').squeeze(" "), arel.joins(:photos).where!(name: 'name').to_sql 34 | SELECT "x".* 35 | FROM "properties" "x" 36 | INNER JOIN "photos" ON "photos"."property_id" = "x"."id" 37 | WHERE "x"."name" = 'name' 38 | SQL 39 | end 40 | 41 | test '::filter nil' do 42 | query = Property.filter(nil) 43 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 44 | SELECT properties.* 45 | FROM properties 46 | SQL 47 | end 48 | 49 | test '::filter not existant column or filter' do 50 | assert_raises(ActiveRecord::UnkownFilterError) do 51 | Property.filter(unkown_column: 1).load 52 | end 53 | end 54 | 55 | test "::filter with lambda" do 56 | query = Property.filter(state: 'NY') 57 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 58 | SELECT properties.* 59 | FROM properties 60 | WHERE properties.state = 'NY' 61 | SQL 62 | end 63 | 64 | 65 | test "::filter with nested ActionController::Parameters" do 66 | query = Property.filter(ActionController::Parameters.new(where: [{id: {lt: 2}}, 'OR', [{id: {gt: 3}}, 'AND', {state: 'VT'}]])[:where]) 67 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 68 | SELECT properties.* 69 | FROM properties 70 | WHERE ((properties.id < 2) OR (properties.id > 3 AND properties.state = 'VT')) 71 | SQL 72 | end 73 | 74 | test '::filter(OR CONDITION)' do 75 | query = Property.filter([{id: 10}, 'OR', {name: 'name'}]) 76 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 77 | SELECT properties.* 78 | FROM properties 79 | WHERE ((properties.id = 10) OR (properties.name = 'name')) 80 | SQL 81 | end 82 | 83 | test '::filter(AND & OR CONDITION)' do 84 | query = Property.filter([{id: 10}, 'AND', [{id: 10}, 'OR', {name: 'name'}]]) 85 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 86 | SELECT properties.* 87 | FROM properties 88 | WHERE properties.id = 10 AND ((properties.id = 10) OR (properties.name = 'name')) 89 | SQL 90 | end 91 | 92 | test '::where with eager_load' do 93 | query = Property.eager_load(:photos).filter(id: 2) 94 | 95 | assert_equal(<<-SQL.strip.gsub(/\s+/, ' '), query.to_sql.strip.gsub('"', '')) 96 | SELECT 97 | properties.id AS t0_r0, 98 | properties.name AS t0_r1, 99 | properties.state AS t0_r2, 100 | properties.score AS t0_r3, 101 | properties.touched_at AS t0_r4, 102 | photos.id AS t1_r0, 103 | photos.property_id AS t1_r1 104 | FROM properties 105 | LEFT OUTER JOIN photos ON photos.property_id = properties.id 106 | WHERE properties.id = 2 107 | SQL 108 | end 109 | 110 | test '::filter on relationship' do 111 | queries = [ 112 | Property.filter("photos" => { "id" => [ 1, 2 ] }), 113 | Property.filter(photos: { id: [ 1, 2 ]}) 114 | ].map { |q| q.to_sql.strip.gsub('"', '') } 115 | 116 | queries.each do |query| 117 | assert_equal <<~SQL.strip, query 118 | SELECT properties.* FROM properties LEFT OUTER JOIN photos ON photos.property_id = properties.id WHERE photos.id IN (1, 2) 119 | SQL 120 | end 121 | end 122 | 123 | end 124 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | # To make testing/debugging easier, test within this source tree versus an 5 | # installed gem 6 | $LOAD_PATH << File.expand_path('../../lib', __FILE__) 7 | 8 | 9 | require 'byebug' 10 | require "minitest/autorun" 11 | require 'minitest/unit' 12 | require 'minitest/reporters' 13 | require 'active_record/filter' 14 | require 'faker' 15 | require 'activerecord-postgis-adapter' 16 | 17 | # Setup the test db 18 | ActiveSupport.test_order = :random 19 | 20 | Minitest::Reporters.use! Minitest::Reporters::SpecReporter.new 21 | 22 | class ActiveSupport::TestCase 23 | 24 | # File 'lib/active_support/testing/declarative.rb' 25 | def self.test(name, &block) 26 | test_name = "test_#{name.gsub(/\s+/, '_')}".to_sym 27 | defined = method_defined? test_name 28 | raise "#{test_name} is already defined in #{self}" if defined 29 | if block_given? 30 | define_method(test_name, &block) 31 | else 32 | define_method(test_name) do 33 | skip "No implementation provided for #{name}" 34 | end 35 | end 36 | end 37 | 38 | def self.schema(&block) 39 | self.class_variable_set(:@@schema, block) 40 | end 41 | 42 | set_callback(:setup, :before) do 43 | if !self.class.class_variable_defined?(:@@suite_setup_run) && self.class.class_variable_defined?(:@@schema) 44 | ActiveRecord::Base.establish_connection({ 45 | adapter: "postgis", 46 | database: "activerecord-filter-test", 47 | encoding: "utf8" 48 | }) 49 | 50 | db_config = ActiveRecord::Base.connection_db_config 51 | db_tasks = ActiveRecord::Tasks::PostgreSQLDatabaseTasks.new(db_config) 52 | db_tasks.purge 53 | 54 | ActiveRecord::Migration.suppress_messages do 55 | ActiveRecord::Schema.define(&self.class.class_variable_get(:@@schema)) 56 | ActiveRecord::Migration.execute("SELECT c.relname FROM pg_class c WHERE c.relkind = 'S'").each_row do |row| 57 | ActiveRecord::Migration.execute("ALTER SEQUENCE #{row[0]} RESTART WITH #{rand(50_000)}") 58 | end 59 | end 60 | end 61 | self.class.class_variable_set(:@@suite_setup_run, true) 62 | end 63 | 64 | def assert_sql(expected, query) 65 | assert_equal( 66 | expected.strip.gsub(/"(\w+)"/, '\1').gsub(/\(\s+/, '(').gsub(/\s+\)/, ')').gsub(/[\s|\n]+/, ' '), 67 | query.to_sql.strip.gsub(/"(\w+)"/, '\1').gsub(/\(\s+/, '(').gsub(/\s+\)/, ')').gsub(/[\s|\n]+/, ' ') 68 | ) 69 | end 70 | end 71 | --------------------------------------------------------------------------------