├── .tool-versions ├── .rspec ├── Rakefile ├── spec ├── dummy-rails5 │ ├── .rspec │ ├── app │ │ ├── models │ │ │ ├── user.rb │ │ │ ├── address.rb │ │ │ ├── micropost.rb │ │ │ └── relationship.rb │ │ └── controllers │ │ │ └── application_controller.rb │ ├── spec │ │ ├── support │ │ │ └── support.rb │ │ ├── factories │ │ │ └── factories.rb │ │ ├── tests │ │ │ └── tests_spec.rb │ │ ├── rails_helper.rb │ │ └── spec_helper.rb │ ├── README.md │ ├── db │ │ ├── migrate │ │ │ └── 001_basic_schema.rb │ │ └── schema.rb │ ├── bin │ │ ├── rake │ │ ├── bundle │ │ ├── rails │ │ ├── update │ │ └── setup │ ├── config │ │ ├── boot.rb │ │ ├── spring.rb │ │ ├── initializers │ │ │ ├── active_hash_relation.rb │ │ │ ├── session_store.rb │ │ │ ├── mime_types.rb │ │ │ ├── application_controller_renderer.rb │ │ │ ├── filter_parameter_logging.rb │ │ │ ├── cookies_serializer.rb │ │ │ ├── backtrace_silencers.rb │ │ │ ├── assets.rb │ │ │ ├── wrap_parameters.rb │ │ │ ├── inflections.rb │ │ │ └── new_framework_defaults.rb │ │ ├── environment.rb │ │ ├── routes.rb │ │ ├── cable.yml │ │ ├── database.yml │ │ ├── locales │ │ │ └── en.yml │ │ ├── application.rb │ │ ├── secrets.yml │ │ ├── environments │ │ │ ├── test.rb │ │ │ ├── development.rb │ │ │ └── production.rb │ │ └── puma.rb │ ├── config.ru │ ├── Rakefile │ ├── Gemfile │ └── .gitignore ├── models │ ├── application_record.rb │ ├── address.rb │ ├── micropost.rb │ ├── relationship.rb │ └── user.rb ├── config │ └── initializers │ │ └── active_hash_relation.rb ├── tests │ ├── primary_key_spec.rb │ ├── booleans_spec.rb │ ├── limit_spec.rb │ ├── null_spec.rb │ ├── aggregations │ │ ├── sum_spec.rb │ │ ├── avg_spec.rb │ │ ├── max_spec.rb │ │ └── min_spec.rb │ ├── scopes_spec.rb │ ├── strings_spec.rb │ ├── or_filter_spec.rb │ ├── associations │ │ ├── has_one_spec.rb │ │ ├── belongs_to_spec.rb │ │ └── has_many_spec.rb │ ├── not_filter_spec.rb │ ├── numbers_spec.rb │ └── sorting_spec.rb ├── factories │ └── factories.rb ├── support │ └── support.rb └── db │ └── migrate │ └── 001_basic_schema.rb ├── lib ├── active_hash_relation │ ├── version.rb │ ├── limit_filters.rb │ ├── scope_filters.rb │ ├── helpers.rb │ ├── sort_filters.rb │ ├── association_filters.rb │ ├── aggregation.rb │ ├── filter_applier.rb │ └── column_filters.rb ├── active_record │ └── scope_names.rb ├── generators │ └── active_hash_relation │ │ ├── initialize_generator.rb │ │ └── templates │ │ └── active_hash_relation.rb └── active_hash_relation.rb ├── Gemfile ├── .gitignore ├── LICENSE ├── .github └── workflows │ └── ruby.yml ├── active_hash_relation.gemspec └── README.md /.tool-versions: -------------------------------------------------------------------------------- 1 | ruby 2.7.4 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | 3 | -------------------------------------------------------------------------------- /spec/dummy-rails5/.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /spec/dummy-rails5/app/models/user.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../models/user' 2 | -------------------------------------------------------------------------------- /spec/dummy-rails5/app/models/address.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../models/address' 2 | -------------------------------------------------------------------------------- /spec/dummy-rails5/app/models/micropost.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../models/micropost' 2 | -------------------------------------------------------------------------------- /spec/dummy-rails5/spec/support/support.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../support/support' 2 | -------------------------------------------------------------------------------- /lib/active_hash_relation/version.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation 2 | VERSION = "1.4.1" 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/app/models/relationship.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../models/relationship' 2 | -------------------------------------------------------------------------------- /spec/dummy-rails5/spec/factories/factories.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../factories/factories' 2 | -------------------------------------------------------------------------------- /spec/dummy-rails5/README.md: -------------------------------------------------------------------------------- 1 | # DummyRails5 2 | 3 | A Rails 5 repo to test ActiveHasRelation compatibility. 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/db/migrate/001_basic_schema.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../db/migrate/001_basic_schema' 2 | 3 | -------------------------------------------------------------------------------- /spec/models/application_record.rb: -------------------------------------------------------------------------------- 1 | class ApplicationRecord < ActiveRecord::Base 2 | self.abstract_class = true 3 | end 4 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in active_hash_relation.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/dummy-rails5/bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /spec/dummy-rails5/spec/tests/tests_spec.rb: -------------------------------------------------------------------------------- 1 | require 'rails_helper' 2 | 3 | Dir[Rails.root.join('../tests/**/*.rb')].each { |f| require f } 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | protect_from_forgery with: :exception 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../Gemfile', __dir__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/spring.rb: -------------------------------------------------------------------------------- 1 | %w( 2 | .ruby-version 3 | .rbenv-vars 4 | tmp/restart.txt 5 | tmp/caching-dev.txt 6 | ).each { |path| Spring.watch(path) } 7 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require_relative 'config/environment' 4 | 5 | run Rails.application 6 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/active_hash_relation.rb: -------------------------------------------------------------------------------- 1 | require_relative '../../../config/initializers/active_hash_relation' 2 | 3 | instance_exec(&(ARH_INITIALIZER)) 4 | -------------------------------------------------------------------------------- /lib/active_hash_relation/limit_filters.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation::LimitFilters 2 | def apply_limit(resource, limit) 3 | return resource.limit(limit) 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/dummy-rails5/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../config/application', __dir__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require_relative 'application' 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | # For details on the DSL available within this file, see http://guides.rubyonrails.org/routing.html 3 | end 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/cable.yml: -------------------------------------------------------------------------------- 1 | development: 2 | adapter: async 3 | 4 | test: 5 | adapter: async 6 | 7 | production: 8 | adapter: redis 9 | url: redis://localhost:6379/1 10 | -------------------------------------------------------------------------------- /spec/models/address.rb: -------------------------------------------------------------------------------- 1 | require_relative './application_record' 2 | 3 | class Address < ApplicationRecord 4 | belongs_to :user 5 | 6 | scope :in_sweden, -> { where(country: 'SE') } 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.session_store :cookie_store, key: '_dummy_rails5_session' 4 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/application_controller_renderer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # ApplicationController.renderer.defaults.merge!( 4 | # http_host: 'example.org', 5 | # https: false 6 | # ) 7 | -------------------------------------------------------------------------------- /spec/dummy-rails5/Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require_relative 'config/application' 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | Gemfile.lock 7 | InstalledFiles 8 | _yardoc 9 | coverage 10 | doc/ 11 | lib/bundler/man 12 | pkg 13 | rdoc 14 | spec/reports 15 | test/tmp 16 | test/version_tmp 17 | tmp 18 | *.bundle 19 | *.so 20 | *.o 21 | *.a 22 | mkmf.log 23 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Specify a serializer for the signed and encrypted cookie jars. 4 | # Valid options are :json, :marshal, and :hybrid. 5 | Rails.application.config.action_dispatch.cookies_serializer = :json 6 | -------------------------------------------------------------------------------- /spec/models/micropost.rb: -------------------------------------------------------------------------------- 1 | require_relative './application_record' 2 | 3 | class Micropost < ApplicationRecord 4 | belongs_to :user, counter_cache: true 5 | 6 | enum status: {draft: 0, published: 1, archived: 2 } 7 | 8 | scope :created_on, ->(date) {where("date(microposts.created_at) = ?", date.to_date)} 9 | end 10 | -------------------------------------------------------------------------------- /spec/models/relationship.rb: -------------------------------------------------------------------------------- 1 | require_relative './application_record' 2 | 3 | class Relationship < ApplicationRecord 4 | belongs_to :follower, class_name: "User", counter_cache: :followings_count 5 | belongs_to :followed, class_name: "User", counter_cache: :followers_count 6 | 7 | validates :follower_id, presence: true 8 | validates :followed_id, presence: true 9 | end 10 | -------------------------------------------------------------------------------- /spec/dummy-rails5/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | ruby "2.7.4" 4 | 5 | group :development, :test do 6 | gem 'rails', '5.2.6' 7 | gem 'pg', '~> 0.18' 8 | end 9 | 10 | group :development, :test do 11 | gem 'rspec-rails', '~> 3.5' 12 | gem 'database_cleaner' 13 | gem 'factory_bot_rails' 14 | gem 'pry-rails' 15 | gem 'faker' 16 | gem 'listen' 17 | end 18 | 19 | gem 'active_hash_relation', path: '../../' 20 | -------------------------------------------------------------------------------- /lib/active_record/scope_names.rb: -------------------------------------------------------------------------------- 1 | module ActiveRecord 2 | module Scoping 3 | module Named 4 | module ClassMethods 5 | attr_reader :scope_names 6 | 7 | alias_method :_scope, :scope 8 | 9 | def scope(name, body, &block) 10 | @scope_names ||= [] 11 | 12 | _scope(name, body, &block) 13 | 14 | @scope_names << name 15 | end 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/database.yml: -------------------------------------------------------------------------------- 1 | default: &default 2 | adapter: postgresql 3 | encoding: unicode 4 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 5 | 6 | development: 7 | <<: *default 8 | database: dummy_rails5_development 9 | 10 | test: 11 | <<: *default 12 | database: dummy_rails5_test 13 | 14 | production: 15 | <<: *default 16 | database: dummy_rails5_production 17 | username: dummy 18 | password: <%= ENV['DUMMY_DATABASE_PASSWORD'] %> 19 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /lib/generators/active_hash_relation/initialize_generator.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation 2 | module Generators 3 | class InitializeGenerator < Rails::Generators::Base 4 | desc "This generator creates an initializer file at config/initializers" 5 | 6 | source_root File.expand_path(File.join(File.dirname(__FILE__), 'templates')) 7 | 8 | def create_initializer_file 9 | template 'active_hash_relation.rb', 'config/initializers/active_hash_relation.rb' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/dummy-rails5/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | /tmp/* 13 | !/log/.keep 14 | !/tmp/.keep 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | # Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /lib/generators/active_hash_relation/templates/active_hash_relation.rb: -------------------------------------------------------------------------------- 1 | ActiveHashRelation.configure do |config| 2 | #override default scope when accessing associations 3 | config.use_unscoped = true 4 | #set true to be able to filter scopes (with params) 5 | #please note that unfortunately (:/) rails does not provide any way 6 | #to iterate through scopes so it uses a monkey patch 7 | #You need to run `initialize!` to actually include the required files 8 | config.filter_active_record_scopes = false 9 | end 10 | 11 | #requires monkeyparched scopes, optional if you don't enable them 12 | ActiveHashRelation.initialize! 13 | -------------------------------------------------------------------------------- /lib/active_hash_relation/scope_filters.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation::ScopeFilters 2 | def filter_scopes(resource, params, model = nil) 3 | unless model 4 | model = model_class_name(resource) 5 | if model.nil? || engine_name == model.to_s 6 | model = model_class_name(resource, true) 7 | end 8 | end 9 | 10 | model.scope_names.each do |scope| 11 | if params.include?(scope) 12 | if params[scope].is_a? Array 13 | resource = resource.send(scope, *params[scope]) 14 | else 15 | resource = resource.send(scope) 16 | end 17 | end 18 | end 19 | 20 | return resource 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/active_hash_relation/helpers.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation 2 | module Helpers 3 | def model_class_name(resource, _engine = false) 4 | _class = resource.class.to_s.split('::') 5 | if _engine === true 6 | "#{_class[0]}::#{_class[1]}".constantize 7 | else 8 | _class.first.constantize 9 | end 10 | end 11 | 12 | def engine_name 13 | Rails::Engine.subclasses[0].to_s.split('::').first 14 | end 15 | 16 | def find_model(model) 17 | return model if model 18 | 19 | model = model_class_name(@resource) 20 | if model.nil? || engine_name == model.to_s 21 | model = model_class_name(@resource, true) 22 | end 23 | 24 | return model 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /spec/config/initializers/active_hash_relation.rb: -------------------------------------------------------------------------------- 1 | ARH_INITIALIZER = proc{ 2 | ActiveHashRelation.configure do |config| 3 | #override default scope when accessing associations 4 | config.use_unscoped = true 5 | #set true to be able to filter scopes (with params) 6 | #please note that unfortunately (:/) rails does not provide any way 7 | #to iterate through scopes so it uses a monkey patch. 8 | #The monkey patch is as gentle as it can be by aliasing the method, adds some 9 | #sugar and calls it 10 | #You need to run `initialize!` to actually include the required files 11 | config.filter_active_record_scopes = true 12 | end 13 | 14 | #requires monkeyparched scopes, optional if you don't enable them 15 | ActiveHashRelation.initialize! 16 | } 17 | -------------------------------------------------------------------------------- /spec/tests/primary_key_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'primary_key' do 5 | it "one key" do 6 | hash = {id: 1} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users WHERE (users.id = 1)" 11 | ) 12 | 13 | expect(strip(query)).to eq expected_query.to_s 14 | end 15 | 16 | it "multiple keys" do 17 | hash = {id: [1,2,3,4]} 18 | 19 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 20 | expected_query = q( 21 | "SELECT users.* FROM users WHERE (users.id IN (1, 2, 3, 4))" 22 | ) 23 | 24 | expect(strip(query)).to eq expected_query.to_s 25 | end 26 | end 27 | end 28 | 29 | 30 | -------------------------------------------------------------------------------- /spec/dummy-rails5/spec/rails_helper.rb: -------------------------------------------------------------------------------- 1 | ENV['RAILS_ENV'] ||= 'test' 2 | require File.expand_path('../../config/environment', __FILE__) 3 | abort("The Rails environment is running in production mode!") if Rails.env.production? 4 | require 'spec_helper' 5 | require 'rspec/rails' 6 | require File.expand_path('../../../../lib/active_hash_relation', __FILE__) 7 | 8 | Dir[Rails.root.join('spec/support/**/*.rb')].each { |f| require f } 9 | 10 | # Checks for pending migration and applies them before tests are run. 11 | # If you are not using ActiveRecord, you can remove this line. 12 | ActiveRecord::Migration.maintain_test_schema! 13 | 14 | RSpec.configure do |config| 15 | config.fixture_path = "#{::Rails.root}/spec/fixtures" 16 | config.use_transactional_fixtures = true 17 | config.infer_spec_type_from_file_location! 18 | config.filter_rails_from_backtrace! 19 | end 20 | -------------------------------------------------------------------------------- /spec/tests/booleans_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'booleans' do 5 | it "one boolean where clause" do 6 | hash = {admin: false} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users", 11 | "WHERE users.admin = FALSE" 12 | ) 13 | 14 | expect(strip(query)).to eq expected_query.to_s 15 | end 16 | 17 | it "multi boolean where clauses" do 18 | hash = {admin: false, verified: true} 19 | 20 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 21 | expected_query = q( 22 | "SELECT users.* FROM users", 23 | "WHERE users.admin = FALSE AND users.verified = TRUE" 24 | ) 25 | 26 | expect(strip(query)).to eq expected_query.to_s 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy-rails5/bin/update: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a way to update your development environment automatically. 15 | # Add necessary update steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | puts "\n== Updating database ==" 22 | system! 'bin/rails db:migrate' 23 | 24 | puts "\n== Removing old logs and tempfiles ==" 25 | system! 'bin/rails log:clear tmp:clear' 26 | 27 | puts "\n== Restarting application server ==" 28 | system! 'bin/rails restart' 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/application.rb: -------------------------------------------------------------------------------- 1 | require_relative 'boot' 2 | 3 | require "rails" 4 | # Pick the frameworks you want: 5 | require "active_model/railtie" 6 | require "active_job/railtie" 7 | require "active_record/railtie" 8 | require "action_controller/railtie" 9 | require "action_mailer/railtie" 10 | require "action_view/railtie" 11 | require "action_cable/engine" 12 | # require "sprockets/railtie" 13 | # require "rails/test_unit/railtie" 14 | 15 | # Require the gems listed in Gemfile, including any gems 16 | # you've limited to :test, :development, or :production. 17 | Bundler.require(*Rails.groups) 18 | 19 | module DummyRails5 20 | class Application < Rails::Application 21 | # Settings in config/environments/* take precedence over those specified here. 22 | # Application configuration should go into files in config/initializers 23 | # -- all .rb files in that directory are automatically loaded. 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /spec/factories/factories.rb: -------------------------------------------------------------------------------- 1 | FactoryBot.define do 2 | factory :address do 3 | user 4 | 5 | street { Faker::Address.street_address } 6 | city { Faker::Address.city } 7 | country { Faker::Address.country } 8 | end 9 | end 10 | 11 | FactoryBot.define do 12 | factory :micropost do 13 | content { Faker::Lorem.sentence } 14 | likes { rand(1..1000) } 15 | reposts { rand(1..1000) } 16 | status { [:draft, :published, :archived].sample } 17 | created_at { rand(1..1000).days.ago } 18 | 19 | user 20 | end 21 | end 22 | 23 | FactoryBot.define do 24 | factory :relationship do 25 | association :follower, factory: :user 26 | association :followed, factory: :user 27 | end 28 | end 29 | 30 | FactoryBot.define do 31 | factory :user do 32 | email { Faker::Internet.email } 33 | name { Faker::Name.name } 34 | 35 | factory :admin do 36 | admin { true } 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/tests/limit_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'limit' do 5 | it "one where clause" do 6 | hash = {microposts_count: 10, limit: 10} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users WHERE (users.microposts_count = 10) LIMIT 10" 11 | ) 12 | 13 | expect(strip(query)).to eq expected_query.to_s 14 | end 15 | 16 | it "multiple where clause" do 17 | hash = {microposts_count: 3, followers_count: 5, limit: 10} 18 | 19 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 20 | expected_query = q( 21 | "SELECT users.* FROM users", 22 | "WHERE (users.microposts_count = 3)", 23 | "AND (users.followers_count = 5)", 24 | "LIMIT 10" 25 | ) 26 | 27 | expect(strip(query)).to eq expected_query.to_s 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/active_hash_relation/sort_filters.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation::SortFilters 2 | def apply_sort(resource, params, model = nil) 3 | if params.is_a? Array 4 | params.each do |param_item| 5 | resource = apply_hash_sort(resource, param_item, model) 6 | end 7 | else 8 | resource = apply_hash_sort(resource, params, model) 9 | end 10 | 11 | return resource 12 | end 13 | 14 | def apply_hash_sort(resource, params, model = nil) 15 | if not params[:property].blank? 16 | if model.columns.map(&:name).include?(params[:property].to_s) 17 | resource = resource.order(params[:property] => (params[:order] || :desc) ) 18 | end 19 | else 20 | params.each do |property, order| 21 | if model.columns.map(&:name).include?(property.to_s) 22 | resource = resource.order(property => (order || :desc) ) 23 | end 24 | end 25 | end 26 | 27 | return resource 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rails secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 18597e9f6dc85291a8c172aca10f8c8f5af933001d5113d55cf61a878ad0fcdb17f0988ea8e40bc7587502837b754505f38a505f4e1a96bedab95dd334b6d668 15 | 16 | test: 17 | secret_key_base: f1b08127908b5be5942afd41298e4301ce6ed230f3f7a1be4752aab3529588ba1da79dcfe9b413ee60be59884b62882d165fd66f225b0933f6fc6892ae583f2e 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /spec/dummy-rails5/bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | require 'fileutils' 4 | include FileUtils 5 | 6 | # path to your application root. 7 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 8 | 9 | def system!(*args) 10 | system(*args) || abort("\n== Command #{args} failed ==") 11 | end 12 | 13 | chdir APP_ROOT do 14 | # This script is a starting point to setup your application. 15 | # Add necessary setup steps to this file. 16 | 17 | puts '== Installing dependencies ==' 18 | system! 'gem install bundler --conservative' 19 | system('bundle check') || system!('bundle install') 20 | 21 | # puts "\n== Copying sample files ==" 22 | # unless File.exist?('config/database.yml') 23 | # cp 'config/database.yml.sample', 'config/database.yml' 24 | # end 25 | 26 | puts "\n== Preparing database ==" 27 | system! 'bin/rails db:setup' 28 | 29 | puts "\n== Removing old logs and tempfiles ==" 30 | system! 'bin/rails log:clear tmp:clear' 31 | 32 | puts "\n== Restarting application server ==" 33 | system! 'bin/rails restart' 34 | end 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Filippos Vasilakis 2 | 3 | MIT License 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/initializers/new_framework_defaults.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | # 3 | # This file contains migration options to ease your Rails 5.0 upgrade. 4 | # 5 | # Read the Rails 5.0 release notes for more info on each option. 6 | 7 | # Enable per-form CSRF tokens. Previous versions had false. 8 | Rails.application.config.action_controller.per_form_csrf_tokens = true 9 | 10 | # Enable origin-checking CSRF mitigation. Previous versions had false. 11 | Rails.application.config.action_controller.forgery_protection_origin_check = true 12 | 13 | # Make Ruby 2.4 preserve the timezone of the receiver when calling `to_time`. 14 | # Previous versions had false. 15 | ActiveSupport.to_time_preserves_timezone = true 16 | 17 | # Require `belongs_to` associations by default. Previous versions had false. 18 | Rails.application.config.active_record.belongs_to_required_by_default = true 19 | 20 | # Do not halt callback chains when a callback returns false. Previous versions had true. 21 | # ActiveSupport.halt_callback_chains_on_return_false = false 22 | 23 | # Configure SSL options to enable HSTS with subdomains. Previous versions had false. 24 | Rails.application.config.ssl_options = { hsts: { subdomains: true } } 25 | -------------------------------------------------------------------------------- /spec/models/user.rb: -------------------------------------------------------------------------------- 1 | require_relative './application_record' 2 | 3 | class User < ApplicationRecord 4 | has_many :microposts, dependent: :destroy 5 | has_many :active_relationships, class_name: "Relationship", 6 | foreign_key: "follower_id", 7 | dependent: :destroy 8 | has_many :passive_relationships, class_name: "Relationship", 9 | foreign_key: "followed_id", 10 | dependent: :destroy 11 | has_many :following, through: :active_relationships, source: :followed 12 | has_many :followers, through: :passive_relationships, source: :follower 13 | has_one :address 14 | 15 | before_validation :ensure_token 16 | 17 | scope :unverified, -> {where(verified: false)} 18 | scope :unsocial, -> {where(followings_count: 0, followers_count: 0)} 19 | scope :created_on, ->(date) {where("date(created_at) = ?", date.to_date)} 20 | 21 | private 22 | def ensure_token 23 | self.token = generate_hex(:token) unless token.present? 24 | end 25 | 26 | def generate_hex(column) 27 | loop do 28 | hex = SecureRandom.hex 29 | break hex unless self.class.where(column => hex).any? 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/tests/null_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'NULL' do 5 | context "one where clause" do 6 | it "is null" do 7 | hash = {admin: {null: true}} 8 | 9 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 10 | expected_query = q( 11 | "SELECT users.* FROM users", 12 | "WHERE (users.admin IS NULL)" 13 | ) 14 | 15 | expect(strip(query)).to eq expected_query.to_s 16 | end 17 | 18 | it "is not null" do 19 | hash = {admin: {null: false}} 20 | 21 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 22 | expected_query = q( 23 | "SELECT users.* FROM users", 24 | "WHERE (users.admin IS NOT NULL)" 25 | ) 26 | 27 | expect(strip(query)).to eq expected_query.to_s 28 | end 29 | end 30 | 31 | it "multi where clauses" do 32 | hash = {admin: {null: true}, verified: {null: false}} 33 | 34 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 35 | expected_query = q( 36 | "SELECT users.* FROM users", 37 | "WHERE (users.admin IS NULL)", 38 | "AND (users.verified IS NOT NULL)" 39 | ) 40 | 41 | expect(strip(query)).to eq expected_query.to_s 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/tests/aggregations/sum_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'aggregations' do 5 | context 'sum' do 6 | before do 7 | FactoryBot.create_list(:micropost, 10) 8 | end 9 | 10 | it "one aggregation" do 11 | hash = { aggregate: {likes: {sum: true} } } 12 | 13 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 14 | expected_aggregations = HashWithIndifferentAccess.new({ 15 | likes: { 16 | sum: Micropost.pluck(:likes).sum 17 | } 18 | }) 19 | expect(aggregations).to eq expected_aggregations 20 | end 21 | it "multiple aggregations" do 22 | hash = { 23 | aggregate: { 24 | likes: {sum: true}, reposts: {sum: true}, created_at: {sum: true} 25 | } 26 | } 27 | 28 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 29 | expected_aggregations = HashWithIndifferentAccess.new({ 30 | likes: { 31 | sum: Micropost.pluck(:likes).sum 32 | }, 33 | reposts: { 34 | sum: Micropost.pluck(:reposts).sum 35 | }, 36 | created_at: {} 37 | }) 38 | expect(aggregations).to eq expected_aggregations 39 | end 40 | 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/tests/aggregations/avg_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'aggregations' do 5 | context 'avg' do 6 | before do 7 | FactoryBot.create_list(:micropost, 10) 8 | end 9 | 10 | it "one aggregation" do 11 | hash = { aggregate: {likes: {avg: true} } } 12 | 13 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 14 | expected_aggregations = HashWithIndifferentAccess.new({ 15 | likes: { 16 | avg: Micropost.pluck(:likes).avg 17 | } 18 | }) 19 | expect(aggregations).to eq expected_aggregations 20 | end 21 | it "multiple aggregations" do 22 | hash = { 23 | aggregate: { 24 | likes: {avg: true}, reposts: {avg: true}, created_at: {avg: true} 25 | } 26 | } 27 | 28 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 29 | expected_aggregations = HashWithIndifferentAccess.new({ 30 | likes: { 31 | avg: Micropost.pluck(:likes).avg 32 | }, 33 | reposts: { 34 | avg: Micropost.pluck(:reposts).avg 35 | }, 36 | created_at: {} 37 | }) 38 | expect(aggregations).to eq expected_aggregations 39 | end 40 | 41 | end 42 | end 43 | end 44 | 45 | 46 | -------------------------------------------------------------------------------- /spec/support/support.rb: -------------------------------------------------------------------------------- 1 | RSpec.configure do |config| 2 | config.before :suite do 3 | DatabaseCleaner[:active_record].strategy = :transaction 4 | DatabaseCleaner.clean_with(:truncation) 5 | end 6 | 7 | config.around(:each) do |example| 8 | DatabaseCleaner.cleaning do 9 | example.run 10 | end 11 | end 12 | end 13 | 14 | RSpec.configure do |config| 15 | config.include FactoryBot::Syntax::Methods 16 | 17 | config.before(:suite) do 18 | begin 19 | DatabaseCleaner.start 20 | FactoryBot.lint 21 | ensure 22 | DatabaseCleaner.clean 23 | end 24 | end 25 | end 26 | 27 | class HelperClass 28 | include ActiveHashRelation 29 | end 30 | 31 | module Helpers 32 | def strip(query) 33 | query.gsub("\"","").gsub(/\s+/, ' ') 34 | end 35 | 36 | def select_all(table) 37 | ExpectedQuery.new("SELECT #{table}.* FROM users WHERE") 38 | end 39 | alias_method :select_all_where, :select_all 40 | 41 | def query(str) 42 | ExpectedQuery.new(str, subquery: true) 43 | end 44 | 45 | def q(*args) 46 | args.each.with_index.inject(''){|memo, (str, index)| 47 | if args[index-1] == '(' 48 | "#{memo}#{str}" 49 | elsif str == ')' 50 | "#{memo}#{str}" 51 | else 52 | "#{memo} #{str}" 53 | end 54 | }.strip 55 | end 56 | end 57 | 58 | class Array 59 | def avg 60 | sum / size.to_f 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /spec/tests/aggregations/max_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'aggregations' do 5 | context 'max' do 6 | before do 7 | FactoryBot.create_list(:micropost, 10) 8 | end 9 | 10 | it "one aggregation" do 11 | hash = { aggregate: {likes: {max: true} } } 12 | 13 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 14 | expected_aggregations = HashWithIndifferentAccess.new({ 15 | likes: { 16 | max: Micropost.pluck(:likes).max 17 | } 18 | }) 19 | expect(aggregations).to eq expected_aggregations 20 | end 21 | it "multiple aggregations" do 22 | hash = { 23 | aggregate: { 24 | likes: {max: true}, reposts: {max: true}, created_at: {max: true} 25 | } 26 | } 27 | 28 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 29 | expected_aggregations = HashWithIndifferentAccess.new({ 30 | likes: { 31 | max: Micropost.pluck(:likes).max, 32 | }, 33 | reposts: { 34 | max: Micropost.pluck(:reposts).max, 35 | }, 36 | created_at: { 37 | max: Micropost.pluck(:created_at).max.to_time 38 | } 39 | }) 40 | expect(aggregations).to eq expected_aggregations 41 | end 42 | 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/tests/aggregations/min_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'aggregations' do 5 | context 'min' do 6 | before do 7 | FactoryBot.create_list(:micropost, 10) 8 | end 9 | 10 | it "one aggregation" do 11 | hash = { aggregate: {likes: {min: true} } } 12 | 13 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 14 | expected_aggregations = HashWithIndifferentAccess.new({ 15 | likes: { 16 | min: Micropost.pluck(:likes).min 17 | } 18 | }) 19 | expect(aggregations).to eq expected_aggregations 20 | end 21 | it "multiple aggregations" do 22 | hash = { 23 | aggregate: { 24 | likes: {min: true}, reposts: {min: true}, created_at: {min: true} 25 | } 26 | } 27 | 28 | aggregations = HelperClass.new.aggregations(Micropost.all, hash) 29 | expected_aggregations = HashWithIndifferentAccess.new({ 30 | likes: { 31 | min: Micropost.pluck(:likes).min 32 | }, 33 | reposts: { 34 | min: Micropost.pluck(:reposts).min 35 | }, 36 | created_at: { 37 | min: Micropost.pluck(:created_at).min.to_time 38 | } 39 | }) 40 | expect(aggregations).to eq expected_aggregations 41 | end 42 | 43 | end 44 | end 45 | end 46 | 47 | -------------------------------------------------------------------------------- /spec/tests/scopes_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'scopes' do 5 | context 'without parameters' do 6 | it "one scope clause" do 7 | hash = {scopes: {unsocial: true}} 8 | 9 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 10 | expected_query = q( 11 | "SELECT users.* FROM users", 12 | "WHERE users.followings_count = 0", 13 | "AND users.followers_count = 0" 14 | ) 15 | 16 | expect(strip(query)).to eq expected_query.to_s 17 | end 18 | 19 | it "multiple scope clauses" do 20 | hash = {scopes: {unsocial: true, unverified: true}} 21 | 22 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 23 | expected_query = q( 24 | "SELECT users.* FROM users", 25 | "WHERE users.verified = FALSE", 26 | "AND users.followings_count = 0", 27 | "AND users.followers_count = 0" 28 | ) 29 | 30 | expect(strip(query)).to eq expected_query.to_s 31 | end 32 | end 33 | 34 | context 'with parameters' do 35 | it "one scope clause" do 36 | hash = {scopes: {created_on: [Date.parse("12-12-1988")]}} 37 | 38 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 39 | expected_query = q( 40 | "SELECT users.* FROM users", 41 | "WHERE (date(created_at) = '1988-12-12')", 42 | ) 43 | 44 | expect(strip(query)).to eq expected_query.to_s 45 | end 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /.github/workflows/ruby.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: ci 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | 9 | jobs: 10 | build: 11 | name: ruby 12 | runs-on: ubuntu-latest 13 | 14 | services: 15 | postgres: 16 | image: postgres:10.8 17 | env: 18 | POSTGRES_USER: postgres 19 | POSTGRES_PASSWORD: password 20 | POSTGRES_DB: postgres 21 | ports: 22 | - 5432:5432 23 | options: >- 24 | --health-cmd pg_isready 25 | --health-interval 10s 26 | --health-timeout 5s 27 | --health-retries 5 28 | steps: 29 | - uses: actions/checkout@v2 30 | with: 31 | persist-credentials: false 32 | - name: Set up Ruby 33 | uses: ruby/setup-ruby@v1 34 | - name: Cache gems 35 | uses: actions/cache@v1 36 | with: 37 | path: vendor/bundle 38 | key: ${{ runner.os }}-gems-${{ hashFiles('.tool-versions') }}-${{ hashFiles('**/Gemfile.lock') }} 39 | restore-keys: | 40 | ${{ runner.os }}-gems-${{ hashFiles('.tool-versions') }}- 41 | - name: Install dependencies and run Rails 5 tests 42 | run: | 43 | cd spec/dummy-rails5 44 | gem install bundler 45 | bundle config path ../../vendor/bundle 46 | bundle install --jobs 4 --retry 3 47 | bin/rails db:migrate 48 | bundle exec rspec spec/ 49 | env: 50 | RAILS_ENV: test 51 | DATABASE_URL: postgres://postgres:password@localhost:5432/postgres 52 | -------------------------------------------------------------------------------- /active_hash_relation.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'active_hash_relation/version' 5 | 6 | Gem::Specification.new do |spec| 7 | spec.name = "active_hash_relation" 8 | spec.version = ActiveHashRelation::VERSION 9 | spec.authors = ["Filippos Vasilakis", "Odd Camp"] 10 | spec.email = ["vasilakisfil@gmail.com", "hello@oddcamp.com"] 11 | spec.summary = %q{Simple gem that allows you to run multiple ActiveRecord::Relation using hash. Perfect for APIs.} 12 | spec.description = spec.summary 13 | spec.homepage = "https://github.com/oddcamp/active_hash_relation" 14 | spec.license = "MIT" 15 | 16 | spec.files = `git ls-files -z`.split("\x0") 17 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 18 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 19 | spec.require_paths = ["lib"] 20 | 21 | spec.add_runtime_dependency "activerecord" 22 | 23 | spec.add_development_dependency "activerecord" 24 | spec.add_development_dependency "bundler", "~> 2.1" 25 | spec.add_development_dependency "rake", "~> 10.0" 26 | 27 | spec.add_development_dependency 'rspec' 28 | spec.add_development_dependency "factory_bot_rails", "~> 6.2" 29 | spec.add_development_dependency 'faker' 30 | spec.add_development_dependency 'database_cleaner' 31 | spec.add_development_dependency 'pry' 32 | spec.add_development_dependency 'sqlite3' 33 | spec.add_development_dependency 'pg' 34 | spec.add_development_dependency 'rails' 35 | spec.add_development_dependency 'rspec-rails' 36 | end 37 | -------------------------------------------------------------------------------- /lib/active_hash_relation.rb: -------------------------------------------------------------------------------- 1 | require_relative "active_hash_relation/version" 2 | require_relative "active_hash_relation/helpers" 3 | require_relative "active_hash_relation/column_filters" 4 | require_relative "active_hash_relation/scope_filters" 5 | require_relative "active_hash_relation/sort_filters" 6 | require_relative "active_hash_relation/limit_filters" 7 | require_relative "active_hash_relation/association_filters" 8 | require_relative "active_hash_relation/filter_applier" 9 | 10 | require_relative "active_hash_relation/aggregation" 11 | 12 | module ActiveHashRelation 13 | class << self 14 | attr_accessor :configuration 15 | end 16 | 17 | def self.configure 18 | self.configuration 19 | yield(configuration) 20 | end 21 | 22 | def self.configuration 23 | @configuration ||= Configuration.new do 24 | self.has_filter_classes = false 25 | self.filter_active_record_scopes = false 26 | end 27 | end 28 | 29 | def self.initialize! 30 | if self.configuration.filter_active_record_scopes 31 | require_relative "active_record/scope_names" 32 | end 33 | end 34 | 35 | def apply_filters(resource, params, include_associations: false, model: nil) 36 | FilterApplier.new( 37 | resource, 38 | params, 39 | include_associations: include_associations, 40 | model: model 41 | ).apply_filters 42 | end 43 | 44 | def aggregations(resource, params, model: nil) 45 | Aggregation.new(resource, params, model: model).apply 46 | end 47 | 48 | class Configuration 49 | attr_accessor :has_filter_classes, :filter_class_prefix, :filter_class_suffix, 50 | :use_unscoped, :filter_active_record_scopes 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/active_hash_relation/association_filters.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation::AssociationFilters 2 | def filter_associations(resource, params, model = nil) 3 | unless model 4 | model = model_class_name(resource) 5 | if model.nil? || engine_name == model.to_s 6 | model = model_class_name(resource, true) 7 | end 8 | end 9 | 10 | model.reflect_on_all_associations.each do |assoc| 11 | association = assoc.name 12 | 13 | if params[association] 14 | association_name = association.to_s.titleize.split.join 15 | association_class = assoc.class_name.constantize 16 | 17 | if self.configuration.has_filter_classes 18 | if self.configuration.use_unscoped 19 | association_filters = self.filter_class(association_name).new( 20 | association_class.unscoped.all, 21 | params[association] 22 | ).apply_filters 23 | else 24 | association_filters = self.filter_class(association_name).new( 25 | association_class.all, 26 | params[association] 27 | ).apply_filters 28 | end 29 | else 30 | if self.configuration.use_unscoped 31 | association_filters = ActiveHashRelation::FilterApplier.new( 32 | association_class.unscoped.all, 33 | params[association], 34 | include_associations: true 35 | ).apply_filters 36 | else 37 | association_filters = ActiveHashRelation::FilterApplier.new( 38 | association_class.all, 39 | params[association], 40 | include_associations: true 41 | ).apply_filters 42 | end 43 | end 44 | resource = resource.joins(association).merge(association_filters) 45 | end 46 | end 47 | 48 | return resource 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure public file server for tests with Cache-Control for performance. 16 | config.public_file_server.enabled = true 17 | config.public_file_server.headers = { 18 | 'Cache-Control' => 'public, max-age=3600' 19 | } 20 | 21 | # Show full error reports and disable caching. 22 | config.consider_all_requests_local = true 23 | config.action_controller.perform_caching = false 24 | 25 | # Raise exceptions instead of rendering exception templates. 26 | config.action_dispatch.show_exceptions = false 27 | 28 | # Disable request forgery protection in test environment. 29 | config.action_controller.allow_forgery_protection = false 30 | config.action_mailer.perform_caching = false 31 | 32 | # Tell Action Mailer not to deliver emails to the real world. 33 | # The :test delivery method accumulates sent emails in the 34 | # ActionMailer::Base.deliveries array. 35 | config.action_mailer.delivery_method = :test 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /spec/db/migrate/001_basic_schema.rb: -------------------------------------------------------------------------------- 1 | class BasicSchema < ActiveRecord::Migration[5.2] 2 | def change 3 | create_table "microposts", force: :cascade do |t| 4 | t.text "content", null: false 5 | t.integer "user_id", null: false 6 | t.integer "likes", null: false, default: 0 7 | t.integer "reposts", null: false, default: 0 8 | t.integer "status", null: false, default: 0 9 | 10 | t.timestamps null: false 11 | 12 | t.index ["user_id"], name: "index_microposts_on_user_id", using: :btree 13 | end 14 | 15 | create_table "relationships", force: :cascade do |t| 16 | t.integer "follower_id" 17 | t.integer "followed_id" 18 | 19 | t.timestamps null: false 20 | 21 | t.index ["followed_id"], name: "index_relationships_on_followed_id", using: :btree 22 | t.index ["follower_id", "followed_id"], name: "index_relationships_on_follower_id_and_followed_id", unique: true, using: :btree 23 | t.index ["follower_id"], name: "index_relationships_on_follower_id", using: :btree 24 | end 25 | 26 | create_table "users", force: :cascade do |t| 27 | t.string "name" 28 | t.string "email", null: false 29 | t.boolean "admin", default: false 30 | t.boolean "verified", default: false 31 | t.string "token", null: false 32 | t.integer "microposts_count", default: 0, null: false 33 | t.integer "followers_count", default: 0, null: false 34 | t.integer "followings_count", default: 0, null: false 35 | 36 | t.timestamps null: false 37 | 38 | t.index ["email"], name: "index_users_on_email", unique: true, using: :btree 39 | end 40 | 41 | create_table :addresses do |t| 42 | t.string :street 43 | t.string :city 44 | t.string :country 45 | t.integer :user_id 46 | 47 | t.timestamps 48 | t.index ["user_id"], name: "index_addresses_on_user_id", unique: true, using: :btree 49 | end 50 | 51 | add_foreign_key "microposts", "users" 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/active_hash_relation/aggregation.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation 2 | class Aggregation 3 | include Helpers 4 | 5 | attr_reader :configuration, :params, :resource, :model 6 | 7 | def initialize(resource, params, model: nil) 8 | @configuration = Module.nesting.last.configuration 9 | @resource = resource 10 | @params = HashWithIndifferentAccess.new(params) 11 | @model = model 12 | 13 | unless @model 14 | @model = model_class_name(@resource) 15 | if @model.nil? || engine_name == @model.to_s 16 | @model = model_class_name(@resource, true) 17 | end 18 | end 19 | end 20 | 21 | def apply 22 | if params[:aggregate].is_a? Hash 23 | meta_attributes = HashWithIndifferentAccess.new 24 | 25 | @model.columns.each do |c| 26 | next unless params[:aggregate][c.name.to_s].is_a? Hash 27 | 28 | case c.type 29 | when :integer, :float, :decimal 30 | meta_attributes[c.name.to_s] = apply_aggregations( 31 | {avg: :average, sum: :sum, max: :maximum, min: :minimum}, 32 | params[:aggregate][c.name.to_s], 33 | c.name.to_s 34 | ) 35 | when :date, :datetime, :timestamp 36 | meta_attributes[c.name.to_s] = apply_aggregations( 37 | {max: :maximum, min: :minimum}, 38 | params[:aggregate][c.name.to_s], 39 | c.name.to_s 40 | ) 41 | end 42 | end 43 | end 44 | 45 | return meta_attributes 46 | end 47 | 48 | def apply_aggregations(available_aggr, asked_aggr, column) 49 | meta_attributes = HashWithIndifferentAccess.new 50 | 51 | available_aggr.each do |k, v| 52 | if asked_aggr[k] 53 | meta_attributes[k] = resource.send(v,column) 54 | meta_attributes[k] = meta_attributes[k].to_f if meta_attributes[k].is_a? BigDecimal 55 | end 56 | end 57 | 58 | return meta_attributes 59 | end 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports. 13 | config.consider_all_requests_local = true 14 | 15 | # Enable/disable caching. By default caching is disabled. 16 | if Rails.root.join('tmp/caching-dev.txt').exist? 17 | config.action_controller.perform_caching = true 18 | 19 | config.cache_store = :memory_store 20 | config.public_file_server.headers = { 21 | 'Cache-Control' => 'public, max-age=172800' 22 | } 23 | else 24 | config.action_controller.perform_caching = false 25 | 26 | config.cache_store = :null_store 27 | end 28 | 29 | # Don't care if the mailer can't send. 30 | config.action_mailer.raise_delivery_errors = false 31 | 32 | config.action_mailer.perform_caching = false 33 | 34 | # Print deprecation notices to the Rails logger. 35 | config.active_support.deprecation = :log 36 | 37 | # Raise an error on page load if there are pending migrations. 38 | config.active_record.migration_error = :page_load 39 | 40 | # Debug mode disables concatenation and preprocessing of assets. 41 | # This option may cause significant delays in view rendering with a large 42 | # number of complex assets. 43 | config.assets.debug = true 44 | 45 | # Suppress logger output for asset requests. 46 | config.assets.quiet = true 47 | 48 | # Raises error for missing translations 49 | # config.action_view.raise_on_missing_translations = true 50 | 51 | # Use an evented file watcher to asynchronously detect changes in source code, 52 | # routes, locales, etc. This feature depends on the listen gem. 53 | config.file_watcher = ActiveSupport::EventedFileUpdateChecker 54 | end 55 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/puma.rb: -------------------------------------------------------------------------------- 1 | # Puma can serve each request in a thread from an internal thread pool. 2 | # The `threads` method setting takes two numbers a minimum and maximum. 3 | # Any libraries that use thread pools should be configured to match 4 | # the maximum value specified for Puma. Default is set to 5 threads for minimum 5 | # and maximum, this matches the default thread size of Active Record. 6 | # 7 | threads_count = ENV.fetch("RAILS_MAX_THREADS") { 5 }.to_i 8 | threads threads_count, threads_count 9 | 10 | # Specifies the `port` that Puma will listen on to receive requests, default is 3000. 11 | # 12 | port ENV.fetch("PORT") { 3000 } 13 | 14 | # Specifies the `environment` that Puma will run in. 15 | # 16 | environment ENV.fetch("RAILS_ENV") { "development" } 17 | 18 | # Specifies the number of `workers` to boot in clustered mode. 19 | # Workers are forked webserver processes. If using threads and workers together 20 | # the concurrency of the application would be max `threads` * `workers`. 21 | # Workers do not work on JRuby or Windows (both of which do not support 22 | # processes). 23 | # 24 | # workers ENV.fetch("WEB_CONCURRENCY") { 2 } 25 | 26 | # Use the `preload_app!` method when specifying a `workers` number. 27 | # This directive tells Puma to first boot the application and load code 28 | # before forking the application. This takes advantage of Copy On Write 29 | # process behavior so workers use less memory. If you use this option 30 | # you need to make sure to reconnect any threads in the `on_worker_boot` 31 | # block. 32 | # 33 | # preload_app! 34 | 35 | # The code in the `on_worker_boot` will be called if you are using 36 | # clustered mode by specifying a number of `workers`. After each worker 37 | # process is booted this block will be run, if you are using `preload_app!` 38 | # option you will want to use this block to reconnect to any threads 39 | # or connections that may have been created at application boot, Ruby 40 | # cannot share connections between processes. 41 | # 42 | # on_worker_boot do 43 | # ActiveRecord::Base.establish_connection if defined?(ActiveRecord) 44 | # end 45 | 46 | # Allow puma to be restarted by `rails restart` command. 47 | plugin :tmp_restart 48 | -------------------------------------------------------------------------------- /spec/tests/strings_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'strings' do 5 | it "one where clause" do 6 | hash = {name: 'Filippos'} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users WHERE (users.name = 'Filippos')" 11 | ) 12 | 13 | expect(strip(query)).to eq expected_query.to_s 14 | end 15 | 16 | it "multiple where clause" do 17 | hash = {name: 'Filippos', token: '123'} 18 | 19 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 20 | expected_query = q( 21 | "SELECT users.* FROM users WHERE (users.name = 'Filippos')", 22 | "AND (users.token = '123')" 23 | ) 24 | 25 | expect(strip(query)).to eq expected_query.to_s 26 | end 27 | 28 | it "multiple values" do 29 | hash = {name: ['Filippos', 'Vasilakis']} 30 | 31 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 32 | expected_query = q( 33 | "SELECT users.* FROM users", 34 | "WHERE (users.name IN ('Filippos', 'Vasilakis'))" 35 | ) 36 | 37 | expect(strip(query)).to eq expected_query.to_s 38 | end 39 | 40 | context "queries" do 41 | it 'eq' do 42 | hash = {name: {eq: 'Filippos'}} 43 | 44 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 45 | expected_query = q( 46 | "SELECT users.* FROM users WHERE (users.name = 'Filippos')" 47 | ) 48 | 49 | expect(strip(query)).to eq expected_query.to_s 50 | end 51 | 52 | it 'starts_with' do 53 | hash = {name: {starts_with: 'Filippos'}} 54 | 55 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 56 | expected_query = q( 57 | "SELECT users.* FROM users WHERE (users.name LIKE 'Filippos%')" 58 | ) 59 | 60 | expect(strip(query)).to eq expected_query.to_s 61 | end 62 | 63 | it 'ends_with' do 64 | hash = {name: {ends_with: 'Filippos'}} 65 | 66 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 67 | expected_query = q( 68 | "SELECT users.* FROM users WHERE (users.name LIKE '%Filippos')" 69 | ) 70 | 71 | expect(strip(query)).to eq expected_query.to_s 72 | end 73 | 74 | it 'like' do 75 | hash = {name: {like: 'Filippos'}} 76 | 77 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 78 | expected_query = q( 79 | "SELECT users.* FROM users WHERE (users.name LIKE '%Filippos%')" 80 | ) 81 | 82 | expect(strip(query)).to eq expected_query.to_s 83 | end 84 | end 85 | end 86 | end 87 | -------------------------------------------------------------------------------- /spec/dummy-rails5/db/schema.rb: -------------------------------------------------------------------------------- 1 | # This file is auto-generated from the current state of the database. Instead 2 | # of editing this file, please use the migrations feature of Active Record to 3 | # incrementally modify your database, and then regenerate this schema definition. 4 | # 5 | # Note that this schema.rb definition is the authoritative source for your 6 | # database schema. If you need to create the application database on another 7 | # system, you should be using db:schema:load, not running all the migrations 8 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 9 | # you'll amass, the slower it'll run and the greater likelihood for issues). 10 | # 11 | # It's strongly recommended that you check this file into your version control system. 12 | 13 | ActiveRecord::Schema.define(version: 1) do 14 | 15 | # These are extensions that must be enabled in order to support this database 16 | enable_extension "plpgsql" 17 | 18 | create_table "addresses", force: :cascade do |t| 19 | t.string "street" 20 | t.string "city" 21 | t.string "country" 22 | t.integer "user_id" 23 | t.datetime "created_at" 24 | t.datetime "updated_at" 25 | t.index ["user_id"], name: "index_addresses_on_user_id", unique: true, using: :btree 26 | end 27 | 28 | create_table "microposts", force: :cascade do |t| 29 | t.text "content", null: false 30 | t.integer "user_id", null: false 31 | t.integer "likes", default: 0, null: false 32 | t.integer "reposts", default: 0, null: false 33 | t.integer "status", default: 0, null: false 34 | t.datetime "created_at", null: false 35 | t.datetime "updated_at", null: false 36 | t.index ["user_id"], name: "index_microposts_on_user_id", using: :btree 37 | end 38 | 39 | create_table "relationships", force: :cascade do |t| 40 | t.integer "follower_id" 41 | t.integer "followed_id" 42 | t.datetime "created_at", null: false 43 | t.datetime "updated_at", null: false 44 | t.index ["followed_id"], name: "index_relationships_on_followed_id", using: :btree 45 | t.index ["follower_id", "followed_id"], name: "index_relationships_on_follower_id_and_followed_id", unique: true, using: :btree 46 | t.index ["follower_id"], name: "index_relationships_on_follower_id", using: :btree 47 | end 48 | 49 | create_table "users", force: :cascade do |t| 50 | t.string "name" 51 | t.string "email", null: false 52 | t.boolean "admin", default: false 53 | t.boolean "verified", default: false 54 | t.string "token", null: false 55 | t.integer "microposts_count", default: 0, null: false 56 | t.integer "followers_count", default: 0, null: false 57 | t.integer "followings_count", default: 0, null: false 58 | t.datetime "created_at", null: false 59 | t.datetime "updated_at", null: false 60 | t.index ["email"], name: "index_users_on_email", unique: true, using: :btree 61 | end 62 | 63 | add_foreign_key "microposts", "users" 64 | end 65 | -------------------------------------------------------------------------------- /spec/tests/or_filter_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | if ActiveRecord::VERSION::MAJOR < 5 5 | context 'OR filter' do 6 | it "one OR clause" do 7 | logger = double('logger') 8 | allow(logger).to receive(:warn) 9 | allow(Rails).to receive(:logger).and_return(logger) 10 | 11 | hash = {or: [{name: 'Filippos'}, {name: 'Vasilis'}]} 12 | 13 | HelperClass.new.apply_filters(User.all, hash).to_sql 14 | expect(logger).to have_received(:warn) 15 | end 16 | end 17 | 18 | else 19 | context 'OR filter' do 20 | it "one OR clause" do 21 | hash = {or: [{name: 'Filippos'}, {name: 'Vasilis'}]} 22 | 23 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 24 | expected_query = q( 25 | "SELECT users.* FROM users", 26 | "WHERE ((users.name = 'Filippos') OR (users.name = 'Vasilis'))" 27 | ) 28 | 29 | expect(strip(query)).to eq expected_query.to_s 30 | end 31 | 32 | it "multiple OR clauses" do 33 | hash = {or: [{or: [{name: 'Filippos'}, {name: 'Vasilis'}]}, {or: [{id: 1}, {id: 2}]}]} 34 | 35 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 36 | expected_query = q( 37 | "SELECT users.* FROM users", 38 | "WHERE", 39 | "(", 40 | "((users.name = 'Filippos') OR (users.name = 'Vasilis'))", 41 | "OR", 42 | "((users.id = 1) OR (users.id = 2))", 43 | ")" 44 | ) 45 | 46 | expect(strip(query)).to eq expected_query.to_s 47 | end 48 | 49 | it "one complex OR clause" do 50 | hash = {or: [{name: 'Filippos', token: '123'}, {name: 'Vasilis'}]} 51 | 52 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 53 | expected_query = q( 54 | "SELECT users.* FROM users", 55 | "WHERE", 56 | "(", 57 | "(users.name = 'Filippos') AND (users.token = '123')", 58 | "OR", 59 | "(users.name = 'Vasilis')", 60 | ")" 61 | ) 62 | 63 | expect(strip(query)).to eq expected_query.to_s 64 | end 65 | 66 | it "nested OR clause" do 67 | hash = {or: [{or: [{name: 'Filippos'}, {token: '123'}]}, {name: 'Vasilis'}]} 68 | 69 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 70 | expected_query = q( 71 | "SELECT users.* FROM users", 72 | "WHERE", 73 | "(", 74 | "((users.name = 'Filippos') OR (users.token = '123'))", 75 | "OR", 76 | "(users.name = 'Vasilis')", 77 | ")" 78 | ) 79 | 80 | expect(strip(query)).to eq expected_query.to_s 81 | end 82 | 83 | it "OR clause on associations" do 84 | hash = {microposts: {or: [{content: 'Sveavägen 4'}, {id: 1}]}} 85 | 86 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 87 | expected_query = q( 88 | "SELECT users.* FROM users", 89 | "INNER JOIN microposts ON microposts.user_id = users.id", 90 | "WHERE ((microposts.content = 'Sveavägen 4') OR (microposts.id = 1))" 91 | ) 92 | 93 | expect(strip(query)).to eq expected_query.to_s 94 | end 95 | end 96 | end 97 | end 98 | 99 | -------------------------------------------------------------------------------- /spec/tests/associations/has_one_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'associations' do 5 | context 'has_one' do 6 | it "one where clause" do 7 | hash = {address: {street: 'Sveavägen 4' }} 8 | 9 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 10 | expected_query = q( 11 | "SELECT users.* FROM users", 12 | "INNER JOIN addresses ON addresses.user_id = users.id", 13 | "WHERE (addresses.street = 'Sveavägen 4')" 14 | ) 15 | 16 | expect(strip(query)).to eq expected_query.to_s 17 | end 18 | 19 | it "multiple where clause" do 20 | hash = {address: {street: 'Sveavägen 4', country: 'SE' }} 21 | 22 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 23 | expected_query = q( 24 | "SELECT users.* FROM users", 25 | "INNER JOIN addresses ON addresses.user_id = users.id", 26 | "WHERE (addresses.street = 'Sveavägen 4')", 27 | "AND (addresses.country = 'SE')" 28 | ) 29 | 30 | expect(strip(query)).to eq expected_query.to_s 31 | end 32 | 33 | it "multiple queries" do 34 | hash = {address: {street: {like: 'sveav', with_ilike: true}, city: {starts_with: 'New'} }} 35 | 36 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 37 | expected_query = q( 38 | "SELECT users.* FROM users", 39 | "INNER JOIN addresses ON addresses.user_id = users.id", 40 | "WHERE (addresses.street ILIKE '%sveav%')", 41 | "AND (addresses.city LIKE 'New%')" 42 | ) 43 | 44 | expect(strip(query)).to eq expected_query.to_s 45 | end 46 | 47 | it "scope" do 48 | hash = {address: {scopes: {in_sweden: true} }} 49 | 50 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 51 | expected_query = q( 52 | "SELECT users.* FROM users", 53 | "INNER JOIN addresses ON addresses.user_id = users.id", 54 | "WHERE addresses.country = 'SE'" 55 | ) 56 | 57 | expect(strip(query)).to eq expected_query.to_s 58 | end 59 | 60 | it "null" do 61 | hash = {address: {street: {null: false} }} 62 | 63 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 64 | expected_query = q( 65 | "SELECT users.* FROM users", 66 | "INNER JOIN addresses ON addresses.user_id = users.id", 67 | "WHERE (addresses.street IS NOT NULL)" 68 | ) 69 | 70 | expect(strip(query)).to eq expected_query.to_s 71 | end 72 | 73 | it "sorting" do 74 | hash = {address: {street: {like: 'svea'}, sort: {property: :street, order: :asc} }} 75 | 76 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 77 | expected_query = q( 78 | "SELECT users.* FROM users", 79 | "INNER JOIN addresses ON addresses.user_id = users.id", 80 | "WHERE (addresses.street LIKE '%svea%')", 81 | "ORDER BY addresses.street ASC" 82 | ) 83 | 84 | expect(strip(query)).to eq expected_query.to_s 85 | end 86 | end 87 | end 88 | end 89 | 90 | -------------------------------------------------------------------------------- /spec/tests/associations/belongs_to_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'associations' do 5 | context 'belongs_to' do 6 | it "one where clause" do 7 | hash = {user: {name: 'Filippos' }} 8 | 9 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 10 | expected_query = q( 11 | "SELECT microposts.* FROM microposts", 12 | "INNER JOIN users ON users.id = microposts.user_id", 13 | "WHERE (users.name = 'Filippos')" 14 | ) 15 | 16 | expect(strip(query)).to eq expected_query.to_s 17 | end 18 | it "multiple where clause" do 19 | hash = {user: {name: 'Filippos', verified: true }} 20 | 21 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 22 | expected_query = q( 23 | "SELECT microposts.* FROM microposts", 24 | "INNER JOIN users ON users.id = microposts.user_id", 25 | "WHERE (users.name = 'Filippos')", 26 | "AND users.verified = TRUE" 27 | ) 28 | 29 | expect(strip(query)).to eq expected_query.to_s 30 | end 31 | 32 | it "multiple queries" do 33 | date = DateTime.now.utc.to_s 34 | hash = { 35 | user: { 36 | name: {starts_with: 'filippos', with_ilike: true}, 37 | created_at: {leq: date} 38 | } 39 | } 40 | 41 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 42 | expected_query = q( 43 | "SELECT microposts.* FROM microposts", 44 | "INNER JOIN users ON users.id = microposts.user_id", 45 | "WHERE (users.name ILIKE 'filippos%')", 46 | "AND (users.created_at <= '#{date}')" 47 | ) 48 | 49 | expect(strip(query)).to eq expected_query.to_s 50 | end 51 | 52 | it "scope" do 53 | hash = {user: {scopes: {unverified: true} }} 54 | 55 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 56 | expected_query = q( 57 | "SELECT microposts.* FROM microposts", 58 | "INNER JOIN users ON users.id = microposts.user_id", 59 | "WHERE users.verified = FALSE" 60 | ) 61 | 62 | expect(strip(query)).to eq expected_query.to_s 63 | end 64 | 65 | it "null" do 66 | hash = {user: {name: {null: true} }} 67 | 68 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 69 | expected_query = q( 70 | "SELECT microposts.* FROM microposts", 71 | "INNER JOIN users ON users.id = microposts.user_id", 72 | "WHERE (users.name IS NULL)" 73 | ) 74 | 75 | expect(strip(query)).to eq expected_query.to_s 76 | end 77 | 78 | it "sorting" do 79 | hash = {user: {name: 'Filippos', sort: {property: :created_at} }} 80 | 81 | query = HelperClass.new.apply_filters(Micropost.all, hash, include_associations: true).to_sql 82 | expected_query = q( 83 | "SELECT microposts.* FROM microposts", 84 | "INNER JOIN users ON users.id = microposts.user_id", 85 | "WHERE (users.name = 'Filippos')", 86 | "ORDER BY users.created_at DESC" 87 | ) 88 | 89 | expect(strip(query)).to eq expected_query.to_s 90 | end 91 | end 92 | end 93 | end 94 | 95 | 96 | 97 | -------------------------------------------------------------------------------- /spec/tests/associations/has_many_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'associations' do 5 | context 'has_many' do 6 | it "one where clause" do 7 | hash = {microposts: {content: 'Sveavägen 4' }} 8 | 9 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 10 | expected_query = q( 11 | "SELECT users.* FROM users", 12 | "INNER JOIN microposts ON microposts.user_id = users.id", 13 | "WHERE (microposts.content = 'Sveavägen 4')" 14 | ) 15 | 16 | expect(strip(query)).to eq expected_query.to_s 17 | end 18 | 19 | it "multiple where clause" do 20 | hash = {microposts: {content: 'Sveavägen 4', created_at: '2017-01-15 16:11:06 UTC' }} 21 | 22 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 23 | expected_query = q( 24 | "SELECT users.* FROM users", 25 | "INNER JOIN microposts ON microposts.user_id = users.id", 26 | "WHERE (microposts.content = 'Sveavägen 4')", 27 | "AND microposts.created_at = '2017-01-15 16:11:06'" 28 | ) 29 | 30 | expect(strip(query)).to eq expected_query.to_s 31 | end 32 | 33 | it "multiple queries" do 34 | hash = {microposts: {content: {ends_with: '4'}, created_at: {leq: '2017-01-15 16:11:06 UTC'} }} 35 | 36 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 37 | expected_query = q( 38 | "SELECT users.* FROM users", 39 | "INNER JOIN microposts ON microposts.user_id = users.id", 40 | "WHERE (microposts.content LIKE '%4')", 41 | "AND (microposts.created_at <= '2017-01-15 16:11:06 UTC')" 42 | ) 43 | 44 | expect(strip(query)).to eq expected_query.to_s 45 | end 46 | 47 | it "scope" do 48 | date = DateTime.now.to_date.to_s 49 | hash = {microposts: {scopes: {created_on: [date] }}} 50 | 51 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 52 | expected_query = q( 53 | "SELECT users.* FROM users", 54 | "INNER JOIN microposts ON microposts.user_id = users.id", 55 | "WHERE (date(microposts.created_at) = '#{date}')" 56 | ) 57 | 58 | expect(strip(query)).to eq expected_query.to_s 59 | end 60 | 61 | it "null" do 62 | hash = {microposts: {content: {null: true} }} 63 | 64 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 65 | expected_query = q( 66 | "SELECT users.* FROM users", 67 | "INNER JOIN microposts ON microposts.user_id = users.id", 68 | "WHERE (microposts.content IS NULL)" 69 | ) 70 | 71 | expect(strip(query)).to eq expected_query.to_s 72 | end 73 | 74 | it "sorting" do 75 | hash = {microposts: {content: 'Sveavägen 4', sort: {property: :created_at} }} 76 | 77 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 78 | expected_query = q( 79 | "SELECT users.* FROM users", 80 | "INNER JOIN microposts ON microposts.user_id = users.id", 81 | "WHERE (microposts.content = 'Sveavägen 4')", 82 | "ORDER BY microposts.created_at DESC" 83 | ) 84 | 85 | expect(strip(query)).to eq expected_query.to_s 86 | end 87 | end 88 | end 89 | end 90 | 91 | 92 | -------------------------------------------------------------------------------- /spec/tests/not_filter_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'NOT filter' do 5 | it "one NOT clause" do 6 | hash = {not: {name: 'Filippos'}} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users WHERE NOT (users.name = 'Filippos')" 11 | ) 12 | 13 | expect(strip(query)).to eq expected_query.to_s 14 | end 15 | 16 | it "multiple NOT clauses" do 17 | hash = {not: {name: 'Filippos', email: 'vasilakisfil@gmail.com'}} 18 | 19 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 20 | expected_query = q( 21 | "SELECT users.* FROM users WHERE", 22 | "NOT (users.name = 'Filippos')", 23 | "AND", 24 | "NOT (users.email = 'vasilakisfil@gmail.com')" 25 | ) 26 | 27 | expect(strip(query)).to eq expected_query.to_s 28 | end 29 | 30 | if ActiveRecord::VERSION::MAJOR >= 5 31 | it "NOT clause inside OR clause" do 32 | hash = {or: [{not: {name: 'Filippos', token: '123'}}, {not: {name: 'Vasilis'}}]} 33 | 34 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 35 | expected_query = q( 36 | "SELECT users.* FROM users", 37 | "WHERE", 38 | "(", 39 | "NOT (users.name = 'Filippos') AND NOT (users.token = '123')", 40 | "OR", 41 | "NOT (users.name = 'Vasilis')", 42 | ")" 43 | ) 44 | 45 | expect(strip(query)).to eq expected_query.to_s 46 | end 47 | end 48 | 49 | it "complex NOT clause" do 50 | hash = {not: {name: 'Filippos', email: {ends_with: '@gmail.com'}}} 51 | 52 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 53 | expected_query = q( 54 | "SELECT users.* FROM users", 55 | "WHERE", 56 | "NOT (users.name = 'Filippos')", 57 | "AND", 58 | "NOT (users.email LIKE '%@gmail.com')", 59 | ) 60 | 61 | expect(strip(query)).to eq expected_query.to_s 62 | end 63 | 64 | context "on NULL" do 65 | it "NOT clause on null" do 66 | hash = {not: {name: {null: true}}} 67 | 68 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 69 | expected_query = q( 70 | "SELECT users.* FROM users WHERE NOT (users.name IS NULL)" 71 | ) 72 | 73 | expect(strip(query)).to eq expected_query.to_s 74 | end 75 | 76 | it "NOT clause on not null" do 77 | hash = {not: {name: {null: false}}} 78 | 79 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 80 | expected_query = q( 81 | "SELECT users.* FROM users WHERE NOT (users.name IS NOT NULL)" 82 | ) 83 | 84 | expect(strip(query)).to eq expected_query.to_s 85 | end 86 | end 87 | 88 | it "NOT clause on associations" do 89 | hash = {microposts: {not: {content: 'Sveavägen 4', id: 1}}} 90 | 91 | query = HelperClass.new.apply_filters(User.all, hash, include_associations: true).to_sql 92 | expected_query = q( 93 | "SELECT users.* FROM users", 94 | "INNER JOIN microposts ON microposts.user_id = users.id", 95 | "WHERE", 96 | "NOT (microposts.id = 1)", 97 | "AND", 98 | "NOT (microposts.content = 'Sveavägen 4')" 99 | ) 100 | 101 | expect(strip(query)).to eq expected_query.to_s 102 | end 103 | 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /spec/dummy-rails5/config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Disable serving static files from the `/public` folder by default since 18 | # Apache or NGINX already handles this. 19 | config.public_file_server.enabled = ENV['RAILS_SERVE_STATIC_FILES'].present? 20 | 21 | # Compress JavaScripts and CSS. 22 | config.assets.js_compressor = :uglifier 23 | # config.assets.css_compressor = :sass 24 | 25 | # Do not fallback to assets pipeline if a precompiled asset is missed. 26 | config.assets.compile = false 27 | 28 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 29 | 30 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 31 | # config.action_controller.asset_host = 'http://assets.example.com' 32 | 33 | # Specifies the header that your server uses for sending files. 34 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 35 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 36 | 37 | # Mount Action Cable outside main process or domain 38 | # config.action_cable.mount_path = nil 39 | # config.action_cable.url = 'wss://example.com/cable' 40 | # config.action_cable.allowed_request_origins = [ 'http://example.com', /http:\/\/example.*/ ] 41 | 42 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 43 | # config.force_ssl = true 44 | 45 | # Use the lowest log level to ensure availability of diagnostic information 46 | # when problems arise. 47 | config.log_level = :debug 48 | 49 | # Prepend all log lines with the following tags. 50 | config.log_tags = [ :request_id ] 51 | 52 | # Use a different cache store in production. 53 | # config.cache_store = :mem_cache_store 54 | 55 | # Use a real queuing backend for Active Job (and separate queues per environment) 56 | # config.active_job.queue_adapter = :resque 57 | # config.active_job.queue_name_prefix = "dummy_rails5_#{Rails.env}" 58 | config.action_mailer.perform_caching = false 59 | 60 | # Ignore bad email addresses and do not raise email delivery errors. 61 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 62 | # config.action_mailer.raise_delivery_errors = false 63 | 64 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 65 | # the I18n.default_locale when a translation cannot be found). 66 | config.i18n.fallbacks = true 67 | 68 | # Send deprecation notices to registered listeners. 69 | config.active_support.deprecation = :notify 70 | 71 | # Use default logging formatter so that PID and timestamp are not suppressed. 72 | config.log_formatter = ::Logger::Formatter.new 73 | 74 | # Use a different logger for distributed setups. 75 | # require 'syslog/logger' 76 | # config.logger = ActiveSupport::TaggedLogging.new(Syslog::Logger.new 'app-name') 77 | 78 | if ENV["RAILS_LOG_TO_STDOUT"].present? 79 | logger = ActiveSupport::Logger.new(STDOUT) 80 | logger.formatter = config.log_formatter 81 | config.logger = ActiveSupport::TaggedLogging.new(logger) 82 | end 83 | 84 | # Do not dump schema after migrations. 85 | config.active_record.dump_schema_after_migration = false 86 | end 87 | -------------------------------------------------------------------------------- /lib/active_hash_relation/filter_applier.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation 2 | class FilterApplier 3 | include Helpers 4 | include ColumnFilters 5 | include AssociationFilters 6 | include ScopeFilters 7 | include SortFilters 8 | include LimitFilters 9 | 10 | attr_reader :configuration 11 | 12 | def initialize(resource, params, include_associations: false, model: nil, is_not: false) 13 | @configuration = Module.nesting.last.configuration 14 | @resource = resource 15 | if params.respond_to?(:to_unsafe_h) 16 | @params = HashWithIndifferentAccess.new(params.to_unsafe_h) 17 | else 18 | @params = HashWithIndifferentAccess.new(params) 19 | end 20 | @include_associations = include_associations 21 | @model = find_model(model) 22 | is_not ? @is_not = true : @is_not = false 23 | end 24 | 25 | def apply_filters 26 | run_or_filters 27 | run_not_filters 28 | 29 | table_name = @model.table_name 30 | @model.columns.each do |c| 31 | next if @params[c.name.to_s].nil? 32 | next if @params[c.name.to_s].is_a?(String) && @params[c.name.to_s].blank? 33 | 34 | case c.type 35 | when :integer 36 | if @model.defined_enums[c.name] && @model.defined_enums[c.name][@params[c.name]] 37 | @params[c.name] = @model.defined_enums[c.name][@params[c.name]] 38 | end 39 | @resource = filter_integer(@resource, c.name, table_name, @params[c.name]) 40 | when :float 41 | @resource = filter_float(@resource, c.name, table_name, @params[c.name]) 42 | when :decimal 43 | @resource = filter_decimal(@resource, c.name, table_name, @params[c.name]) 44 | when :string, :uuid, :text 45 | @resource = filter_string(@resource, c.name, table_name, @params[c.name]) 46 | when :date 47 | @resource = filter_date(@resource, c.name, table_name, @params[c.name]) 48 | when :datetime, :timestamp 49 | @resource = filter_datetime(@resource, c.name, table_name, @params[c.name]) 50 | when :boolean 51 | @resource = filter_boolean(@resource, c.name, table_name, @params[c.name]) 52 | end 53 | end 54 | 55 | if @params.include?(:scopes) 56 | if ActiveHashRelation.configuration.filter_active_record_scopes 57 | @resource = filter_scopes(@resource, @params[:scopes], @model) 58 | else 59 | Rails.logger.warn('Ignoring ActiveRecord scope filters because they are not enabled') 60 | end 61 | end 62 | @resource = filter_associations(@resource, @params, @model) if @include_associations 63 | @resource = apply_limit(@resource, @params[:limit]) if @params.include?(:limit) 64 | @resource = apply_sort(@resource, @params[:sort], @model) if @params.include?(:sort) 65 | 66 | return @resource 67 | end 68 | 69 | def filter_class(resource_name) 70 | "#{configuration.filter_class_prefix}#{resource_name.pluralize}#{configuration.filter_class_suffix}".constantize 71 | end 72 | 73 | def run_or_filters 74 | if @params[:or].is_a?(Array) 75 | if ActiveRecord::VERSION::MAJOR < 5 76 | return Rails.logger.warn("OR query is supported on ActiveRecord 5+") 77 | end 78 | 79 | if @params[:or].length >= 2 80 | array = @params[:or].map do |or_param| 81 | self.class.new(@resource, or_param, include_associations: @include_associations).apply_filters 82 | end 83 | 84 | @resource = @resource.merge(array[0]) 85 | array[1..-1].each{|query| @resource = @resource.or(query)} 86 | else 87 | Rails.logger.warn("Can't run an OR with 1 element!") 88 | end 89 | end 90 | end 91 | 92 | def run_not_filters 93 | if @params[:not].is_a?(Hash) && !@params[:not].blank? 94 | @resource = self.class.new( 95 | @resource, 96 | @params[:not], 97 | include_associations: @include_associations, 98 | is_not: true 99 | ).apply_filters 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/tests/numbers_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'numbers' do 5 | it "one where clause" do 6 | hash = {microposts_count: 10} 7 | 8 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 9 | expected_query = q( 10 | "SELECT users.* FROM users WHERE (users.microposts_count = 10)" 11 | ) 12 | 13 | expect(strip(query)).to eq expected_query.to_s 14 | end 15 | 16 | it "multiple where clause" do 17 | hash = {microposts_count: 3, followers_count: 5} 18 | 19 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 20 | expected_query = q( 21 | "SELECT users.* FROM users", 22 | "WHERE (users.microposts_count = 3)", 23 | "AND (users.followers_count = 5)" 24 | ) 25 | 26 | expect(strip(query)).to eq expected_query.to_s 27 | end 28 | 29 | it "multiple values" do 30 | hash = {microposts_count: [3,4,5]} 31 | 32 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 33 | expected_query = q( 34 | "SELECT users.* FROM users", 35 | "WHERE (users.microposts_count IN (3, 4, 5))", 36 | ) 37 | 38 | expect(strip(query)).to eq expected_query.to_s 39 | end 40 | 41 | context "queries" do 42 | it 'eq' do 43 | hash = {microposts_count: {eq: 10}} 44 | 45 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 46 | expected_query = q( 47 | "SELECT users.* FROM users WHERE (users.microposts_count = 10)" 48 | ) 49 | 50 | expect(strip(query)).to eq expected_query.to_s 51 | end 52 | 53 | it 'le' do 54 | hash = {microposts_count: {le: 10}} 55 | 56 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 57 | expected_query = q( 58 | "SELECT users.* FROM users WHERE (users.microposts_count < 10)" 59 | ) 60 | 61 | expect(strip(query)).to eq expected_query.to_s 62 | end 63 | 64 | it 'leq' do 65 | hash = {microposts_count: {leq: 10}} 66 | 67 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 68 | expected_query = q( 69 | "SELECT users.* FROM users WHERE (users.microposts_count <= 10)" 70 | ) 71 | 72 | expect(strip(query)).to eq expected_query.to_s 73 | end 74 | 75 | it 'ge' do 76 | hash = {microposts_count: {ge: 10}} 77 | 78 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 79 | expected_query = q( 80 | "SELECT users.* FROM users WHERE (users.microposts_count > 10)" 81 | ) 82 | 83 | expect(strip(query)).to eq expected_query.to_s 84 | end 85 | 86 | it 'geq' do 87 | hash = {microposts_count: {geq: 10}} 88 | 89 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 90 | expected_query = q( 91 | "SELECT users.* FROM users WHERE (users.microposts_count >= 10)" 92 | ) 93 | 94 | expect(strip(query)).to eq expected_query.to_s 95 | end 96 | 97 | context 'combined' do 98 | it 'with a single column' do 99 | hash = {microposts_count: {geq: 10, le: 20}} 100 | 101 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 102 | expected_query = q( 103 | "SELECT users.* FROM users", 104 | "WHERE (users.microposts_count < 20)", 105 | "AND (users.microposts_count >= 10)" 106 | ) 107 | 108 | expect(strip(query)).to eq expected_query.to_s 109 | end 110 | 111 | it 'with multiple columns' do 112 | hash = { 113 | microposts_count: {geq: 10, le: 20}, 114 | followers_count: {leq: 50, ge: 0} 115 | } 116 | 117 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 118 | expected_query = q( 119 | "SELECT users.* FROM users", 120 | "WHERE (users.microposts_count < 20)", 121 | "AND (users.microposts_count >= 10)", 122 | "AND (users.followers_count <= 50)", 123 | "AND (users.followers_count > 0)" 124 | ) 125 | 126 | expect(strip(query)).to eq expected_query.to_s 127 | end 128 | end 129 | 130 | it 'rails enum' do 131 | hash = {status: 0} 132 | 133 | query = HelperClass.new.apply_filters(Micropost.all, hash).to_sql 134 | expected_query = q( 135 | "SELECT microposts.* FROM microposts WHERE (microposts.status = 0)" 136 | ) 137 | 138 | expect(strip(query)).to eq expected_query.to_s 139 | end 140 | 141 | it 'rails string enum' do 142 | hash = {status: 'published'} 143 | 144 | query = HelperClass.new.apply_filters(Micropost.all, hash).to_sql 145 | expected_query = q( 146 | "SELECT microposts.* FROM microposts WHERE (microposts.status = #{Micropost.statuses[:published]})" 147 | ) 148 | 149 | expect(strip(query)).to eq expected_query.to_s 150 | end 151 | end 152 | end 153 | end 154 | -------------------------------------------------------------------------------- /spec/dummy-rails5/spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rails generate rspec:install` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | RSpec.configure do |config| 20 | # rspec-expectations config goes here. You can use an alternate 21 | # assertion/expectation library such as wrong or the stdlib/minitest 22 | # assertions if you prefer. 23 | config.expect_with :rspec do |expectations| 24 | # This option will default to `true` in RSpec 4. It makes the `description` 25 | # and `failure_message` of custom matchers include text for helper methods 26 | # defined using `chain`, e.g.: 27 | # be_bigger_than(2).and_smaller_than(4).description 28 | # # => "be bigger than 2 and smaller than 4" 29 | # ...rather than: 30 | # # => "be bigger than 2" 31 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 32 | end 33 | 34 | # rspec-mocks config goes here. You can use an alternate test double 35 | # library (such as bogus or mocha) by changing the `mock_with` option here. 36 | config.mock_with :rspec do |mocks| 37 | # Prevents you from mocking or stubbing a method that does not exist on 38 | # a real object. This is generally recommended, and will default to 39 | # `true` in RSpec 4. 40 | mocks.verify_partial_doubles = true 41 | end 42 | 43 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 44 | # have no way to turn it off -- the option exists only for backwards 45 | # compatibility in RSpec 3). It causes shared context metadata to be 46 | # inherited by the metadata hash of host groups and examples, rather than 47 | # triggering implicit auto-inclusion in groups with matching metadata. 48 | config.shared_context_metadata_behavior = :apply_to_host_groups 49 | 50 | # The settings below are suggested to provide a good initial experience 51 | # with RSpec, but feel free to customize to your heart's content. 52 | =begin 53 | # This allows you to limit a spec run to individual examples or groups 54 | # you care about by tagging them with `:focus` metadata. When nothing 55 | # is tagged with `:focus`, all examples get run. RSpec also provides 56 | # aliases for `it`, `describe`, and `context` that include `:focus` 57 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 58 | config.filter_run_when_matching :focus 59 | 60 | # Allows RSpec to persist some state between runs in order to support 61 | # the `--only-failures` and `--next-failure` CLI options. We recommend 62 | # you configure your source control system to ignore this file. 63 | config.example_status_persistence_file_path = "spec/examples.txt" 64 | 65 | # Limits the available syntax to the non-monkey patched syntax that is 66 | # recommended. For more details, see: 67 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 68 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 69 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 70 | config.disable_monkey_patching! 71 | 72 | # Many RSpec users commonly either run the entire suite or an individual 73 | # file, and it's useful to allow more verbose output when running an 74 | # individual spec file. 75 | if config.files_to_run.one? 76 | # Use the documentation formatter for detailed output, 77 | # unless a formatter has already been configured 78 | # (e.g. via a command-line flag). 79 | config.default_formatter = 'doc' 80 | end 81 | 82 | # Print the 10 slowest examples and example groups at the 83 | # end of the spec run, to help surface which specs are running 84 | # particularly slow. 85 | config.profile_examples = 10 86 | 87 | # Run specs in random order to surface order dependencies. If you find an 88 | # order dependency and want to debug it, you can fix the order by providing 89 | # the seed, which is printed after each run. 90 | # --seed 1234 91 | config.order = :random 92 | 93 | # Seed global randomization in this process using the `--seed` CLI option. 94 | # Setting this allows you to use `--seed` to deterministically reproduce 95 | # test failures related to randomization by passing the same `--seed` value 96 | # as the one that triggered the failure. 97 | Kernel.srand config.seed 98 | =end 99 | end 100 | -------------------------------------------------------------------------------- /lib/active_hash_relation/column_filters.rb: -------------------------------------------------------------------------------- 1 | module ActiveHashRelation::ColumnFilters 2 | def filter_integer(resource, column, table_name, param) 3 | if param.is_a? Array 4 | n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! 5 | if @is_not 6 | return resource.where.not("#{table_name}.#{column} IN (#{n_param})") 7 | else 8 | return resource.where("#{table_name}.#{column} IN (#{n_param})") 9 | end 10 | elsif param.is_a? Hash 11 | if !param[:null].nil? 12 | return null_filters(resource, table_name, column, param) 13 | else 14 | return apply_leq_geq_le_ge_filters(resource, table_name, column, param) 15 | end 16 | else 17 | if @is_not 18 | return resource.where.not("#{table_name}.#{column} = ?", param) 19 | else 20 | return resource.where("#{table_name}.#{column} = ?", param) 21 | end 22 | end 23 | end 24 | 25 | def filter_float(resource, column, table_name, param) 26 | filter_integer(resource, column, table_name, param) 27 | end 28 | 29 | def filter_decimal(resource, column, table_name, param) 30 | filter_integer(resource, column, table_name, param) 31 | end 32 | 33 | def filter_string(resource, column, table_name, param) 34 | if param.is_a? Array 35 | n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! 36 | if @is_not 37 | return resource.where.not("#{table_name}.#{column} IN (#{n_param})") 38 | else 39 | return resource.where("#{table_name}.#{column} IN (#{n_param})") 40 | end 41 | elsif param.is_a? Hash 42 | if !param[:null].nil? 43 | return null_filters(resource, table_name, column, param) 44 | else 45 | return apply_like_filters(resource, table_name, column, param) 46 | end 47 | else 48 | if @is_not 49 | return resource.where.not("#{table_name}.#{column} = ?", param) 50 | else 51 | return resource.where("#{table_name}.#{column} = ?", param) 52 | end 53 | end 54 | end 55 | 56 | def filter_text(resource, column, table_name, param) 57 | return filter_string(resource, column, table_name, param) 58 | end 59 | 60 | def filter_date(resource, column, table_name, param) 61 | if param.is_a? Array 62 | n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! 63 | if @is_not 64 | return resource.where.not("#{table_name}.#{column} IN (#{n_param})") 65 | else 66 | return resource.where("#{table_name}.#{column} IN (#{n_param})") 67 | end 68 | elsif param.is_a? Hash 69 | if !param[:null].nil? 70 | return null_filters(resource, table_name, column, param) 71 | else 72 | return apply_leq_geq_le_ge_filters(resource, table_name, column, param) 73 | end 74 | else 75 | if @is_not 76 | resource = resource.where.not(column => param) 77 | else 78 | resource = resource.where(column => param) 79 | end 80 | end 81 | 82 | return resource 83 | end 84 | 85 | def filter_datetime(resource, column, table_name, param) 86 | if param.is_a? Array 87 | n_param = param.to_s.gsub("\"","'").gsub("[","").gsub("]","") #fix this! 88 | if @is_not 89 | return resource = resource.where.not("#{table_name}.#{column} IN (#{n_param})") 90 | else 91 | return resource = resource.where("#{table_name}.#{column} IN (#{n_param})") 92 | end 93 | elsif param.is_a? Hash 94 | if !param[:null].nil? 95 | return null_filters(resource, table_name, column, param) 96 | else 97 | return apply_leq_geq_le_ge_filters(resource, table_name, column, param) 98 | end 99 | else 100 | if @is_not 101 | resource = resource.where.not(column => param) 102 | else 103 | resource = resource.where(column => param) 104 | end 105 | end 106 | 107 | return resource 108 | end 109 | 110 | def filter_boolean(resource, column, table_name, param) 111 | if param.is_a?(Hash) && !param[:null].nil? 112 | return null_filters(resource, table_name, column, param) 113 | else 114 | if ActiveRecord::VERSION::MAJOR >= 5 115 | b_param = ActiveRecord::Type::Boolean.new.cast(param) 116 | else 117 | b_param = ActiveRecord::Type::Boolean.new.type_cast_from_database(param) 118 | end 119 | 120 | if @is_not 121 | resource = resource.where.not(column => b_param) 122 | else 123 | resource = resource.where(column => b_param) 124 | end 125 | end 126 | end 127 | 128 | private 129 | 130 | def apply_leq_geq_le_ge_filters(resource, table_name, column, param) 131 | return resource.where("#{table_name}.#{column} = ?", param[:eq]) if param[:eq] 132 | 133 | if !param[:leq].blank? 134 | if @is_not 135 | resource = resource.where.not("#{table_name}.#{column} <= ?", param[:leq]) 136 | else 137 | resource = resource.where("#{table_name}.#{column} <= ?", param[:leq]) 138 | end 139 | elsif !param[:le].blank? 140 | if @is_not 141 | resource = resource.where.not("#{table_name}.#{column} < ?", param[:le]) 142 | else 143 | resource = resource.where("#{table_name}.#{column} < ?", param[:le]) 144 | end 145 | end 146 | 147 | if !param[:geq].blank? 148 | if @is_not 149 | resource = resource.where.not("#{table_name}.#{column} >= ?", param[:geq]) 150 | else 151 | resource = resource.where("#{table_name}.#{column} >= ?", param[:geq]) 152 | end 153 | elsif !param[:ge].blank? 154 | if @is_not 155 | resource = resource.where.not("#{table_name}.#{column} > ?", param[:ge]) 156 | else 157 | resource = resource.where("#{table_name}.#{column} > ?", param[:ge]) 158 | end 159 | end 160 | 161 | return resource 162 | end 163 | 164 | def apply_like_filters(resource, table_name, column, param) 165 | like_method = "LIKE" 166 | like_method = "ILIKE" if param[:with_ilike] 167 | 168 | if !param[:starts_with].blank? 169 | if @is_not 170 | resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "#{param[:starts_with]}%") 171 | else 172 | resource = resource.where("#{table_name}.#{column} #{like_method} ?", "#{param[:starts_with]}%") 173 | end 174 | end 175 | 176 | if !param[:ends_with].blank? 177 | if @is_not 178 | resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "%#{param[:ends_with]}") 179 | else 180 | resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:ends_with]}") 181 | end 182 | end 183 | 184 | if !param[:like].blank? 185 | if @is_not 186 | resource = resource.where.not("#{table_name}.#{column} #{like_method} ?", "%#{param[:like]}%") 187 | else 188 | resource = resource.where("#{table_name}.#{column} #{like_method} ?", "%#{param[:like]}%") 189 | end 190 | end 191 | 192 | if !param[:eq].blank? 193 | if @is_not 194 | resource = resource.where.not("#{table_name}.#{column} = ?", param[:eq]) 195 | else 196 | resource = resource.where("#{table_name}.#{column} = ?", param[:eq]) 197 | end 198 | end 199 | 200 | return resource 201 | end 202 | 203 | def null_filters(resource, table_name, column, param) 204 | if param[:null] == true || param[:null] == 'true' || param[:null] == 1 || param[:null] == '1' 205 | if @is_not 206 | resource = resource.where.not("#{table_name}.#{column} IS NULL") 207 | else 208 | resource = resource.where("#{table_name}.#{column} IS NULL") 209 | end 210 | end 211 | 212 | if param[:null] == false || param[:null] == 'false' || param[:null] == 0 || param[:null] == '0' 213 | if @is_not 214 | resource = resource.where.not("#{table_name}.#{column} IS NOT NULL") 215 | else 216 | resource = resource.where("#{table_name}.#{column} IS NOT NULL") 217 | end 218 | end 219 | 220 | return resource 221 | end 222 | end 223 | -------------------------------------------------------------------------------- /spec/tests/sorting_spec.rb: -------------------------------------------------------------------------------- 1 | describe ActiveHashRelation do 2 | include Helpers 3 | 4 | context 'sorting' do 5 | 6 | context "one where clause" do 7 | it "asc" do 8 | hash = { 9 | microposts_count: 10, 10 | sort: {microposts_count: :asc} 11 | } 12 | 13 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 14 | expected_query = q( 15 | "SELECT users.* FROM users", 16 | "WHERE (users.microposts_count = 10)", 17 | "ORDER BY users.microposts_count ASC" 18 | ) 19 | 20 | expect(strip(query)).to eq expected_query.to_s 21 | end 22 | 23 | it "desc" do 24 | hash = { 25 | microposts_count: 10, 26 | sort: {microposts_count: :desc} 27 | } 28 | 29 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 30 | expected_query = q( 31 | "SELECT users.* FROM users", 32 | "WHERE (users.microposts_count = 10)", 33 | "ORDER BY users.microposts_count DESC" 34 | ) 35 | 36 | expect(strip(query)).to eq expected_query.to_s 37 | end 38 | end 39 | 40 | context "multiple where clauses" do 41 | it "asc" do 42 | hash = { 43 | followers_count: {leq: 20}, 44 | microposts_count: 10, 45 | sort: {microposts_count: :asc} 46 | } 47 | 48 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 49 | expected_query = q( 50 | "SELECT users.* FROM users", 51 | "WHERE (users.microposts_count = 10)", 52 | "AND (users.followers_count <= 20)", 53 | "ORDER BY users.microposts_count ASC" 54 | ) 55 | 56 | expect(strip(query)).to eq expected_query.to_s 57 | end 58 | 59 | it "desc" do 60 | hash = { 61 | followers_count: {leq: 20}, 62 | microposts_count: 10, 63 | sort: {microposts_count: :desc} 64 | } 65 | 66 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 67 | expected_query = q( 68 | "SELECT users.* FROM users", 69 | "WHERE (users.microposts_count = 10)", 70 | "AND (users.followers_count <= 20)", 71 | "ORDER BY users.microposts_count DESC" 72 | ) 73 | 74 | expect(strip(query)).to eq expected_query.to_s 75 | end 76 | end 77 | 78 | context "multiple sorting properties" do 79 | context "as a hashe" do 80 | it "with single where clause" do 81 | hash = { 82 | microposts_count: 10, 83 | sort: { 84 | microposts_count: :asc, 85 | followings_count: :desc 86 | } 87 | } 88 | 89 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 90 | expected_query = q( 91 | "SELECT users.* FROM users", 92 | "WHERE (users.microposts_count = 10)", 93 | "ORDER BY users.microposts_count ASC, users.followings_count DESC" 94 | ) 95 | 96 | expect(strip(query)).to eq expected_query.to_s 97 | end 98 | 99 | it "when the sorting column does not exist" do 100 | hash = { 101 | microposts_count: 10, 102 | sort: { 103 | i_do_not_exist: :asc, 104 | followings_count: :desc 105 | } 106 | } 107 | 108 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 109 | expected_query = q( 110 | "SELECT users.* FROM users", 111 | "WHERE (users.microposts_count = 10)", 112 | "ORDER BY users.followings_count DESC" 113 | ) 114 | 115 | expect(strip(query)).to eq expected_query.to_s 116 | end 117 | end 118 | 119 | context "as an array of hashes (not recommended)" do 120 | it "with single where clause" do 121 | hash = { 122 | microposts_count: 10, 123 | sort: [{ 124 | microposts_count: :asc, 125 | }, { 126 | followings_count: :desc 127 | }] 128 | } 129 | 130 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 131 | expected_query = q( 132 | "SELECT users.* FROM users", 133 | "WHERE (users.microposts_count = 10)", 134 | "ORDER BY users.microposts_count ASC, users.followings_count DESC" 135 | ) 136 | 137 | expect(strip(query)).to eq expected_query.to_s 138 | end 139 | 140 | it "when the sorting column does not exist" do 141 | hash = { 142 | microposts_count: 10, 143 | sort: [{ 144 | i_do_not_exist: :asc, 145 | }, { 146 | followings_count: :desc 147 | }] 148 | } 149 | 150 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 151 | expected_query = q( 152 | "SELECT users.* FROM users", 153 | "WHERE (users.microposts_count = 10)", 154 | "ORDER BY users.followings_count DESC" 155 | ) 156 | 157 | expect(strip(query)).to eq expected_query.to_s 158 | end 159 | end 160 | 161 | it "when the sorting column does not exist" do 162 | hash = { 163 | microposts_count: 10, 164 | sort: {i_do_not_exist: :asc} 165 | } 166 | 167 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 168 | expected_query = q( 169 | "SELECT users.* FROM users", 170 | "WHERE (users.microposts_count = 10)" 171 | ) 172 | 173 | expect(strip(query)).to eq expected_query.to_s 174 | 175 | end 176 | end 177 | 178 | context "deprecated API" do 179 | context "one where clause" do 180 | it "asc" do 181 | hash = { 182 | microposts_count: 10, 183 | sort: {property: :microposts_count, order: :asc} 184 | } 185 | 186 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 187 | expected_query = q( 188 | "SELECT users.* FROM users", 189 | "WHERE (users.microposts_count = 10)", 190 | "ORDER BY users.microposts_count ASC" 191 | ) 192 | 193 | expect(strip(query)).to eq expected_query.to_s 194 | end 195 | 196 | it "desc" do 197 | hash = { 198 | microposts_count: 10, 199 | sort: {property: :microposts_count, order: :desc} 200 | } 201 | 202 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 203 | expected_query = q( 204 | "SELECT users.* FROM users", 205 | "WHERE (users.microposts_count = 10)", 206 | "ORDER BY users.microposts_count DESC" 207 | ) 208 | 209 | expect(strip(query)).to eq expected_query.to_s 210 | end 211 | end 212 | 213 | context "multiple where clauses" do 214 | it "asc" do 215 | hash = { 216 | followers_count: {leq: 20}, 217 | microposts_count: 10, 218 | sort: {property: :microposts_count, order: :asc} 219 | } 220 | 221 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 222 | expected_query = q( 223 | "SELECT users.* FROM users", 224 | "WHERE (users.microposts_count = 10)", 225 | "AND (users.followers_count <= 20)", 226 | "ORDER BY users.microposts_count ASC" 227 | ) 228 | 229 | expect(strip(query)).to eq expected_query.to_s 230 | end 231 | 232 | it "desc" do 233 | hash = { 234 | followers_count: {leq: 20}, 235 | microposts_count: 10, 236 | sort: {property: :microposts_count, order: :desc} 237 | } 238 | 239 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 240 | expected_query = q( 241 | "SELECT users.* FROM users", 242 | "WHERE (users.microposts_count = 10)", 243 | "AND (users.followers_count <= 20)", 244 | "ORDER BY users.microposts_count DESC" 245 | ) 246 | 247 | expect(strip(query)).to eq expected_query.to_s 248 | end 249 | end 250 | 251 | context "multiple sorting properties" do 252 | it "with single where clause" do 253 | hash = { 254 | microposts_count: 10, 255 | sort: [{ 256 | property: :microposts_count, order: :asc, 257 | }, { 258 | property: :followings_count, order: :desc 259 | }] 260 | } 261 | 262 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 263 | expected_query = q( 264 | "SELECT users.* FROM users", 265 | "WHERE (users.microposts_count = 10)", 266 | "ORDER BY users.microposts_count ASC, users.followings_count DESC" 267 | ) 268 | 269 | expect(strip(query)).to eq expected_query.to_s 270 | end 271 | 272 | it "when the sorting column does not exist" do 273 | hash = { 274 | microposts_count: 10, 275 | sort: [{ 276 | property: :i_do_not_exist, order: :asc, 277 | }, { 278 | property: :followings_count, order: :desc 279 | }] 280 | } 281 | 282 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 283 | expected_query = q( 284 | "SELECT users.* FROM users", 285 | "WHERE (users.microposts_count = 10)", 286 | "ORDER BY users.followings_count DESC" 287 | ) 288 | 289 | expect(strip(query)).to eq expected_query.to_s 290 | end 291 | end 292 | 293 | it "when the sorting column does not exist" do 294 | hash = { 295 | microposts_count: 10, 296 | sort: {property: :i_do_not_exist, order: :asc} 297 | } 298 | 299 | query = HelperClass.new.apply_filters(User.all, hash).to_sql 300 | expected_query = q( 301 | "SELECT users.* FROM users", 302 | "WHERE (users.microposts_count = 10)" 303 | ) 304 | 305 | expect(strip(query)).to eq expected_query.to_s 306 | 307 | end 308 | end 309 | end 310 | end 311 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ActiveHashRelation 2 | 3 | ActiveHashRelation is a complete substitute of ActiveRecord::Relation that allows you to run ActiveRecord queries using only regular Hash using a very powerful yet simple API. It was initially built to allow front-end teams to specify from the API exactly what they need withoug bugging the backend developers, eventually emerged into its own little gem. 4 | 5 | ## Introduction 6 | 7 | Simple gem that allows you to manipulate ActiveRecord::Relation using JSON. For instance: 8 | 9 | ```ruby 10 | apply_filters(User.all, {name: 'filippos', created_at: {leq: "2016-10-19"}, role: 'regular', email: {like: 'vasilakis'}}) 11 | ``` 12 | 13 | filter a resource based on it's associations using a NOT filter: 14 | 15 | ```ruby 16 | apply_filters(Microposts.all, {updated_at: { geq: "2014-11-2 14:25:04"}, user: {not: {email: vasilakisfil@gmail.com}}) 17 | ``` 18 | 19 | or even filter a resource based on it's associations' associations using an OR filter: 20 | 21 | ```ruby 22 | apply_filters(Comments.all, {updated_at: { geq: "2014-11-2 14:25:04"}, user: {id: 9, units: {or: [{id: 22}, {id: 21}]} }}) 23 | ``` 24 | 25 | and the list could go on.. Basically you can run anything ActiveRecord supports, except from groupping. It's perfect for filtering a collection of resources on APIs. 26 | 27 | It should be noted that `apply_filters` calls `ActiveHashRelation::FilterApplier` class 28 | underneath with the same params. 29 | 30 | You can also do [**aggregation queries**](#aggregation-queries), like `sum`, `avg`, `min` and `max` on any column. 31 | 32 | _\*A user could retrieve resources based 33 | on unknown attributes (attributes not returned from the API) by brute forcing 34 | which might or might not be a security issue. If you don't like that check you can specify from the `params` exactly what is allowed and what is not allowed. For more information check: [whitelisting](#whitelisting)._ 35 | 36 | ## Installation 37 | 38 | Add this line to your application's Gemfile: 39 | 40 | gem 'active_hash_relation', '~> 1.4.1' 41 | 42 | And then execute: 43 | 44 | $ bundle 45 | 46 | Or install it yourself as: 47 | 48 | $ gem install active_hash_relation 49 | 50 | ## How to use 51 | 52 | The gem exposes only one method: `apply_filters(resource, hash_params, include_associations: true, model: nil)`. 53 | `resource` is expected to be an ActiveRecord::Relation. 54 | That way, you can add your custom filters before passing the `Relation` to `ActiveHashRelation`. 55 | 56 | In order to use it you have to include ActiveHashRelation module in your class. For instance in a Rails API controller you would do: 57 | 58 | ```ruby 59 | class Api::V1::ResourceController < Api::V1::BaseController 60 | include ActiveHashRelation 61 | 62 | def index 63 | resources = apply_filters(Resource.all, params) 64 | 65 | authorized_resources = policy_scope(resource) 66 | 67 | render json: resources, each_serializer: Api::V1::ResourceSerializer 68 | end 69 | end 70 | ``` 71 | 72 | If you **need to enable filtering on scopes**, you need to specify that explicitly from the initializer. Please run: 73 | `bundle exec rails g active_hash_relation:initialize` which will create an initializer with the following content: 74 | 75 | ```ruby 76 | ActiveHashRelation.configure do |config| 77 | #override default scope when accessing associations 78 | config.use_unscoped = true 79 | #set true to be able to filter scopes (with params) 80 | #please note that unfortunately (:/) rails does not provide any way 81 | #to iterate through scopes so it uses a monkey patch. 82 | #The monkey patch is as gentle as it can be by aliasing the method, adds some 83 | #sugar and calls it, 84 | #You need to run `initialize!` to actually include the required files 85 | config.filter_active_record_scopes = true 86 | end 87 | 88 | #requires monkeyparched scopes, optional if you don't enable them 89 | ActiveHashRelation.initialize! 90 | ``` 91 | 92 | If you are not using Rails, just add the code above in your equivelant initialize block. 93 | 94 | ## The API 95 | 96 | ### Columns 97 | 98 | For each param, `apply_filters` method will search in the model's (derived from the 99 | first param, or explicitly defined as the last param) all the record's column names 100 | and associations. (filtering based on scopes are not working at the moment but 101 | will be supported soon). For each column, if there is such a param, it will 102 | apply the filter based on the column type. The following column types are supported: 103 | 104 | #### Integer, Float, Decimal, Date, Time or Datetime/Timestamp 105 | 106 | You can apply an equality filter: 107 | 108 | - `{example_column: 500}` 109 | 110 | or using an array (`ActiveRecord` translates that internally to an `IN` query) 111 | 112 | - `{example_column: [500, 40]}` 113 | 114 | or using a hash as a value you get more options: 115 | 116 | - `{example_column: {le: 500}}` 117 | - `{example_column: {leq: 500}}` 118 | - `{example_column: {ge: 500}}` 119 | - `{example_column: {geq: 500}}` 120 | 121 | Of course you can provide a compination of those like: 122 | 123 | - `{example_column: {geq: 500, le: 1000}}` 124 | 125 | The same api is for a Float, Decimal, Date, Time or Datetime/Timestamp. 126 | 127 | #### Boolean 128 | 129 | The boolean value is converted from string using ActiveRecord's `TRUE_VALUES` through `value_to_boolean` method.. So for a value to be true must be one of the following: `[true, 1, '1', 't', 'T', 'true', 'TRUE']`. Anything else is false. 130 | 131 | - `{example_column: true}` 132 | - `{example_column: 0}` 133 | 134 | #### String or Text 135 | 136 | You can apply an incensitive matching filter (currently working only for Postgres): 137 | 138 | - `{example_column: 'test'}` `#runs EXAMPLE_COLUMN = 'test'` 139 | - `{example_column: ['test', 'another test']}` `#runs EXAMPLE_COLUMN = 'test' OR EXAMPLE_COLUMN = 'another test'` 140 | 141 | or using a hash as a value you get more options: 142 | 143 | - `{example_column: {eq: 'exact value'}}` `#runs: EXAMPLE_COLUMN = 'test'` 144 | - `{example_column: {starts_with: 'exac'}}` `#runs: EXAMPLE_COLUMN LIKE 'test%'` 145 | - `{example_column: {ends_with: 'alue'}}` `#runs: EXAMPLE_COLUMN LIKE '%test'` 146 | - `{example_column: {like: 'ct_va'}}` `#runs: EXAMPLE_COLUMN LIKE '%test%'` 147 | 148 | If you want to filter using `ILIKE` you can pass an `with_ilike` param: 149 | 150 | - `{example_column: {like: 'ct_va', with_ilike: true}}` `#runs: EXAMPLE_COLUMN ILIKE '%test%'` 151 | - `{example_column: {like: 'ct_va', with_ilike: true}}` `#runs: EXAMPLE_COLUMN ILIKE '%test%'` 152 | 153 | **Please note that ILIKE and especially LIKE are quite slow if you have millions of records in the db even with an index.** 154 | 155 | ### Limit 156 | 157 | A limit param defines the number of returned resources. For instance: 158 | 159 | - `{limit: 10}` 160 | 161 | However I would strongly advice you to use a pagination gem like Kaminari, and use `page` and `per_page` params. 162 | 163 | ### Sorting 164 | 165 | You can apply sorting using the property as the key of the hash and order as the value. For instance: 166 | 167 | - `{sort: {created_at: desc}}` 168 | 169 | You can also order by multiple attributes: 170 | 171 | - `{sort: {created_at: desc, microposts_count: :asc}}` 172 | 173 | If there is no column named after the property value, sorting is skipped. 174 | 175 | #### Deprecated API (will be removed in version 2.0) 176 | 177 | You can apply sorting using the `property` and `order` attributes. For instance: 178 | 179 | - `{sort: {property: :created_at, order: :desc}}` 180 | 181 | You can also order by multiple attributes: 182 | 183 | - `{sort: [{property: :created_at, order: :desc}, {property: :created_at, order: :desc}]}` 184 | 185 | If there is no column named after the property value, sorting is skipped. 186 | 187 | ### Associations 188 | 189 | If the association is a `belongs_to` or `has_one`, then the hash key name must be in singular. If the association is `has_many` the attribute must be in plural reflecting the association type. When you have, in your hash, filters for an association, the sub-hash is passed in the association's model. For instance, let's say a user has many microposts and the following filter is applied (could be through an HTTP GET request on controller's index method): 190 | 191 | - `{email: test@user.com, microposts: {created_at { leq: 12-9-2014} }` 192 | 193 | Internally, ActiveHashRelation, extracts `{created_at { leq: 12-9-2014} }` and runs it on Micropost model. So the final query will look like: 194 | 195 | ```ruby 196 | micropost_filter = Micropost.all.where("CREATED_AT =< ?", '12-9-2014'.to_datetime) 197 | User.where(email: 'test@user.com').joins(:microposts).merge(micropost_filter) 198 | ``` 199 | 200 | ### NULL Filter 201 | 202 | You can apply null filter for generate query like this `"users.name IS NULL"` or `"users.name IS NOT NULL"` with this following code: 203 | `{ name: { null: true } }` for is null filter and `{ name: { null: false } }` for not null filter. 204 | 205 | this can be used also for relations tables, so you can write like this `{ books: {title: {null: false }} }` 206 | 207 | ### OR Filter 208 | 209 | You can apply an SQL `OR` (for ActiveRecord 5+) using the following syntax: 210 | `{or: [{name: 'Filippos'}, {name: 'Vasilis'}]}` 211 | 212 | It will generate: `WHERE ((users.name = 'Filippos') OR (users.name = 'Vasilis'))` 213 | 214 | You can apply an `OR` on associations as well or even nested ones, there isn't much limitation on that. 215 | I suggest you though to take a look on the [tests](spec/tests/or_filter_spec.rb), cause the syntax gets a bit complex after a while ;) 216 | 217 | ### NOT Filter 218 | 219 | You can apply an SQL `NOT` (for ActiveRecord 4+) using the following syntax: 220 | `{not: {name: 'Filippos', email: {ends_with: '@gmail.com'}}}` 221 | 222 | It will generate: `WHERE (NOT (users.name = 'Filippos')) AND (NOT (users.email LIKE '%@gmail.com'))` 223 | 224 | You can apply an `NOT` on associations as well or even nested ones, there isn't much limitation on that. 225 | I suggest you to also take a look on the [tests](spec/tests/not_filter_spec.rb). 226 | 227 | Also I should note that you need to add specific (partial) queries if you don't want 228 | to have performance issues on tables with millions of rows. 229 | 230 | ### Scopes 231 | 232 | **Filtering on scopes is not enabled by default. You need to add the initializer mentioned in the beginning of the [How to use](#how-to-use) section.**. 233 | 234 | Scopes are supported via a tiny monkeypatch in the ActiveRecord's scope class method which holds the name of each scope. 235 | The monkey patch is as gentle as it can be: it aliases the method, adds some sugar and executes it. 236 | 237 | Scopes with arguments are also supported but not tested much. Probably they will work fine unless your arguments expect 238 | complex objects. 239 | 240 | If you want to filter based on a scope in a model, the scope names should go under `scopes` sub-hash. For instance the following: 241 | 242 | - `{ scopes: { planned: true } }` 243 | 244 | will run the `.planned` scope on the resource. 245 | 246 | - `{scopes: {created_between: [1988, 2018]}}` 247 | 248 | will run the `.created_on(1988, 2018)` scope on the resource. 249 | 250 | ### Unscoped assotiations 251 | 252 | If you have a default scope in your models and you have a good reason to keep that, `active_hash_relation` provides an option to override it when filtering associations: 253 | 254 | ```ruby 255 | #config/initializers/active_hash_relation.rb 256 | ActiveHashRelation.configure do |config| 257 | config.use_unscoped = true 258 | end 259 | ``` 260 | 261 | You still have to provide the main model `active_hash_relation` runs as unscoped though. 262 | 263 | ```ruby 264 | apply_filters(Video.unscoped.all, {limit: 30, user: {country_code: 'SE'}}) 265 | #"SELECT \"videos\".* FROM \"videos\" INNER JOIN \"users\" ON \"users\".\"id\" = \"videos\".\"user_id\" WHERE (users.country_code ILIKE '%GR%') LIMIT 30" 266 | ``` 267 | 268 | ### Whitelisting 269 | 270 | If you don't want to allow a column/association/scope just remove it from the params hash. 271 | 272 | #### Filter Classes 273 | 274 | Sometimes, especially on larger projects, you have specific classes that handle 275 | the input params outside the controllers. You can configure the gem to look for 276 | those classes and call `apply_filters` which will apply the necessary filters when 277 | iterating over associations. 278 | 279 | In an initializer: 280 | 281 | ```ruby 282 | #config/initializers/active_hash_relation.rb 283 | ActiveHashRelation.configure do |config| 284 | config.has_filter_classes = true 285 | config.filter_class_prefix = 'Api::V1::' 286 | config.filter_class_suffix = 'Filter' 287 | end 288 | ``` 289 | 290 | With the above settings, when the association name is `resource`, 291 | `Api::V1::ResourceFilter.new(resource, params[resource]).apply_filters` will be 292 | called to apply the filters in resource association. 293 | 294 | ## Aggregation Queries 295 | 296 | Sometimes we need to ask the database queries that act on the collection but don't want back an array of elements but a value instead! Now you can do that on an ActiveRecord::Relation by simply calling the aggregations method inside the controller: 297 | 298 | ```ruby 299 | aggregations(resource, { 300 | aggregate: { 301 | integer_column: { avg: true, max: true, min: true, sum: true }, 302 | float_column: {avg: true, max: true, min: true }, 303 | datetime_column: { max: true, min: true } 304 | } 305 | }) 306 | ``` 307 | 308 | and you will get a hash (HashWithIndifferentAccess) back that holds all your aggregations like: 309 | 310 | ```ruby 311 | {"float_column"=>{"avg"=>25.5, "max"=>50, "min"=>1}, 312 | "integer_column"=>{"avg"=>4.38, "sum"=>219, "max"=>9, "min"=>0}, 313 | "datetime_at"=>{"max"=>2015-06-11 20:59:14 UTC, "min"=>2015-06-11 20:59:12 UTC}} 314 | ``` 315 | 316 | These attributes usually go to the "meta" section of your serializer. In that way it's easy to parse them in the front-end (for ember check [here](http://guides.emberjs.com/v1.10.0/models/handling-metadata/)). Please note that you should apply the aggregations **after** you apply the filters (if there any) but **before** you apply pagination! 317 | 318 | ## Contributing 319 | 320 | 1. Fork it ( https://github.com/oddcamp/active_hash_relation/fork ) 321 | 2. Create your feature branch (`git checkout -b my-new-feature`) 322 | 3. Commit your changes (`git commit -am 'Add some feature'`) 323 | 4. Push to the branch (`git push origin my-new-feature`) 324 | 5. Create a new Pull Request 325 | --------------------------------------------------------------------------------