├── .rspec ├── Gemfile ├── lib ├── parelation │ ├── version.rb │ ├── errors │ │ └── parameter.rb │ ├── helpers.rb │ ├── criteria │ │ ├── limit.rb │ │ ├── offset.rb │ │ ├── select.rb │ │ ├── order.rb │ │ ├── order │ │ │ └── object.rb │ │ ├── where.rb │ │ ├── where │ │ │ ├── directional_query_applier.rb │ │ │ ├── criteria_builder.rb │ │ │ └── caster.rb │ │ └── query.rb │ ├── criteria.rb │ └── applier.rb └── parelation.rb ├── Rakefile ├── .gemfiles ├── 6.0.gemfile └── 6.1.gemfile ├── spec ├── support │ ├── env.rb │ └── db.rb ├── spec_helper.rb └── lib │ ├── helpers_spec.rb │ ├── criteria │ ├── limit_spec.rb │ ├── offset_spec.rb │ ├── select_spec.rb │ ├── query_spec.rb │ ├── order_spec.rb │ └── where_spec.rb │ └── applier_spec.rb ├── .gitignore ├── .github └── workflows │ └── test.yml ├── LICENSE ├── parelation.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gemspec 3 | -------------------------------------------------------------------------------- /lib/parelation/version.rb: -------------------------------------------------------------------------------- 1 | module Parelation 2 | VERSION = "1.0.0" 3 | end 4 | -------------------------------------------------------------------------------- /lib/parelation/errors/parameter.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Errors::Parameter < StandardError 2 | end 3 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "bundler/gem_tasks" 2 | require "rspec/core/rake_task" 3 | RSpec::Core::RakeTask.new(:spec) 4 | task default: :spec 5 | -------------------------------------------------------------------------------- /.gemfiles/6.0.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "~> 6.0.0" 3 | gem "sqlite3", "~> 1.4.2" 4 | gemspec path: "../" 5 | -------------------------------------------------------------------------------- /.gemfiles/6.1.gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | gem "activerecord", "~> 6.1.0" 3 | gem "sqlite3", "~> 1.4.2" 4 | gemspec path: "../" 5 | -------------------------------------------------------------------------------- /spec/support/env.rb: -------------------------------------------------------------------------------- 1 | require "pry" 2 | require "active_record" 3 | 4 | ROOT_PATH = File.expand_path("../../..", __FILE__) 5 | SPEC_PATH = File.join(ROOT_PATH, "spec") 6 | 7 | require "#{SPEC_PATH}/support/db" 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | .bundle 4 | .config 5 | .yardoc 6 | .DS_Store 7 | .gemfiles/*.lock 8 | Gemfile.lock 9 | InstalledFiles 10 | _yardoc 11 | coverage 12 | doc/ 13 | lib/bundler/man 14 | pkg 15 | rdoc 16 | spec/reports 17 | test/tmp 18 | test/version_tmp 19 | tmp 20 | *.bundle 21 | *.so 22 | *.o 23 | *.a 24 | mkmf.log 25 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift(File.expand_path("../../lib", __FILE__)) 2 | require File.expand_path("../support/env", __FILE__) 3 | 4 | require "database_cleaner" 5 | require "simplecov" 6 | SimpleCov.start 7 | 8 | require "parelation" 9 | 10 | RSpec.configure do |config| 11 | config.before { DatabaseCleaner.start } 12 | config.after { DatabaseCleaner.clean } 13 | end 14 | -------------------------------------------------------------------------------- /lib/parelation/helpers.rb: -------------------------------------------------------------------------------- 1 | module Parelation::Helpers 2 | 3 | # Shorthand method used in ActionController controllers for converting 4 | # and applying parameters to ActionController::Relation criteria chains. 5 | # 6 | # @param object [ActiveRecord::Relation] 7 | # @return [ActiveRecord::Relation] 8 | # 9 | def parelate(object) 10 | Parelation::Applier.new(object, params).apply 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/parelation/criteria/limit.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Limit < Parelation::Criteria 2 | 3 | # @return [Regexp] the limit format. 4 | # 5 | LIMIT_FORMAT = /^limit$/ 6 | 7 | # @param param [String] 8 | # @return [TrueClass, FalseClass] 9 | # 10 | def self.match?(param) 11 | !!param.match(LIMIT_FORMAT) 12 | end 13 | 14 | # @return [ActiveRecord::Relation] 15 | # 16 | def call 17 | chain.limit(value) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/parelation/criteria/offset.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Offset < Parelation::Criteria 2 | 3 | # @return [Regexp] the offset format. 4 | # 5 | OFFSET_FORMAT = /^offset$/ 6 | 7 | # @param param [String] 8 | # @return [TrueClass, FalseClass] 9 | # 10 | def self.match?(param) 11 | !!param.match(OFFSET_FORMAT) 12 | end 13 | 14 | # @return [ActiveRecord::Relation] 15 | # 16 | def call 17 | chain.offset(value) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/parelation/criteria/select.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Select < Parelation::Criteria 2 | 3 | # @return [Regexp] the select format. 4 | # 5 | SELECT_FORMAT = /^select$/ 6 | 7 | # @param param [String] 8 | # @return [TrueClass, FalseClass] 9 | # 10 | def self.match?(param) 11 | !!param.match(SELECT_FORMAT) 12 | end 13 | 14 | # @return [ActiveRecord::Relation] 15 | # 16 | def call 17 | chain.select(*value) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/lib/helpers_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Helpers do 4 | 5 | class ExampleController 6 | include Parelation::Helpers 7 | 8 | def params 9 | { "where" => { "state" => "open" } } 10 | end 11 | 12 | def index 13 | parelate(Ticket.all) 14 | end 15 | end 16 | 17 | it "should parelate the ticket criteria chain" do 18 | expect(ExampleController.new.index.to_sql) 19 | .to eq(Ticket.all.where(state: 'open').to_sql) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/parelation.rb: -------------------------------------------------------------------------------- 1 | module Parelation 2 | module Errors 3 | require "parelation/errors/parameter" 4 | end 5 | 6 | class Criteria 7 | require "parelation/criteria/select" 8 | require "parelation/criteria/limit" 9 | require "parelation/criteria/offset" 10 | require "parelation/criteria/order" 11 | require "parelation/criteria/query" 12 | require "parelation/criteria/where" 13 | end 14 | 15 | require "parelation/applier" 16 | require "parelation/criteria" 17 | require "parelation/helpers" 18 | require "parelation/version" 19 | end 20 | -------------------------------------------------------------------------------- /spec/lib/criteria/limit_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Limit do 4 | 5 | let(:klass) { Parelation::Criteria::Limit } 6 | 7 | it "should match" do 8 | expect(klass.match?("limit")).to eq(true) 9 | end 10 | 11 | it "should not match" do 12 | expect(klass.match?("query")).to eq(false) 13 | end 14 | 15 | it "should add criteria to the chain" do 16 | criteria = klass.new(Ticket.all, "limit", "40").call 17 | ar_query = Ticket.limit(40) 18 | 19 | expect(criteria.to_sql).to eq(ar_query.to_sql) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/lib/criteria/offset_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Offset do 4 | 5 | let(:klass) { Parelation::Criteria::Offset } 6 | 7 | it "should match" do 8 | expect(klass.match?("offset")).to eq(true) 9 | end 10 | 11 | it "should not match" do 12 | expect(klass.match?("query")).to eq(false) 13 | end 14 | 15 | it "should add criteria to the chain" do 16 | criteria = klass.new(Ticket.all, "offset", "40").call 17 | ar_query = Ticket.offset(40) 18 | 19 | expect(criteria.to_sql).to eq(ar_query.to_sql) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/parelation/criteria.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria 2 | 3 | # @return [ActiveRecord::Relation] the current criteria chain 4 | # 5 | attr_reader :chain 6 | 7 | # @return [String] the param param 8 | # 9 | attr_reader :param 10 | 11 | # @return [String] the param value 12 | # 13 | attr_reader :value 14 | 15 | # @param chain [ActiveRecord::Relation] 16 | # @param param [String] 17 | # @param value [String, Array, Hash] 18 | # 19 | def initialize(chain, param, value) 20 | @chain = chain 21 | @param = param.clone 22 | @value = value.clone 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/lib/criteria/select_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Select do 4 | 5 | let(:klass) { Parelation::Criteria::Select } 6 | 7 | it "should match" do 8 | expect(klass.match?("select")).to eq(true) 9 | end 10 | 11 | it "should not match" do 12 | expect(klass.match?("query")).to eq(false) 13 | end 14 | 15 | it "should add criteria to the chain" do 16 | criteria = klass.new(Ticket.all, "select", ["id", "name"]).call 17 | ar_query = Ticket.all.select(:id, :name) 18 | 19 | expect(criteria.to_sql).to eq(ar_query.to_sql) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/db.rb: -------------------------------------------------------------------------------- 1 | ActiveRecord::Base.establish_connection( 2 | :adapter => "sqlite3", 3 | :database => ":memory:", 4 | ) 5 | 6 | class Schema < ActiveRecord::Migration[5.0] 7 | 8 | def change 9 | create_table :projects do |t| 10 | t.string :name 11 | end 12 | 13 | create_table :tickets do |t| 14 | t.belongs_to :project 15 | t.string :name 16 | t.string :state 17 | t.text :message 18 | t.datetime :created_at 19 | t.datetime :updated_at 20 | t.boolean :resolved 21 | t.integer :position 22 | t.float :rating 23 | end 24 | end 25 | end 26 | 27 | Schema.new.change 28 | 29 | class Project < ActiveRecord::Base 30 | has_many :tickets 31 | end 32 | 33 | class Ticket < ActiveRecord::Base 34 | belongs_to :project 35 | end 36 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | - push 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | ruby: 12 | - 2.5 13 | - 2.6 14 | - 2.7 15 | - 3.0 16 | gemfile: 17 | - .gemfiles/6.0.gemfile 18 | - .gemfiles/6.1.gemfile 19 | steps: 20 | - uses: actions/checkout@v2 21 | - name: Use Ruby ${{ matrix.ruby }} 22 | uses: actions/setup-ruby@v1 23 | with: 24 | ruby-version: ${{ matrix.ruby }} 25 | - name: Install libsqlite3 26 | run: | 27 | sudo apt-get install libsqlite3-dev 28 | - name: Run tests 29 | run: | 30 | gem install bundler 31 | bundle install --jobs 4 --retry 3 32 | bundle exec rake 33 | env: 34 | BUNDLE_GEMFILE: ${{ matrix.gemfile }} 35 | -------------------------------------------------------------------------------- /lib/parelation/criteria/order.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Order < Parelation::Criteria 2 | 3 | require_relative "order/object" 4 | 5 | # @return [Regexp] The order format. 6 | # 7 | ORDER_FORMAT = /^order$/ 8 | 9 | # @param param [String] 10 | # @return [TrueClass, FalseClass] 11 | # 12 | def self.match?(param) 13 | !!param.match(ORDER_FORMAT) 14 | end 15 | 16 | # Applies the specified orderings to {#chain}. 17 | # 18 | # @return [ActiveRecord::Relation] the modified chain. 19 | # 20 | def call 21 | orders.inject(chain) do |chain, order| 22 | chain.order(order.criteria) 23 | end 24 | end 25 | 26 | private 27 | 28 | # @return [Array] an 29 | # array of attributes to order. 30 | # 31 | def orders 32 | @orders ||= [value].flatten.map do |order| 33 | Object.new(order) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/lib/criteria/query_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Query do 4 | 5 | let(:klass) { Parelation::Criteria::Query } 6 | 7 | it "should match" do 8 | expect(klass.match?("query")).to eq(true) 9 | end 10 | 11 | it "should not match" do 12 | expect(klass.match?("not_query")).to eq(false) 13 | end 14 | 15 | it "should add single-column criteria to the chain" do 16 | criteria = klass.new(Ticket.all, "query", { "ruby on rails" => "name" }).call 17 | ar_query = Ticket.where(%Q{"tickets"."name" LIKE ?}, "%ruby on rails%") 18 | 19 | expect(criteria.to_sql).to eq(ar_query.to_sql) 20 | end 21 | 22 | it "should add multi-column criteria to the chain" do 23 | criteria = klass.new(Ticket.all, "query", { "ruby on rails" => ["name", "message"] }).call 24 | ar_query = Ticket.where( 25 | %Q{"tickets"."name" LIKE ? OR "tickets"."message" LIKE ?}, 26 | "%ruby on rails%", "%ruby on rails%" 27 | ) 28 | 29 | expect(criteria.to_sql).to eq(ar_query.to_sql) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/parelation/criteria/order/object.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Order::Object 2 | 3 | # @return [Hash] the possible directions (asc, desc) 4 | # for database queries. 5 | # 6 | DIRECTIONS = { 7 | "asc" => :asc, 8 | "desc" => :desc, 9 | } 10 | 11 | # @return [String] 12 | # 13 | attr_reader :order 14 | 15 | # @param order [String] 16 | # 17 | def initialize(order) 18 | @order = order 19 | end 20 | 21 | # @return [Hash] returns criteria for {ActiveRecord::Relation}. 22 | # 23 | # @example 24 | # { created_at: :asc } 25 | # 26 | def criteria 27 | { field => direction } 28 | end 29 | 30 | private 31 | 32 | # @return [String] the name of the field to perform the ordering on. 33 | # 34 | def field 35 | parts.first || "" 36 | end 37 | 38 | # @return [Symbol, nil] the direction to order {#field}, 39 | # either :asc or :desc. 40 | # 41 | def direction 42 | DIRECTIONS[parts.last] 43 | end 44 | 45 | # @return [Array] the criteria chunks (separated by +:+). 46 | # 47 | def parts 48 | @parts ||= order.split(":") 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Michael van Rooijen 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 | -------------------------------------------------------------------------------- /lib/parelation/criteria/where.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Where < Parelation::Criteria 2 | 3 | require_relative "where/caster" 4 | require_relative "where/directional_query_applier" 5 | require_relative "where/criteria_builder" 6 | 7 | # @return [Regexp] the "where" format. 8 | # 9 | WHERE_FORMAT = /^(where|where_(not|gt|gte|lt|lte))$/ 10 | 11 | # @param param [String] 12 | # @return [true, false] 13 | # 14 | def self.match?(param) 15 | !!param.match(WHERE_FORMAT) 16 | end 17 | 18 | # @return [ActiveRecord::Relation] 19 | # 20 | def call 21 | criteria.inject(chain) do |chain, (field, value)| 22 | case param 23 | when "where" 24 | chain.where(field => value) 25 | when "where_not" 26 | chain.where.not(field => value) 27 | when "where_gt", "where_gte", "where_lt", "where_lte" 28 | DirectionalQueryApplier.new(chain, param, field, value).apply 29 | end 30 | end 31 | end 32 | 33 | private 34 | 35 | # @return [Hash] containing data used to pass to {#chain}'s +where+ method. 36 | # 37 | def criteria 38 | @criteria ||= CriteriaBuilder.new(value, chain).build 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /parelation.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path("../lib", __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require "parelation/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "parelation" 7 | spec.version = Parelation::VERSION 8 | spec.authors = ["Michael van Rooijen"] 9 | spec.email = ["michael@vanrooijen.io"] 10 | spec.summary = %q{Translates HTTP parameters to ActiveRecord queries.} 11 | spec.homepage = "http://mrrooijen.github.io/store_schema/" 12 | spec.license = "MIT" 13 | 14 | spec.files = `git ls-files -z`.split("\x0") 15 | spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) } 16 | spec.test_files = spec.files.grep(%r{^(test|spec|features)/}) 17 | spec.require_paths = ["lib"] 18 | 19 | spec.add_dependency "activerecord", ">= 6.0.0", "< 6.2.0" 20 | spec.add_development_dependency "bundler" 21 | spec.add_development_dependency "rake", "~> 13.0.3" 22 | spec.add_development_dependency "rspec", "~> 3.10.0" 23 | spec.add_development_dependency "database_cleaner", "~> 2.0.1" 24 | spec.add_development_dependency "pry", "~> 0.14.1" 25 | spec.add_development_dependency "simplecov", "~> 0.21.2" 26 | spec.add_development_dependency "yard", "~> 0.9.26" 27 | end 28 | -------------------------------------------------------------------------------- /spec/lib/criteria/order_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Order do 4 | 5 | let(:klass) { Parelation::Criteria::Order } 6 | 7 | it "should match" do 8 | expect(klass.match?("order")).to eq(true) 9 | end 10 | 11 | it "should not match" do 12 | expect(klass.match?("query")).to eq(false) 13 | end 14 | 15 | it "should add acending order criteria to the chain" do 16 | criteria = klass.new(Ticket.all, "order", "created_at:asc").call 17 | ar_query = Ticket.order(created_at: :asc) 18 | 19 | expect(criteria.to_sql).to eq(ar_query.to_sql) 20 | end 21 | 22 | it "should add descending order criteria to the chain" do 23 | criteria = klass.new(Ticket.all, "order", "created_at:desc").call 24 | ar_query = Ticket.order(created_at: :desc) 25 | 26 | expect(criteria.to_sql).to eq(ar_query.to_sql) 27 | end 28 | 29 | it "should combine multiple asc and desc order criteria" do 30 | orders = %w[created_at:desc name:asc updated_at:desc message:asc] 31 | criteria = klass.new(Ticket.all, "order", orders).call 32 | ar_query = Ticket.order(created_at: :desc, name: :asc, updated_at: :desc, message: :asc) 33 | 34 | expect(criteria.to_sql).to eq(ar_query.to_sql) 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/parelation/criteria/where/directional_query_applier.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Where::DirectionalQueryApplier 2 | 3 | # @return [Hash] keyword to operator mappings 4 | # 5 | OPERATOR_MAPPING = { 6 | "where_gt" => ">", 7 | "where_gte" => ">=", 8 | "where_lt" => "<", 9 | "where_lte" => "<=" 10 | } 11 | 12 | # @return [ActiveRecord::Relation] 13 | # 14 | attr_reader :chain 15 | 16 | # @return [String] 17 | # 18 | attr_reader :operator 19 | 20 | # @return [String] 21 | # 22 | attr_reader :field 23 | 24 | # @return [String] 25 | # 26 | attr_reader :value 27 | 28 | # @param chain [ActiveRecord::Relation] the chain to apply to 29 | # @param operator [String] the named operator from the params 30 | # @param field [String] the field to query on 31 | # @param value [String] the value of the query 32 | # 33 | def initialize(chain, operator, field, value) 34 | @chain = chain 35 | @operator = operator 36 | @field = field 37 | @value = value 38 | end 39 | 40 | # @return [ActiveRecord::Relation] the chain with newly applied operations 41 | # 42 | def apply 43 | chain.where(sql, field, value) 44 | end 45 | 46 | private 47 | 48 | # @return [String] the base SQL template to build queries on-top of 49 | # 50 | def sql 51 | %Q{"#{chain.arel_table.name}".? #{OPERATOR_MAPPING[operator]} ?} 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/parelation/criteria/query.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Query < Parelation::Criteria 2 | 3 | # @return [Regexp] the query format. 4 | # 5 | QUERY_FORMAT = /^query$/ 6 | 7 | # @param param [String] 8 | # @return [TrueClass, FalseClass] 9 | # 10 | def self.match?(param) 11 | !!param.match(QUERY_FORMAT) 12 | end 13 | 14 | # @return [ActiveRecord::Relation] 15 | # 16 | def call 17 | chain.where(*criteria) 18 | end 19 | 20 | private 21 | 22 | # @return [Array] containing {#chain} criteria. 23 | # 24 | def criteria 25 | [sql] + args 26 | end 27 | 28 | # @return [String] an SQL statement based on the selected {#attributes}. 29 | # 30 | def sql 31 | template = ->(field) { %Q{"#{chain.arel_table.name}"."#{field}" LIKE ?} } 32 | attributes.map(&template).join(" OR ") 33 | end 34 | 35 | # @return [Array] containing the query, one for each field in {#attributes}. 36 | # 37 | def args 38 | attributes.count.times.map { "%#{query}%" } 39 | end 40 | 41 | # @return [String] the "search" query to perform. 42 | # 43 | def query 44 | @query ||= parts.first 45 | end 46 | 47 | # @return [Array] an array of attributes to search in. 48 | # 49 | def attributes 50 | @attributes ||= parts.last 51 | end 52 | 53 | # @return [Array] containing the query and attributes. 54 | # 55 | def parts 56 | @parts ||= [value.keys.first, [value.values.first].flatten] 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/parelation/applier.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Applier 2 | 3 | # @return [Array] the list of active criteria classes 4 | # that are used to narrow down database results. 5 | # 6 | CRITERIA = [ 7 | Parelation::Criteria::Select, 8 | Parelation::Criteria::Limit, 9 | Parelation::Criteria::Offset, 10 | Parelation::Criteria::Order, 11 | Parelation::Criteria::Query, 12 | Parelation::Criteria::Where, 13 | ] 14 | 15 | # @return [ActiveRecord::Relation] 16 | # 17 | attr_reader :chain 18 | 19 | # @return [ActionController::Parameters, Hash] 20 | # 21 | attr_reader :params 22 | 23 | # @param chain [ActionController::Relation] the base chain to build on. 24 | # @param params [ActionController::Parameters, Hash] user input via params. 25 | # 26 | def initialize(chain, params) 27 | @chain = chain 28 | @params = params 29 | end 30 | 31 | # @return [ActiveRecord::Relation] the criteria-applied {#chain}. 32 | # 33 | def apply 34 | @apply ||= apply_to_chain 35 | end 36 | 37 | private 38 | 39 | # Iterates over each user-provided parameter and incrementally 40 | # updates the {#chain} to incorporate the user-requested criteria. 41 | # 42 | # @return [ActiveRecord::Relation] 43 | # 44 | def apply_to_chain 45 | params.each do |param, value| 46 | CRITERIA.each do |criteria| 47 | if criteria.match?(param) 48 | begin 49 | @chain = criteria.new(chain, param, value).call 50 | rescue 51 | raise Parelation::Errors::Parameter, 52 | "the #{param} parameter is invalid" 53 | end 54 | end 55 | end 56 | end 57 | 58 | chain 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/parelation/criteria/where/criteria_builder.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Where::CriteriaBuilder 2 | 3 | # @return [Hash] 4 | # 5 | attr_reader :value 6 | 7 | # @return [ActiveRecord::Relation] 8 | # 9 | attr_reader :chain 10 | 11 | # @param value [Hash] the user-provided criteria 12 | # @param chain [ActiveRecord::Relation] 13 | # 14 | def initialize(value, chain) 15 | @value = value 16 | @chain = chain 17 | end 18 | 19 | # @return [Hash] criteria that can be passed into 20 | # the +where+ method of an ActiveRecord::Relation chain. 21 | # 22 | def build 23 | value.inject(Hash.new) do |hash, (field, value)| 24 | values = [value].flatten 25 | 26 | if values.count > 1 27 | assign_array(hash, field, values) 28 | else 29 | assign_value(hash, field, values) 30 | end 31 | 32 | hash 33 | end 34 | end 35 | 36 | private 37 | 38 | # Assigns each of the provided values to the +hash+ and casts 39 | # the value to a database-readable value. 40 | # 41 | # @param hash [Hash] 42 | # @param field [Symbol] 43 | # @param values [Array] 44 | # 45 | def assign_array(hash, field, values) 46 | values.each { |val| (hash[field] ||= []) << cast(field, val) } 47 | end 48 | 49 | # Assigns the first value of the provided values array 50 | # to the +hash+ and casts it to a database-readable value. 51 | # 52 | # @param hash [Hash] 53 | # @param field [Symbol] 54 | # @param values [Array] 55 | # 56 | def assign_value(hash, field, values) 57 | hash[field] = cast(field, values[0]) 58 | end 59 | 60 | # @param field [Symbol] 61 | # @param value [String] 62 | # 63 | def cast(field, value) 64 | Parelation::Criteria::Where::Caster 65 | .new(field, value, chain.model).cast 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/parelation/criteria/where/caster.rb: -------------------------------------------------------------------------------- 1 | class Parelation::Criteria::Where::Caster 2 | 3 | # @return [String] an array of values that are considered true 4 | # 5 | TRUTHY_VALUES = ["1", "t", "true"] 6 | 7 | # @return [String] an array of values that are considered false 8 | # 9 | FALSY_VALUES = ["0", "f", "false"] 10 | 11 | # @return [String] 12 | # 13 | attr_reader :field 14 | 15 | # @return [String] 16 | # 17 | attr_reader :value 18 | 19 | # @return [Class] 20 | # 21 | attr_reader :klass 22 | 23 | # @param field [Symbol] the name of the attribute that needs to be casted 24 | # @param value [String] the value of the attribute that needs to be casted 25 | # @param klass [Class] the corresponding field's class 26 | # 27 | def initialize(field, value, klass) 28 | @field = field.to_s 29 | @value = value 30 | @klass = klass 31 | end 32 | 33 | # @return [String, Boolean, Time, nil] 34 | # 35 | def cast 36 | case type 37 | when :boolean 38 | to_boolean 39 | when :integer 40 | to_integer 41 | when :float 42 | to_float 43 | when :datetime 44 | to_time 45 | else 46 | value 47 | end 48 | end 49 | 50 | private 51 | 52 | # @return [Symbol] 53 | # 54 | def type 55 | klass.columns_hash[field].type 56 | end 57 | 58 | # @return [true, false, nil] 59 | # 60 | def to_boolean 61 | TRUTHY_VALUES.include?(value) && (return true) 62 | FALSY_VALUES.include?(value) && (return false) 63 | end 64 | 65 | # @return [Integer] 66 | # 67 | def to_integer 68 | value.to_i 69 | end 70 | 71 | # @return [Float] 72 | # 73 | def to_float 74 | value.to_f 75 | end 76 | 77 | # @return [Time] the parsed time string 78 | # 79 | def to_time 80 | Time.parse(value) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /spec/lib/applier_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Applier do 4 | 5 | let(:klass) { Parelation::Applier } 6 | 7 | it "should apply the requested criteria" do 8 | params = { 9 | "format" => :json, 10 | "action" => "index", 11 | "controller" => "api/v1/tickets", 12 | "select" => ["id", "name", "state", "message"], 13 | "where" => { state: ["open", "pending"] }, 14 | "where_not" => { state: "closed" }, 15 | "where_gt" => { created_at: "2014-01-01T00:00:00Z" }, 16 | "where_gte" => { updated_at: "2014-01-01T00:00:00Z" }, 17 | "where_lt" => { created_at: "2014-01-01T01:00:00Z" }, 18 | "where_lte" => { updated_at: "2014-01-01T01:00:00Z" }, 19 | "query" => { "ruby on rails" => ["name", "message"] }, 20 | "order" => ["created_at:desc", "name:asc"], 21 | "limit" => "50", 22 | "offset" => "100" 23 | } 24 | 25 | project = Project.create 26 | criteria = klass.new(project.tickets, params).apply 27 | ar_query = project.tickets 28 | .select(:id, :name, :state, :message) 29 | .where(state: ["open", "pending"]) 30 | .where.not(state: "closed") 31 | .where(%Q{"tickets".'created_at' > ?}, "2014-01-01 00:00:00") 32 | .where(%Q{"tickets".'updated_at' >= ?}, "2014-01-01 00:00:00") 33 | .where(%Q{"tickets".'created_at' < ?}, "2014-01-01 01:00:00") 34 | .where(%Q{"tickets".'updated_at' <= ?}, "2014-01-01 01:00:00") 35 | .where( 36 | %Q{"tickets"."name" LIKE ? OR "tickets"."message" LIKE ?}, 37 | "%ruby on rails%", "%ruby on rails%" 38 | ) 39 | .order(created_at: :desc, name: :asc) 40 | .limit(50) 41 | .offset(100) 42 | 43 | expect(criteria.to_sql).to eq(ar_query.to_sql) 44 | end 45 | 46 | it "raise an exception if parameter data is invalid" do 47 | params = { "order" => ["name"] } 48 | 49 | expect { klass.new(Ticket.all, params).apply } 50 | .to raise_error(Parelation::Errors::Parameter, "the order parameter is invalid") 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /spec/lib/criteria/where_spec.rb: -------------------------------------------------------------------------------- 1 | require "spec_helper" 2 | 3 | describe Parelation::Criteria::Where do 4 | 5 | let(:klass) { Parelation::Criteria::Where } 6 | 7 | it "should match" do 8 | operators = %w[where where_not where_gt where_gte where_lt where_lte] 9 | operators.each { |operator| expect(klass.match?(operator)).to eq(true) } 10 | end 11 | 12 | it "should not match" do 13 | expect(klass.match?("query")).to eq(false) 14 | end 15 | 16 | it "should add = criteria to the chain when argument is a value" do 17 | criteria = klass.new(Ticket.all, "where", state: "open").call 18 | ar_query = Ticket.where(state: "open") 19 | 20 | expect(criteria.to_sql).to eq(ar_query.to_sql) 21 | end 22 | 23 | it "should add = criteria to the chain when argument is an array" do 24 | criteria = klass.new(Ticket.all, "where", state: ["open"]).call 25 | ar_query = Ticket.where(state: "open") 26 | 27 | expect(criteria.to_sql).to eq(ar_query.to_sql) 28 | end 29 | 30 | it "should add IN criteria to the chain when values are many" do 31 | criteria = klass.new(Ticket.all, "where", state: ["open", "pending"]).call 32 | ar_query = Ticket.where(state: ["open", "pending"]) 33 | 34 | expect(criteria.to_sql).to eq(ar_query.to_sql) 35 | end 36 | 37 | it "should add != criteria to the chain when argument is a value" do 38 | criteria = klass.new(Ticket.all, "where_not", state: "open").call 39 | ar_query = Ticket.where.not(state: "open") 40 | 41 | expect(criteria.to_sql).to eq(ar_query.to_sql) 42 | end 43 | 44 | it "should add != criteria to the chain when argument is an array" do 45 | criteria = klass.new(Ticket.all, "where_not", state: "open").call 46 | ar_query = Ticket.where.not(state: "open") 47 | 48 | expect(criteria.to_sql).to eq(ar_query.to_sql) 49 | end 50 | 51 | it "should add NOT IN criteria to the chain when values are many" do 52 | criteria = klass.new(Ticket.all, "where_not", state: ["open", "pending"]).call 53 | ar_query = Ticket.where.not(state: ["open", "pending"]) 54 | 55 | expect(criteria.to_sql).to eq(ar_query.to_sql) 56 | end 57 | 58 | [%w[where_gt >], %w[where_gte >=], %w[where_lt <], %w[where_lte <=]] 59 | .each do |(operator, symbol)| 60 | 61 | it "should add #{symbol} criteria to the chain" do 62 | criteria = klass.new(Ticket.all, operator, created_at: "2014-08-26T19:20:44Z").call 63 | ar_query = Ticket.where( 64 | %Q{"tickets".'created_at' #{symbol} ?}, "2014-08-26 19:20:44" 65 | ) 66 | 67 | expect(criteria.to_sql).to eq(ar_query.to_sql) 68 | end 69 | 70 | it "should add multiple #{symbol} criteria to the chain" do 71 | criteria = klass.new(Ticket.all, operator, created_at: "2014-08-26T19:20:44Z", position: "5").call 72 | ar_query = Ticket.where(%Q{"tickets".'created_at' #{symbol} ?}, "2014-08-26 19:20:44") 73 | .where(%Q{"tickets".'position' #{symbol} ?}, 5) 74 | 75 | expect(criteria.to_sql).to eq(ar_query.to_sql) 76 | end 77 | end 78 | 79 | describe "relations" do 80 | 81 | before do 82 | @project1 = Project.create 83 | @project2 = Project.create 84 | @ticket1 = @project1.tickets.create 85 | @ticket2 = @project2.tickets.create 86 | end 87 | 88 | it "should yield related resources" do 89 | tickets = @project1.tickets 90 | expect(tickets.count).to eq(1) 91 | expect(tickets.first).to eq(@ticket1) 92 | end 93 | 94 | it "should yield results when using the same foreign key" do 95 | chain = klass.new(@project1.tickets, "where", project_id: @project1.id).call 96 | expect(chain.count).to eq(1) 97 | expect(chain.first).to eq(@ticket1) 98 | end 99 | 100 | it "should not override foreign key, nor yield results" do 101 | chain = klass.new(@project1.tickets, "where", project_id: @project2.id).call 102 | expect(chain.count).to eq(0) 103 | expect(chain.to_sql).to eq(@project1.tickets.where(project_id: @project2.id).to_sql) 104 | end 105 | end 106 | 107 | describe "casting:boolean" do 108 | 109 | ["1", "t", "true"].each do |string_bool| 110 | it "should cast #{string_bool} to true" do 111 | criteria = klass.new(Ticket.all, "where", resolved: string_bool).call 112 | ar_query = Ticket.where(resolved: true) 113 | 114 | expect(criteria.to_sql).to eq(ar_query.to_sql) 115 | end 116 | end 117 | 118 | ["0", "f", "false"].each do |string_bool| 119 | it "should cast #{string_bool} to false" do 120 | criteria = klass.new(Ticket.all, "where", resolved: string_bool).call 121 | ar_query = Ticket.where(resolved: false) 122 | 123 | expect(criteria.to_sql).to eq(ar_query.to_sql) 124 | end 125 | end 126 | end 127 | 128 | describe "casting:integer" do 129 | 130 | it "should cast a string to integer" do 131 | criteria = klass.new(Ticket.all, "where", position: "5").call 132 | ar_query = Ticket.where(position: 5) 133 | 134 | expect(criteria.to_sql).to eq(ar_query.to_sql) 135 | end 136 | end 137 | 138 | describe "casting:float" do 139 | 140 | it "should cast a string to float" do 141 | criteria = klass.new(Ticket.all, "where", rating: "2.14").call 142 | ar_query = Ticket.where(rating: 2.14) 143 | 144 | expect(criteria.to_sql).to eq(ar_query.to_sql) 145 | end 146 | end 147 | 148 | describe "casting:datetime" do 149 | 150 | it "should cast a string to time" do 151 | criteria = klass.new(Ticket.all, "where", created_at: "2014-01-01T00:00:00Z").call 152 | ar_query = Ticket.where(created_at: "2014-01-01 00:00:00") 153 | 154 | expect(criteria.to_sql).to eq(ar_query.to_sql) 155 | end 156 | end 157 | end 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Parelation 2 | 3 | [![Gem Version](https://badge.fury.io/rb/parelation.svg)](http://badge.fury.io/rb/parelation) 4 | [![Code Climate](https://codeclimate.com/github/mrrooijen/parelation.png)](https://codeclimate.com/github/mrrooijen/parelation) 5 | [![Build Status](https://travis-ci.org/mrrooijen/parelation.svg)](https://travis-ci.org/mrrooijen/parelation) 6 | 7 | Parelation, for Rails/ActiveRecord 6.0+, allows you to query your ActiveRecord-mapped database easily, securely and quite flexibly using simple GET requests. It's used in your controller layer where it uses HTTP GET parameters to build on the ActiveRecord::Relation chain. This provides the client-side with the out-of-the-box flexibility to perform fairly dynamic queries without having to write boilerplate on the server. 8 | 9 | This library was developed for- and extracted from [HireFire]. 10 | 11 | The documentation can be found on [RubyDoc]. 12 | 13 | ### Compatibility 14 | 15 | - Rails/ActiveRecord 6.0+ 16 | - Ruby (MRI) 2.5+ 17 | 18 | ### Installation 19 | 20 | Add the gem to your Gemfile and run `bundle`. 21 | 22 | ```rb 23 | gem "parelation" 24 | ``` 25 | 26 | 27 | ### Example 28 | 29 | Here's an example to get an idea of how it works. We'll fetch the `50` most recently created and `open` tickets, and we only want their `id`, `name` and `message` attributes. 30 | 31 | ```js 32 | var params = { 33 | "select[]": ["id", "name", "message"], 34 | "where[state]": "open", 35 | "order": "created_at:desc", 36 | "limit": "50" 37 | } 38 | 39 | $.get("https://api.ticket.app/tickets", params, function(tickets){ 40 | console.log("Just fetched the 50 most recent and open tickets.") 41 | $.each(tickets, function(ticket){ 42 | console.log("Ticket " + ticket.name + " loaded!") 43 | }) 44 | }) 45 | ``` 46 | 47 | Simply include `Parelation::Helpers` and use the `parelate` method. This will ensure that the provided parameters are converted and applied to the `Ticket.all` criteria chain. 48 | 49 | ```rb 50 | class Api::V1::TicketsController < ApplicationController 51 | include Parelation::Helpers 52 | 53 | def index 54 | render json: parelate(Ticket.all) 55 | end 56 | end 57 | ``` 58 | 59 | You can also scope results to the `current_user`: 60 | 61 | ```rb 62 | class Api::V1::TicketsController < ApplicationController 63 | include Parelation::Helpers 64 | 65 | def index 66 | render json: parelate(current_user.tickets) 67 | end 68 | end 69 | ``` 70 | 71 | Using the same JavaScript, this'll now fetch the 50 most recent open tickets scoped to the `current_user`. 72 | 73 | 74 | ### Parameter List (Reference) 75 | 76 | Here follows a list of all possible query syntaxes. We'll assume we have a Ticket model to query on. 77 | 78 | #### Select 79 | 80 | ``` 81 | /tickets?select[]=id&select[]=name&select[]=message 82 | ``` 83 | 84 | Translates to: 85 | 86 | ```rb 87 | Ticket.select(:id, :name, :message) 88 | ``` 89 | 90 | #### Where 91 | 92 | ``` 93 | /tickets?where[state]=open 94 | ``` 95 | 96 | Translates to: 97 | 98 | ```rb 99 | Ticket.where(state: "open") 100 | ``` 101 | 102 | You can also specify multiple multiple conditions: 103 | 104 | ``` 105 | /tickets?where[state][]=open&where[state][]=pending 106 | ``` 107 | 108 | Translates to: 109 | 110 | ```rb 111 | Ticket.where(state: ["open", "pending"]) 112 | ``` 113 | 114 | #### Where (directional) 115 | 116 | * `where_gt` (greater than `>`) 117 | * `where_gte` (greater than or equal to `>=`) 118 | * `where_lt` (less than `<`) 119 | * `where_lte` (less than or equal to `<=`) 120 | 121 | ``` 122 | /tickets?where_gt[created_at]=2014-01-01T00:00:00Z 123 | ``` 124 | 125 | Translates to: 126 | 127 | ```rb 128 | Ticket.where("'tickets'.'created_at' > '2014-01-01 00:00:00.000000'") 129 | ``` 130 | 131 | You can also specify multiple conditions: 132 | 133 | ``` 134 | /tickets?where_gt[created_at]=2014-01-01T00:00:00Z&where_gt[updated_at]=2014-01-01T00:00:00Z 135 | ``` 136 | 137 | Translates to: 138 | 139 | ```rb 140 | Ticket 141 | .where("'tickets'.'created_at' > '2014-01-01 00:00:00.000000'") 142 | .where("'tickets'.'updated_at' > '2014-01-01 00:00:00.000000'") 143 | ``` 144 | 145 | #### Query 146 | 147 | ``` 148 | /tickets?query[memory leak]=name 149 | ``` 150 | 151 | Translates to: 152 | 153 | ```rb 154 | Ticket.where("'tickets'.'name' LIKE '%memory leak%'") 155 | ``` 156 | 157 | You can also specify multiple columns to scan: 158 | 159 | ``` 160 | /tickets?query[memory leak]=name&query[memory leak]=message 161 | ``` 162 | 163 | Translates to: 164 | 165 | ```rb 166 | Ticket.where("( 167 | 'tickets'.'name' LIKE '%memory leak%' OR 168 | 'tickets'.'message' LIKE '%memory leak%' 169 | )") 170 | ``` 171 | 172 | #### Order 173 | 174 | ``` 175 | /tickets?order=created_at:desc 176 | ``` 177 | 178 | Translates to: 179 | 180 | ```rb 181 | Ticket.order(created_at: :desc) 182 | ``` 183 | 184 | You can also specify multiple order-operations: 185 | 186 | ``` 187 | /tickets?order[]=created_at:desc&order[]=name:asc 188 | ``` 189 | 190 | Translates to: 191 | 192 | ```rb 193 | Ticket.order(created_at: :desc, name: :asc) 194 | ``` 195 | 196 | #### Limit 197 | 198 | ``` 199 | /tickets?limit=50 200 | ``` 201 | 202 | Translates to: 203 | 204 | ```rb 205 | Ticket.limit(50) 206 | ``` 207 | 208 | #### Offset 209 | 210 | ``` 211 | /tickets?offset=25 212 | ``` 213 | 214 | Translates to: 215 | 216 | ```rb 217 | Ticket.offset(25) 218 | ``` 219 | 220 | 221 | ### Error Handling 222 | 223 | When invalid parameters were sent, you can rescue the exception and return a message to the client. 224 | 225 | ```rb 226 | class Api::V1::TicketsController < ApplicationController 227 | include Parelation::Helpers 228 | 229 | def index 230 | render json: parelate(Ticket.all) 231 | rescue Parelation::Errors::Parameter => error 232 | render json: { error: error }, status: :bad_request 233 | end 234 | end 235 | ``` 236 | 237 | This will tell client developers what parameter failed in the HTTP response. This exception generally occurs when there is a typo in the URL's parameters. Knowing which parameter failed (described in `error`) helps narrowing down the issue. 238 | 239 | 240 | ### Contributing 241 | 242 | Contributions are welcome, but please conform to these requirements: 243 | 244 | - Ruby (MRI) 2.5+ 245 | - ActiveRecord 6.0+ 246 | - 100% Spec Coverage 247 | - Generated by when running the test suite 248 | - 100% [Passing Specs] 249 | - Run test suite with `$ rspec spec` 250 | - 4.0 [Code Climate Score] 251 | - Run `$ rubycritic lib` to generate the score locally and receive tips 252 | - No code smells 253 | - No duplication 254 | 255 | To start contributing, fork the project, clone it, and install the development dependencies: 256 | 257 | ``` 258 | git clone git@github.com:USERNAME/parelation.git 259 | cd parelation 260 | bundle 261 | ``` 262 | 263 | Ensure that everything works: 264 | 265 | ``` 266 | rspec spec 267 | rubycritic lib 268 | ``` 269 | 270 | To run the local documentation server: 271 | 272 | ``` 273 | yard server --reload 274 | ``` 275 | 276 | Create a new branch and start hacking: 277 | 278 | ``` 279 | git checkout -b my-contributions 280 | ``` 281 | 282 | Submit a pull request. 283 | 284 | 285 | ### Author / License 286 | 287 | Released under the [MIT License] by [Michael van Rooijen]. 288 | 289 | [Michael van Rooijen]: https://twitter.com/mrrooijen 290 | [HireFire]: http://hirefire.io 291 | [Passing Specs]: https://travis-ci.org/mrrooijen/parelation 292 | [Code Climate Score]: https://codeclimate.com/github/mrrooijen/parelation 293 | [RubyDoc]: http://rubydoc.info/github/mrrooijen/parelation/master/frames 294 | [MIT License]: https://github.com/mrrooijen/parelation/blob/master/LICENSE 295 | --------------------------------------------------------------------------------