├── .rspec ├── .rubocop ├── _config.yml ├── rubocop.gemfile ├── lib ├── order_query │ ├── version.rb │ ├── errors.rb │ ├── direction.rb │ ├── sql │ │ ├── column.rb │ │ ├── order_by.rb │ │ └── where.rb │ ├── nulls_direction.rb │ ├── space.rb │ ├── point.rb │ └── column.rb └── order_query.rb ├── spec ├── gemfiles │ ├── rubocop.gemfile │ ├── rails_7_1.gemfile │ ├── rails_7_2.gemfile │ ├── rails_8_0.gemfile │ ├── rails_8_1.gemfile │ ├── rails_5_0.gemfile │ ├── rails_5_1.gemfile │ ├── rails_6_0.gemfile │ ├── rails_6_1.gemfile │ ├── rails_7_0.gemfile │ └── rails_5_2.gemfile ├── spec_helper.rb ├── support │ └── order_expectation.rb └── order_query_spec.rb ├── Gemfile ├── .simplecov ├── .gitignore ├── shared.gemfile ├── .rubocop.yml ├── MIT-LICENSE ├── order_query.gemspec ├── Rakefile ├── script └── create-db-users ├── CHANGES.md ├── .travis.yml └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | -------------------------------------------------------------------------------- /.rubocop: -------------------------------------------------------------------------------- 1 | -D 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-slate -------------------------------------------------------------------------------- /rubocop.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | gem 'rubocop', '= 0.74.0' 4 | -------------------------------------------------------------------------------- /lib/order_query/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | VERSION = '0.5.6' 5 | end 6 | -------------------------------------------------------------------------------- /spec/gemfiles/rubocop.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | eval_gemfile '../../rubocop.gemfile' 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | eval_gemfile 'spec/gemfiles/rails_8_1.gemfile' 6 | eval_gemfile 'rubocop.gemfile' 7 | -------------------------------------------------------------------------------- /.simplecov: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | SimpleCov.start do 3 | add_filter '/spec/' 4 | add_filter '.bundle/' 5 | add_group 'lib', 'lib/' 6 | formatter SimpleCov::Formatter::HTMLFormatter unless ENV['TRAVIS'] 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.lock 2 | coverage/ 3 | .ruby-version 4 | .ruby-gemset 5 | .bundle/ 6 | log/*.log 7 | pkg/ 8 | test/dummy/db/*.sqlite3 9 | test/dummy/db/*.sqlite3-journal 10 | test/dummy/log/*.log 11 | test/dummy/tmp/ 12 | test/dummy/.sass-cache 13 | -------------------------------------------------------------------------------- /shared.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | platform :jruby do 4 | gem 'activerecord-jdbcmysql-adapter' 5 | gem 'activerecord-jdbcpostgresql-adapter' 6 | gem 'activerecord-jdbcsqlite3-adapter' 7 | end 8 | 9 | group :test, :development do 10 | gem 'byebug', platform: :mri, require: false 11 | end 12 | -------------------------------------------------------------------------------- /lib/order_query/errors.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | # All the exceptions that can be raised by order query methods. 5 | module Errors 6 | # Raised when a column that OrderQuery assumes to never contain NULLs 7 | # contains a null. 8 | class NonNullableColumnIsNullError < RuntimeError 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_7_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 7.1.0' 8 | gem 'activesupport', '~> 7.1.0' 9 | 10 | platforms :mri, :rbx do 11 | gem 'sqlite3', '~> 1.4' 12 | gem 'pg', '>= 0.18', '< 2.0' 13 | gem 'mysql2', '>= 0.4.4' 14 | end 15 | 16 | eval_gemfile '../../shared.gemfile' 17 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_7_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 7.2.0' 8 | gem 'activesupport', '~> 7.2.0' 9 | 10 | platforms :mri, :rbx do 11 | gem 'sqlite3', '~> 1.4' 12 | gem 'pg', '>= 0.18', '< 2.0' 13 | gem 'mysql2', '>= 0.4.4' 14 | end 15 | 16 | eval_gemfile '../../shared.gemfile' 17 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_8_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 8.0.0' 8 | gem 'activesupport', '~> 8.0.0' 9 | 10 | platforms :mri, :rbx do 11 | gem 'sqlite3', '~> 2.2' 12 | gem 'pg', '>= 0.18', '< 2.0' 13 | gem 'mysql2', '>= 0.4.4' 14 | end 15 | 16 | group :test, :development do 17 | gem 'ostruct' 18 | end 19 | 20 | eval_gemfile '../../shared.gemfile' 21 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_8_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 8.1.0' 8 | gem 'activesupport', '~> 8.1.0' 9 | 10 | platforms :mri, :rbx do 11 | gem 'sqlite3', '~> 2.2' 12 | gem 'pg', '>= 0.18', '< 2.0' 13 | gem 'mysql2', '>= 0.4.4' 14 | end 15 | 16 | group :test, :development do 17 | gem 'ostruct' 18 | end 19 | 20 | eval_gemfile '../../shared.gemfile' 21 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.3 3 | 4 | Metrics/MethodLength: 5 | Max: 25 6 | 7 | Metrics/PerceivedComplexity: 8 | Max: 10 9 | 10 | Metrics/CyclomaticComplexity: 11 | Max: 10 12 | 13 | Metrics/BlockLength: 14 | Exclude: 15 | - spec/**/*.rb 16 | 17 | Naming/MemoizedInstanceVariableName: 18 | Enabled: false 19 | 20 | Naming/UncommunicativeMethodParamName: 21 | Enabled: false 22 | 23 | Style/AndOr: 24 | Enabled: false 25 | 26 | Style/SignalException: 27 | EnforcedStyle: semantic 28 | 29 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_5_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 5.0.6' 8 | gem 'activesupport', '~> 5.0.6' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v5.0.6/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L4 12 | gem 'mysql2', '< 0.5' 13 | 14 | # https://github.com/rails/rails/blob/v5.0.6/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L2 15 | gem 'pg', '~> 0.18' 16 | 17 | # https://github.com/rails/rails/blob/v5.0.6/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L7 18 | gem 'sqlite3', '~> 1.3.6' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_5_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 5.1.3' 8 | gem 'activesupport', '~> 5.1.3' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v5.1.5/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L4 12 | gem 'mysql2', '< 0.5' 13 | 14 | # https://github.com/rails/rails/blob/v5.1.5/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L2 15 | gem 'pg', '>= 0.18', '< 2.0' 16 | 17 | # https://github.com/rails/rails/blob/v5.1.5/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L10 18 | gem 'sqlite3', '~> 1.3.6' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_6_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 6.0.3' 8 | gem 'activesupport', '~> 6.0.3' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L13 12 | gem 'sqlite3', '~> 1.4' 13 | 14 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L4 15 | gem 'pg', '>= 0.18', '< 2.0' 16 | 17 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 18 | gem 'mysql2', '>= 0.4.4' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 6.1.1' 8 | gem 'activesupport', '~> 6.1.1' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L13 12 | gem 'sqlite3', '~> 1.4' 13 | 14 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L4 15 | gem 'pg', '>= 0.18', '< 2.0' 16 | 17 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 18 | gem 'mysql2', '>= 0.4.4' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_7_0.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 7.0.0' 8 | gem 'activesupport', '~> 7.0.0' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L13 12 | gem 'sqlite3', '~> 1.4' 13 | 14 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L4 15 | gem 'pg', '>= 0.18', '< 2.0' 16 | 17 | # https://github.com/rails/rails/blob/v6.0.0/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 18 | gem 'mysql2', '>= 0.4.4' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /spec/gemfiles/rails_5_2.gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source 'https://rubygems.org' 4 | 5 | gemspec path: '../../' 6 | 7 | gem 'activerecord', '~> 5.2.3' 8 | gem 'activesupport', '~> 5.2.3' 9 | 10 | platforms :mri, :rbx do 11 | # https://github.com/rails/rails/blob/v5.2.3/activerecord/lib/active_record/connection_adapters/mysql2_adapter.rb#L6 12 | gem 'mysql2', '>= 0.4.4', '< 0.6.0' 13 | 14 | # https://github.com/rails/rails/blob/v5.2.3/activerecord/lib/active_record/connection_adapters/postgresql_adapter.rb#L4 15 | gem 'pg', '>= 0.18', '< 2.0' 16 | 17 | # https://github.com/rails/rails/blob/v5.2.3/activerecord/lib/active_record/connection_adapters/sqlite3_adapter.rb#L12 18 | gem 'sqlite3', '~> 1.3', '>= 1.3.6' 19 | end 20 | 21 | eval_gemfile '../../shared.gemfile' 22 | -------------------------------------------------------------------------------- /lib/order_query/direction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | # Responsible for handling :asc and :desc 5 | module Direction 6 | module_function 7 | 8 | DIRECTIONS = %i[asc desc].freeze 9 | 10 | def all 11 | DIRECTIONS 12 | end 13 | 14 | # @param [:asc, :desc] direction 15 | # @return [:asc, :desc] 16 | def reverse(direction) 17 | all[(all.index(direction) + 1) % 2].to_sym 18 | end 19 | 20 | # @param [:asc, :desc] direction 21 | # @raise [ArgumentError] 22 | # @return [:asc, :desc] 23 | def parse!(direction) 24 | all.include?(direction) && direction or 25 | fail ArgumentError, 26 | "sort direction must be in #{all.map(&:inspect).join(', ')}, "\ 27 | "is #{direction.inspect}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/order_query/sql/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | module SQL 5 | # A column in the given scope. 6 | class Column 7 | attr_reader :scope, :column 8 | 9 | def initialize(scope, column) 10 | @scope = scope 11 | @column = column 12 | end 13 | 14 | def column_name 15 | @column_name ||= begin 16 | sql = column.custom_sql 17 | if sql 18 | sql.respond_to?(:call) ? sql.call : sql 19 | else 20 | "#{connection.quote_table_name(scope.table_name)}."\ 21 | "#{connection.quote_column_name(column.name)}" 22 | end 23 | end 24 | end 25 | 26 | def quote(value) 27 | connection.quote value 28 | end 29 | 30 | protected 31 | 32 | def connection 33 | scope.connection 34 | end 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /MIT-LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014-2017 Gleb Mazovetskiy 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 columns: 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 | -------------------------------------------------------------------------------- /order_query.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | $LOAD_PATH.push File.expand_path('lib', __dir__) 4 | require 'order_query/version' 5 | 6 | # Describe your gem and declare its dependencies: 7 | Gem::Specification.new do |s| 8 | s.name = 'order_query' 9 | s.version = OrderQuery::VERSION 10 | s.author = 'Gleb Mazovetskiy' 11 | s.email = 'glex.spb@gmail.com' 12 | s.homepage = 'https://github.com/glebm/order_query' 13 | s.license = 'MIT' 14 | s.summary = 'Find next / previous Active Record(s) in one query' 15 | s.description = 'Find next / previous Active Record(s) in one efficient query' 16 | s.files = Dir[ 17 | '{app,lib,config}/**/*', 'MIT-LICENSE', 'Rakefile', 'Gemfile', '*.md'] 18 | s.test_files = Dir['spec/**/*'] 19 | 20 | if s.respond_to?(:metadata=) 21 | s.metadata = { 'issue_tracker' => 'https://github.com/glebm/order_query' } 22 | end 23 | 24 | s.required_ruby_version = '>= 2.3.0' 25 | 26 | s.add_dependency 'activerecord', '>= 5.0', '< 8.2' 27 | s.add_dependency 'activesupport', '>= 5.0', '< 8.2' 28 | s.add_development_dependency 'rake', '~> 13.0' 29 | s.add_development_dependency 'rspec', '~> 3.4' 30 | s.add_development_dependency 'simplecov' 31 | end 32 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Configure Rails Environment 4 | ENV['RAILS_ENV'] = ENV['RACK_ENV'] = 'test' 5 | if ENV['COVERAGE'] && !%w[rbx jruby].include?(RUBY_ENGINE) 6 | require 'simplecov' 7 | SimpleCov.command_name 'RSpec' 8 | end 9 | require 'order_query' 10 | require 'ostruct' 11 | 12 | require_relative './support/order_expectation' 13 | 14 | require 'fileutils' 15 | FileUtils.mkpath 'log' unless File.directory? 'log' 16 | ActiveRecord::Base.logger = Logger.new('log/test-queries.log') 17 | adapter = ENV.fetch('DB', 'sqlite3') 18 | case adapter 19 | when 'mysql2', 'postgresql' 20 | system({ 'DB' => adapter }, 'script/create-db-users') unless ENV['TRAVIS'] 21 | config = { 22 | # Host 127.0.0.1 required for default postgres installation on Ubuntu. 23 | host: '127.0.0.1', 24 | database: 'order_query_gem_test', 25 | encoding: 'utf8', 26 | min_messages: 'WARNING', 27 | adapter: adapter, 28 | username: ENV['DB_USERNAME'] || 'order_query', 29 | password: ENV['DB_PASSWORD'] || 'order_query' 30 | } 31 | ActiveRecord::Tasks::DatabaseTasks.create config.stringify_keys 32 | ActiveRecord::Base.establish_connection config 33 | when 'sqlite3' 34 | ActiveRecord::Base.establish_connection adapter: adapter, database: ':memory:' 35 | else 36 | fail "Unknown DB adapter #{adapter}. "\ 37 | 'Valid adapters are: mysql2, postgresql, sqlite3.' 38 | end 39 | 40 | RSpec.configure do |c| 41 | c.include OrderExpectations 42 | end 43 | -------------------------------------------------------------------------------- /lib/order_query/nulls_direction.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | # Handles nulls :first and :last direction. 5 | module NullsDirection 6 | module_function 7 | 8 | DIRECTIONS = %i[first last].freeze 9 | 10 | def all 11 | DIRECTIONS 12 | end 13 | 14 | # @param [:first, :last] direction 15 | # @return [:first, :last] 16 | def reverse(direction) 17 | all[(all.index(direction) + 1) % 2].to_sym 18 | end 19 | 20 | # @param [:first, :last] direction 21 | # @raise [ArgumentError] 22 | # @return [:first, :last] 23 | def parse!(direction) 24 | all.include?(direction) && direction or 25 | fail ArgumentError, 26 | "`nulls` must be in #{all.map(&:inspect).join(', ')}, "\ 27 | "is #{direction.inspect}" 28 | end 29 | 30 | # @param scope [ActiveRecord::Relation] 31 | # @param dir [:asc, :desc] 32 | # @return [:first, :last] the default nulls order, based on the given 33 | # scope's connection adapter name. 34 | def default(scope, dir) 35 | case connection_adapter(scope) 36 | when /mysql|maria|sqlite|sqlserver/i 37 | (dir == :asc ? :first : :last) 38 | else 39 | # Oracle, Postgres 40 | (dir == :asc ? :last : :first) 41 | end 42 | end 43 | 44 | def connection_adapter(scope) 45 | if scope.respond_to?(:connection_db_config) 46 | # Rails >= 6.1.0 47 | scope.connection_db_config.adapter 48 | else 49 | scope.connection_config[:adapter] 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | begin 4 | require 'bundler/setup' 5 | rescue LoadError 6 | puts 'You must `gem install bundler` and `bundle install` to run rake tasks' 7 | end 8 | 9 | require 'bundler/gem_tasks' 10 | require 'rspec/core/rake_task' 11 | RSpec::Core::RakeTask.new(:spec) 12 | 13 | task default: :spec 14 | 15 | # rubocop:disable Metrics/BlockLength,Style/StderrPuts 16 | 17 | desc 'Test all Gemfiles from spec/*.gemfile' 18 | task :test_all_gemfiles do 19 | require 'pty' 20 | require 'shellwords' 21 | cmd = 'bundle install --quiet && bundle exec rake --trace' 22 | statuses = Dir.glob('./spec/gemfiles/*{[!.lock]}').map do |gemfile| 23 | Bundler.with_clean_env do 24 | env = { 'BUNDLE_GEMFILE' => gemfile } 25 | $stderr.puts "Testing #{File.basename(gemfile)}: 26 | export #{env.map { |k, v| "#{k}=#{Shellwords.escape v}" } * ' '}; #{cmd}" 27 | PTY.spawn(env, cmd) do |r, _w, pid| 28 | begin 29 | r.each_line { |l| puts l } 30 | rescue Errno::EIO # rubocop:disable Lint/HandleExceptions 31 | # Errno:EIO error means that the process has finished giving output. 32 | ensure 33 | ::Process.wait pid 34 | end 35 | end 36 | [$CHILD_STATUS&.exitstatus&.zero?, gemfile] 37 | end 38 | end 39 | failed_gemfiles = statuses.reject(&:first).map { |(_, gemfile)| gemfile } 40 | if failed_gemfiles.empty? 41 | $stderr.puts "✓ Tests pass with all #{statuses.size} gemfiles" 42 | else 43 | $stderr.puts "❌ FAILING (#{failed_gemfiles.size} / #{statuses.size}) 44 | #{failed_gemfiles * "\n"}" 45 | exit 1 46 | end 47 | end 48 | 49 | # rubocop:enable Metrics/BlockLength,Style/StderrPuts 50 | -------------------------------------------------------------------------------- /script/create-db-users: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -euo pipefail 4 | 5 | readonly NAME=order_query 6 | 7 | readonly GREEN='\033[0;32m' 8 | readonly RESET_COLOR='\033[0m' 9 | 10 | if [[ "$#" -ne 0 ]]; then cat <&2 "${GREEN}create-db-users: $@${RESET_COLOR}"; else echo >&2 "$@"; fi 21 | } 22 | 23 | create_mysql_user() { 24 | if mysql -s -u"$USER" -p"$PASS" -e '' 2>/dev/null; then return; fi 25 | log "Creating MySQL '$USER' user. MySQL root password required." 26 | local mysql_flags 27 | if [ -n "${TRAVIS+x}" ]; then 28 | mysql_flags='' 29 | else 30 | mysql_flags='-p' 31 | fi 32 | mysql --verbose -uroot $mysql_flags < /dev/null; then return; fi 40 | log "Creating Postgres '$USER' user." 41 | local cmd='psql postgres' 42 | if ! $cmd -c '' 2>/dev/null; then 43 | log "sudo required:" 44 | cmd="sudo -u ${PG_DAEMON_USER:-postgres} psql postgres" 45 | fi 46 | $cmd --echo-all <] 9 | attr_reader :columns 10 | delegate :count, :empty?, to: :@base_scope 11 | 12 | # @param [ActiveRecord::Relation] base_scope 13 | # @param [Array>] order_spec 14 | # @see Column#initialize for the order_spec element format. 15 | def initialize(base_scope, order_spec) 16 | @base_scope = base_scope 17 | @columns = order_spec.map(&:clone) 18 | @columns.map! do |cond_spec| 19 | build_column(base_scope, cond_spec) 20 | end 21 | # add primary key if columns are not unique 22 | unless @columns.last.unique? 23 | if @columns.detect(&:unique?) 24 | fail ArgumentError, 'Unique column must be last' 25 | end 26 | 27 | @columns << Column.new(base_scope, base_scope.primary_key) 28 | end 29 | @order_by_sql = SQL::OrderBy.new(@columns) 30 | end 31 | 32 | # @return [Point] 33 | def at(record) 34 | Point.new(record, self) 35 | end 36 | 37 | # @return [ActiveRecord::Relation] scope ordered by columns 38 | def scope 39 | @scope ||= @base_scope.order(Arel.sql(@order_by_sql.build)) 40 | end 41 | 42 | # @return [ActiveRecord::Relation] scope ordered by columns in reverse 43 | def scope_reverse 44 | @scope_reverse ||= @base_scope 45 | .order(Arel.sql(@order_by_sql.build_reverse)) 46 | end 47 | 48 | # @return [ActiveRecord::Base] 49 | def first 50 | scope.first 51 | end 52 | 53 | # @return [ActiveRecord::Base] 54 | def last 55 | scope_reverse.first 56 | end 57 | 58 | def inspect 59 | "#" 61 | end 62 | 63 | private 64 | 65 | def build_column(base_scope, cond_spec) 66 | column_spec = cond_spec.last.is_a?(Hash) ? cond_spec : cond_spec.push({}) 67 | attr_name, *vals_and_or_dir, options = column_spec 68 | Column.new(base_scope, attr_name, *vals_and_or_dir, **options) 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /CHANGES.md: -------------------------------------------------------------------------------- 1 | ## 0.5.6 2 | 3 | * Rails 8.1 now supported 4 | 5 | ## 0.5.5 6 | 7 | * Rails 8.0 now supported 8 | 9 | ## 0.5.4 10 | 11 | * Rails 7.2 now supported 12 | 13 | ## 0.5.3 14 | 15 | * Rails 7.1 now supported. 16 | 17 | ## 0.5.2 18 | 19 | * Ruby 3.0 now supported. 20 | * Rails 7.0 now supported. 21 | 22 | ## 0.5.1 23 | 24 | * Rails 6.1 now supported. 25 | ## 0.5.0 26 | 27 | * Rails 6 now supported. 28 | * Fixes support for `nil`s with explicit order, when a `nil` is neither 29 | the first nor the last element of the explicit order, 30 | e.g. `status: ['assigned', nil, 'fixed']`. 31 | [#93b08877](https://github.com/glebm/order_query/commit/93b08877790a0ff02eea0d835def6ff3c40a83da) 32 | 33 | ## 0.4.1 34 | 35 | * If a column had a `nulls:` option and there were multiple records with `NULL`, 36 | all of these records but one were previously skipped. This is now fixed. 37 | [#21](https://github.com/glebm/order_query/issues/21) 38 | 39 | ## 0.4.0 40 | 41 | * Adds nulls ordering options `nulls: :first` and `nulls: :last`. 42 | * Now supports Rails 5.2. 43 | * Dropped support for Rails < 5 and Ruby < 2.3. 44 | 45 | ## 0.3.4 46 | 47 | * The `before` and `after` methods now accept a boolean argument that indicates 48 | whether the relation should exclude the given point or not. 49 | By default the given point is excluded, if you want to include it, 50 | use `before(false)` / `after(false)`. 51 | 52 | ## 0.3.3 53 | 54 | * Now compatible with Rails 5 beta 1. 55 | 56 | ## 0.3.2 57 | 58 | * Optimization: do not wrap top-level disjunctive in `AND` when the column has an enumerated order. [Read more](https://github.com/glebm/order_query/issues/3#issuecomment-54764638). 59 | * Boolean enum columns (e.g. `[:pinned, [true, false]]`) are now automatically collapsed to `ORDER by column ASC|DESC`. 60 | 61 | ## 0.3.1 62 | 63 | * Automatically add primary key when there is no unique column for the order 64 | * Remove `complete` option 65 | * Fix Rubinius compatibility 66 | 67 | ## 0.3.0 68 | 69 | * `order_query` now accepts columns as varargs. Array form is still supported. 70 | * `order_by` renamed to `seek` 71 | 72 | ## 0.2.1 73 | 74 | * `complete` now defaults to true for list attributes as well. 75 | 76 | ## 0.2.0 77 | 78 | * Dynamic query methods renamed to `order_by` 79 | 80 | ## 0.1.3 81 | 82 | * New condition option `complete` for list conditions for optimized query generation 83 | 84 | ## 0.1.2 85 | 86 | * Wrap top-level `OR` with a redundant `AND` for [performance reasons](https://github.com/glebm/order_query/issues/3). 87 | * Remove redundant parens from the query 88 | 89 | ## 0.1.1 90 | 91 | * `#next(true)` and `#previous(true)` return `nil` if there is only one record in total. 92 | 93 | ## 0.1.0 94 | 95 | Initial release 96 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | dist: xenial 3 | sudo: false 4 | cache: bundler 5 | bundler_args: --path ../../vendor/bundle --without debug 6 | 7 | matrix: 8 | include: 9 | - name: Rubocop 10 | gemfile: spec/gemfiles/rubocop.gemfile 11 | rvm: 2.5.3 12 | script: bundle exec rubocop 13 | before_script: 14 | after_script: 15 | - gemfile: spec/gemfiles/rails_5_0.gemfile 16 | rvm: 2.3.8 17 | env: DB=sqlite3 18 | - gemfile: spec/gemfiles/rails_5_0.gemfile 19 | rvm: 2.3.8 20 | env: DB=mysql2 DB_USERNAME=root DB_PASSWORD="" 21 | services: mysql 22 | - gemfile: spec/gemfiles/rails_5_0.gemfile 23 | rvm: 2.3.8 24 | env: DB=postgresql DB_USERNAME=postgres DB_PASSWORD="" 25 | services: postgresql 26 | - gemfile: spec/gemfiles/rails_5_1.gemfile 27 | rvm: 2.3.8 28 | env: DB=sqlite3 29 | - gemfile: spec/gemfiles/rails_5_1.gemfile 30 | rvm: 2.3.8 31 | env: DB=mysql2 DB_USERNAME=root DB_PASSWORD="" 32 | services: mysql 33 | - gemfile: spec/gemfiles/rails_5_1.gemfile 34 | rvm: 2.3.8 35 | env: DB=postgresql DB_USERNAME=postgres DB_PASSWORD="" 36 | services: postgresql 37 | - gemfile: spec/gemfiles/rails_5_2.gemfile 38 | rvm: 2.3.8 39 | env: DB=sqlite3 40 | - gemfile: spec/gemfiles/rails_5_2.gemfile 41 | rvm: 2.3.8 42 | env: DB=mysql2 DB_USERNAME=root DB_PASSWORD="" 43 | services: mysql 44 | - gemfile: spec/gemfiles/rails_5_2.gemfile 45 | rvm: 2.3.8 46 | env: DB=postgresql DB_USERNAME=postgres DB_PASSWORD="" 47 | services: postgresql 48 | - gemfile: spec/gemfiles/rails_6_0.gemfile 49 | rvm: 2.5.3 50 | env: DB=sqlite3 51 | - gemfile: spec/gemfiles/rails_6_0.gemfile 52 | rvm: 2.5.3 53 | env: DB=mysql2 DB_USERNAME=root DB_PASSWORD="" 54 | services: mysql 55 | - gemfile: spec/gemfiles/rails_6_0.gemfile 56 | rvm: 2.5.3 57 | env: DB=postgresql DB_USERNAME=postgres DB_PASSWORD="" 58 | services: postgresql 59 | - gemfile: spec/gemfiles/rails_6_1.gemfile 60 | rvm: 2.5.3 61 | env: DB=sqlite3 62 | - gemfile: spec/gemfiles/rails_6_1.gemfile 63 | rvm: 2.5.3 64 | env: DB=mysql2 DB_USERNAME=root DB_PASSWORD="" 65 | services: mysql 66 | - gemfile: spec/gemfiles/rails_6_1.gemfile 67 | rvm: 2.5.3 68 | env: DB=postgresql DB_USERNAME=postgres DB_PASSWORD="" 69 | services: postgresql 70 | env: 71 | global: 72 | - TRAVIS=1 73 | - COVERAGE=1 74 | before_script: 75 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter 76 | - chmod +x ./cc-test-reporter 77 | - ./cc-test-reporter before-build 78 | script: bundle exec rspec --force-color --format d 79 | after_script: 80 | - > 81 | if [ ! -z "$COVERAGE" ]; then 82 | ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT 83 | fi 84 | -------------------------------------------------------------------------------- /lib/order_query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'active_support' 4 | require 'active_record' 5 | require 'order_query/space' 6 | require 'order_query/point' 7 | 8 | # This gem finds the next or previous record(s) relative to the current one 9 | # efficiently using keyset pagination, e.g. for navigation or infinite scroll. 10 | module OrderQuery 11 | extend ActiveSupport::Concern 12 | 13 | # @param [ActiveRecord::Relation] scope optional first argument 14 | # (default: self.class.all) 15 | # @param [Array>, OrderQuery::Spec] order_spec 16 | # @return [OrderQuery::Point] 17 | # @example 18 | # users = User.active 19 | # user = users.find(42) 20 | # next_user = user.seek(users, [:activated_at, :desc], [:id, :desc]).next 21 | def seek(*spec) 22 | fst = spec.first 23 | if fst.nil? || fst.is_a?(ActiveRecord::Relation) || 24 | fst.is_a?(ActiveRecord::Base) 25 | scope = spec.shift 26 | end 27 | scope ||= self.class.all 28 | scope.seek(*spec).at(self) 29 | end 30 | 31 | # Top-level functions. 32 | module ClassMethods 33 | # @return [OrderQuery::Space] 34 | def seek(*spec) 35 | # allow passing without a splat, as we can easily distinguish 36 | spec = spec.first if spec.length == 1 && spec.first.first.is_a?(Array) 37 | Space.new(all, spec) 38 | end 39 | 40 | #= DSL 41 | 42 | protected 43 | 44 | # @param [Symbol] name 45 | # @param [Array>] order_spec 46 | # @example 47 | # class Post < ActiveRecord::Base 48 | # include OrderQuery 49 | # order_query :order_home, 50 | # [:pinned, [true, false]] 51 | # [:published_at, :desc], 52 | # [:id, :desc] 53 | # end 54 | # 55 | #== Scopes 56 | # .order_home 57 | # # 58 | # .order_home_reverse 59 | # # 60 | # 61 | #== Class methods 62 | # .order_home_at(post) 63 | # # 64 | # .order_home_space 65 | # # 66 | # 67 | #== Instance methods 68 | # .order_home(scope) 69 | # # 70 | def order_query(name, *spec) 71 | define_singleton_method(:"#{name}_space") { seek(*spec) } 72 | class_eval <<-RUBY, __FILE__, __LINE__ + 1 73 | scope :#{name}, -> { #{name}_space.scope } 74 | scope :#{name}_reverse, -> { #{name}_space.scope_reverse } 75 | def self.#{name}_at(record) 76 | #{name}_space.at(record) 77 | end 78 | def #{name}(scope = self.class) 79 | scope.#{name}_space.at(self) 80 | end 81 | RUBY 82 | end 83 | end 84 | 85 | class << self 86 | attr_accessor :wrap_top_level_or 87 | end 88 | # Wrap top-level or with an AND and a redundant column for performance 89 | self.wrap_top_level_or = true 90 | end 91 | -------------------------------------------------------------------------------- /lib/order_query/point.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'order_query/space' 4 | require 'order_query/sql/where' 5 | require 'order_query/errors' 6 | 7 | module OrderQuery 8 | # Search around a record in an order space 9 | class Point 10 | attr_reader :record, :space 11 | delegate :first, :last, :count, to: :space 12 | 13 | # @param [ActiveRecord::Base] record 14 | # @param [OrderQuery::Space] space 15 | def initialize(record, space) 16 | @record = record 17 | @space = space 18 | @where_sql = SQL::Where.new(self) 19 | end 20 | 21 | # @param [true, false] loop if true, loops as if the last and the first 22 | # records were adjacent, unless there is only one record. 23 | # @return [ActiveRecord::Base] 24 | def next(loop = true) 25 | unless_record_eq after.first || (first if loop) 26 | end 27 | 28 | # @return [ActiveRecord::Base] 29 | def previous(loop = true) 30 | unless_record_eq before.first || (last if loop) 31 | end 32 | 33 | # @return [Integer] counting from 1 34 | def position 35 | space.count - after.count 36 | end 37 | 38 | # @param [true, false] strict if false, the given scope will include the 39 | # record at this point. 40 | # @return [ActiveRecord::Relation] 41 | def after(strict = true) 42 | side :after, strict 43 | end 44 | 45 | # @param [true, false] strict if false, the given scope will include the 46 | # record at this point. 47 | # @return [ActiveRecord::Relation] 48 | def before(strict = true) 49 | side :before, strict 50 | end 51 | 52 | # @param [:before, :after] side 53 | # @param [true, false] strict if false, the given scope will include the 54 | # record at this point. 55 | # @return [ActiveRecord::Relation] 56 | def side(side, strict = true) 57 | query, query_args = @where_sql.build(side, strict) 58 | scope = if side == :after 59 | space.scope 60 | else 61 | space.scope_reverse 62 | end 63 | scope.where(query, *query_args) 64 | end 65 | 66 | # @param column [Column] 67 | def value(column) 68 | v = record.send(column.name) 69 | if v.nil? && !column.nullable? 70 | fail Errors::NonNullableColumnIsNullError, 71 | "Column #{column.inspect} is NULL on record #{@record.send(inspect_method)}. "\ 72 | 'Set the `nulls` option to :first or :last.' 73 | end 74 | v 75 | end 76 | 77 | def inspect 78 | "#" 79 | end 80 | 81 | protected 82 | 83 | # @param [ActiveRecord::Base] rec 84 | # @return [ActiveRecord::Base, nil] rec unless rec == @record 85 | def unless_record_eq(rec) 86 | rec unless rec == @record 87 | end 88 | 89 | private 90 | def inspect_method 91 | Gem::Version.new(ActiveRecord::VERSION::STRING) >= Gem::Version.new("7.2.0") ? :full_inspect 92 | : :inspect 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/order_query/column.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'order_query/direction' 4 | require 'order_query/nulls_direction' 5 | require 'order_query/sql/column' 6 | module OrderQuery 7 | # An order column (sort column) 8 | class Column 9 | attr_reader :name, :order_enum, :custom_sql 10 | delegate :column_name, :quote, :scope, to: :@sql_builder 11 | 12 | # rubocop:disable Metrics/ParameterLists,Metrics/AbcSize 13 | # rubocop:disable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity 14 | # rubocop:disable Metrics/MethodLength 15 | 16 | # @param scope [ActiveRecord::Relation] 17 | # @param attr_name [Symbol] the name of the column, or the method providing 18 | # the value to sort by. 19 | # @param vals_and_or_dir [Array] optionally, values in the desired order, 20 | # and / or one of `:asc`, `:desc`. Default order is `:desc` if the values 21 | # are given (so the result is ordered like the values), `:asc` otherwise. 22 | # @param unique [Boolean] mark the attribute as unique to avoid redundant 23 | # columns. Default: `true` for primary key. 24 | # @param nulls [:first, :last, false] whether to consider NULLS to be 25 | # ordered first or last. If false, assumes that a column is not nullable 26 | # and raises [Errors::NonNullableColumnIsNullError] if a null is 27 | # encountered. 28 | # @param sql [String, nil] a custom sql fragment. 29 | def initialize(scope, attr_name, *vals_and_or_dir, 30 | unique: nil, nulls: false, sql: nil) 31 | @name = attr_name 32 | @order_enum = vals_and_or_dir.shift if vals_and_or_dir[0].is_a?(Array) 33 | @direction = Direction.parse!( 34 | vals_and_or_dir.shift || (@order_enum ? :desc : :asc) 35 | ) 36 | unless vals_and_or_dir.empty? 37 | fail ArgumentError, 38 | "extra arguments: #{vals_and_or_dir.map(&:inspect) * ', '}" 39 | end 40 | @unique = unique.nil? ? (name.to_s == scope.primary_key) : unique 41 | if @order_enum&.include?(nil) 42 | fail ArgumentError, '`nulls` cannot be set if a value is null' if nulls 43 | 44 | @nullable = true 45 | @nulls = if @order_enum[0].nil? 46 | @direction == :desc ? :first : :last 47 | else 48 | @direction == :desc ? :last : :first 49 | end 50 | else 51 | @nullable = !!nulls # rubocop:disable Style/DoubleNegation 52 | @nulls = NullsDirection.parse!( 53 | nulls || NullsDirection.default(scope, @direction) 54 | ) 55 | end 56 | @custom_sql = sql 57 | @sql_builder = SQL::Column.new(scope, self) 58 | end 59 | # rubocop:enable Metrics/ParameterLists,Metrics/AbcSize 60 | # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity 61 | # rubocop:enable Metrics/MethodLength 62 | 63 | def direction(reverse = false) 64 | reverse ? Direction.reverse(@direction) : @direction 65 | end 66 | 67 | # @return [:first, :last] 68 | def nulls_direction(reverse = false) 69 | reverse ? NullsDirection.reverse(@nulls) : @nulls 70 | end 71 | 72 | # @return [:first, :last] 73 | def default_nulls_direction(reverse = false) 74 | NullsDirection.default(scope, direction(reverse)) 75 | end 76 | 77 | def nullable? 78 | @nullable 79 | end 80 | 81 | def unique? 82 | @unique 83 | end 84 | 85 | # @param [Object] value 86 | # @param [:before, :after] side 87 | # @return [Array] valid order values before / after the given value. 88 | # @example for [:difficulty, ['Easy', 'Normal', 'Hard']]: 89 | # enum_side('Normal', :after) #=> ['Hard'] 90 | # enum_side('Normal', :after, false) #=> ['Normal', 'Hard'] 91 | def enum_side(value, side, strict = true) # rubocop:disable Metrics/AbcSize 92 | ord = order_enum 93 | pos = ord.index(value) 94 | if pos 95 | dir = direction 96 | if side == :after && dir == :desc || side == :before && dir == :asc 97 | ord.from pos + (strict ? 1 : 0) 98 | else 99 | ord.first pos + (strict ? 0 : 1) 100 | end 101 | else 102 | # default to all if current is not in sort order values 103 | [] 104 | end 105 | end 106 | 107 | def inspect 108 | parts = [ 109 | @name, 110 | (@order_enum.inspect if order_enum), 111 | ('unique' if @unique), 112 | (column_name if @custom_sql), 113 | @direction 114 | ].compact 115 | "(#{parts.join(' ')})" 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /lib/order_query/sql/order_by.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | module SQL 5 | # Constructs SQL for ORDER BY. 6 | class OrderBy 7 | # @param [Array] 8 | def initialize(columns) 9 | @columns = columns 10 | end 11 | 12 | # @return [String] 13 | def build 14 | @sql ||= join_order_by_clauses order_by_sql_clauses 15 | end 16 | 17 | # @return [String] 18 | def build_reverse 19 | @reverse_sql ||= join_order_by_clauses order_by_sql_clauses(true) 20 | end 21 | 22 | protected 23 | 24 | # @return [Array] 25 | def order_by_sql_clauses(reverse = false) 26 | @columns.map { |col| column_clause col, reverse } 27 | end 28 | 29 | def column_clause(col, reverse = false) 30 | if col.order_enum 31 | column_clause_enum col, reverse 32 | else 33 | column_clause_ray col, reverse 34 | end 35 | end 36 | 37 | def column_clause_ray(col, reverse = false, 38 | with_null_clause = needs_null_sort?(col, reverse)) 39 | clauses = [] 40 | # TODO: use NULLS FIRST/LAST where supported. 41 | clauses << order_by_nulls_sql(col, reverse) if with_null_clause 42 | clauses << "#{col.column_name} #{sort_direction_sql(col, reverse)}" 43 | clauses.join(', ').freeze 44 | end 45 | 46 | # rubocop:disable Metrics/AbcSize 47 | 48 | def column_clause_enum(col, reverse = false) 49 | # Collapse booleans enum to `ORDER BY column ASC|DESC` 50 | return optimize_enum_bools(col, reverse) if optimize_enum_bools?(col) 51 | if optimize_enum_bools_nil?(col) 52 | return optimize_enum_bools_nil(col, reverse) 53 | end 54 | 55 | clauses = [] 56 | with_nulls = false 57 | if col.order_enum.include?(nil) 58 | with_nulls = true 59 | elsif needs_null_sort?(col, reverse) 60 | clauses << order_by_nulls_sql(col, reverse) 61 | end 62 | clauses.concat(col.order_enum.map do |v| 63 | "#{order_by_value_sql col, v, with_nulls} " \ 64 | "#{sort_direction_sql(col, reverse)}" 65 | end) 66 | clauses.join(', ').freeze 67 | end 68 | # rubocop:enable Metrics/AbcSize 69 | 70 | def needs_null_sort?(col, reverse, 71 | nulls_direction = col.nulls_direction(reverse)) 72 | return false unless col.nullable? 73 | 74 | nulls_direction != col.default_nulls_direction(reverse) 75 | end 76 | 77 | def order_by_nulls_sql(col, reverse) 78 | if col.default_nulls_direction != 79 | (col.direction == :asc ? :first : :last) 80 | reverse = !reverse 81 | end 82 | "#{col.column_name} IS NULL #{sort_direction_sql(col, reverse)}" 83 | end 84 | 85 | def order_by_value_sql(col, v, with_nulls = false) 86 | if with_nulls 87 | if v.nil? 88 | "#{col.column_name} IS NULL" 89 | else 90 | "#{col.column_name} IS NOT NULL AND " \ 91 | "#{col.column_name}=#{col.quote v}" 92 | end 93 | else 94 | "#{col.column_name}=#{col.quote v}" 95 | end 96 | end 97 | 98 | # @return [String] 99 | def sort_direction_sql(col, reverse = false) 100 | col.direction(reverse).to_s.upcase.freeze 101 | end 102 | 103 | # @param [Array] clauses 104 | def join_order_by_clauses(clauses) 105 | clauses.join(', ').freeze 106 | end 107 | 108 | private 109 | 110 | def optimize_enum_bools?(col) 111 | col.order_enum == [false, true] || col.order_enum == [true, false] 112 | end 113 | 114 | def optimize_enum_bools(col, reverse) 115 | column_clause_ray(col, col.order_enum[-1] ^ reverse) 116 | end 117 | 118 | ENUM_SET_TRUE_FALSE_NIL = Set[false, true, nil] 119 | 120 | def optimize_enum_bools_nil?(col) 121 | Set.new(col.order_enum) == ENUM_SET_TRUE_FALSE_NIL && 122 | !col.order_enum[1].nil? 123 | end 124 | 125 | def optimize_enum_bools_nil(col, reverse) 126 | last_bool_true = if col.order_enum[-1].nil? 127 | col.order_enum[-2] 128 | else 129 | col.order_enum[-1] 130 | end 131 | reverse_override = last_bool_true ^ reverse 132 | with_nulls_sort = 133 | needs_null_sort?(col, reverse_override, col.nulls_direction(reverse)) 134 | column_clause_ray(col, reverse_override, with_nulls_sort) 135 | end 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /lib/order_query/sql/where.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module OrderQuery 4 | module SQL 5 | # Builds where clause for searching around a record in an order space. 6 | class Where 7 | attr_reader :point 8 | 9 | # @param [OrderQuery::Point] point 10 | def initialize(point) 11 | @point = point 12 | @columns = point.space.columns 13 | end 14 | 15 | # Join column pairs with OR, and nest within each other with AND 16 | # @param [:before or :after] side 17 | # @return [query, parameters] WHERE columns matching records strictly 18 | # before / after this one. 19 | # 20 | # sales < 5 OR 21 | # sales = 5 AND ( 22 | # invoice < 3 OR 23 | # invoices = 3 AND ( 24 | # ... )) 25 | def build(side, strict = true) 26 | # generate pairs of terms such as sales < 5, sales = 5 27 | terms = @columns.map.with_index do |col, i| 28 | be_strict = i != @columns.size - 1 ? true : strict 29 | [where_side(col, side, be_strict), where_tie(col)].reject do |x| 30 | x == WHERE_IDENTITY 31 | end 32 | end 33 | # group pairwise with OR, and nest with AND 34 | query = foldr_terms terms.map { |pair| join_terms 'OR', *pair }, 'AND' 35 | if ::OrderQuery.wrap_top_level_or 36 | # wrap in a redundant AND clause for performance 37 | query = wrap_top_level_or query, terms, side 38 | end 39 | query 40 | end 41 | 42 | protected 43 | 44 | # @param [String] sql_operator SQL operator 45 | # @return [query, params] terms right-folded with sql_operator 46 | # [A, B, C, ...] -> A AND (B AND (C AND ...)) 47 | def foldr_terms(terms, sql_operator) 48 | foldr_i WHERE_IDENTITY, terms do |a, b, i| 49 | join_terms sql_operator, a, (i > 1 ? wrap_term_with_parens(b) : b) 50 | end 51 | end 52 | 53 | # joins terms with an operator, empty terms are skipped 54 | # @return [query, parameters] 55 | def join_terms(op, *terms) 56 | [terms.map(&:first).reject(&:empty?).join(" #{op} "), 57 | terms.map(&:second).reduce([], :+)] 58 | end 59 | 60 | def wrap_term_with_parens(t) 61 | ["(#{t[0]})", t[1]] 62 | end 63 | 64 | # Wrap top level OR clause to help DB with using the index 65 | # Before: 66 | # (sales < 5 OR 67 | # (sales = 5 AND ...)) 68 | # After: 69 | # (sales <= 5 AND 70 | # (sales < 5 OR 71 | # (sales = 5 AND ...))) 72 | # Read more at https://github.com/glebm/order_query/issues/3 73 | def wrap_top_level_or(query, terms, side) 74 | top_term_i = terms.index(&:present?) 75 | if top_term_i && terms[top_term_i].length == 2 && 76 | !(col = @columns[top_term_i]).order_enum 77 | join_terms 'AND', 78 | where_side(col, side, false), 79 | wrap_term_with_parens(query) 80 | else 81 | query 82 | end 83 | end 84 | 85 | # @return [query, params] tie-breaker unless column is unique 86 | def where_tie(col) 87 | if col.unique? 88 | WHERE_IDENTITY 89 | else 90 | where_eq(col) 91 | end 92 | end 93 | 94 | # @param [:before or :after] side 95 | # @return [query, params] return query fragment for column values 96 | # before / after the current one. 97 | def where_side(col, side, strict, value = point.value(col)) 98 | if col.order_enum 99 | where_in col, col.enum_side(value, side, strict) 100 | elsif value.nil? 101 | where_null col, side, strict 102 | else 103 | where_ray col, value, side, strict 104 | end 105 | end 106 | 107 | def where_in(col, values) 108 | join_terms 'OR', 109 | (values.include?(nil) ? where_eq(col, nil) : WHERE_IDENTITY), 110 | case (non_nil_values = values - [nil]).length 111 | when 0 112 | WHERE_IDENTITY 113 | when 1 114 | where_eq col, non_nil_values 115 | else 116 | ["#{col.column_name} IN (?)", [non_nil_values]] 117 | end 118 | end 119 | 120 | def where_eq(col, value = point.value(col)) 121 | if value.nil? 122 | ["#{col.column_name} IS NULL", []] 123 | else 124 | ["#{col.column_name} = ?", [value]] 125 | end 126 | end 127 | 128 | RAY_OP = { asc: '>', desc: '<' }.freeze 129 | NULLS_ORD = { first: 'IS NOT NULL', last: 'IS NULL' }.freeze 130 | 131 | def where_null(col, mode, strict) 132 | if strict && col.nulls_direction(mode == :before) != :last 133 | ["#{col.column_name} IS NOT NULL", []] 134 | else 135 | WHERE_IDENTITY 136 | end 137 | end 138 | 139 | def where_ray(col, from, mode, strict) 140 | ["#{col.column_name} "\ 141 | "#{RAY_OP[col.direction(mode == :before)]}#{'=' unless strict} ?", 142 | [from]].tap do |ray| 143 | if col.nullable? && col.nulls_direction(mode == :before) == :last 144 | ray[0] = "(#{ray[0]} OR #{col.column_name} IS NULL)" 145 | end 146 | end 147 | end 148 | 149 | WHERE_IDENTITY = ['', [].freeze].freeze 150 | 151 | private 152 | 153 | # Inject with index from right to left, turning [a, b, c] into a + (b + c) 154 | # Passes an index to the block, counting from the right 155 | # Read more about folds: 156 | # * http://www.haskell.org/haskellwiki/Fold 157 | # * http://en.wikipedia.org/wiki/Fold_(higher-order_function) 158 | def foldr_i(z, xs) 159 | xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| yield a, b, i } 160 | end 161 | end 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # order_query [![Build Status][travis-badge]][travis] [![Coverage Status][coverage-badge]][coverage] 2 | 3 | 4 | 100% offset-free 5 | 6 | 7 | This gem finds the next or previous record(s) relative to the current one efficiently using [keyset pagination](http://use-the-index-luke.com/no-offset), e.g. for navigation or infinite scroll. 8 | 9 | ## Installation 10 | 11 | Add to Gemfile: 12 | 13 | ```ruby 14 | gem 'order_query', '~> 0.5.6' 15 | ``` 16 | 17 | ## Usage 18 | 19 | Use `order_query(scope_name, *order_option)` to create scopes and class methods 20 | in your model and specify how you want results ordered. A basic example: 21 | 22 | ```ruby 23 | class Post < ActiveRecord::Base 24 | include OrderQuery 25 | order_query :order_home, 26 | [:pinned, [true, false]], # First sort by :pinned over t/f in :desc order 27 | [:published_at, :desc] # Next sort :published_at in :desc order 28 | end 29 | ``` 30 | 31 | Each order option specified in `order_query` is an array in the following form: 32 | 33 | 1. Symbol of the attribute name (required). 34 | 2. An array of values to order by, such as `%w(high medium low)` or `[true, false]` (optional). 35 | 3. Sort direction, `:asc` or `:desc` (optional). Default: `:asc`; `:desc` when values to order by are specified. 36 | 4. A hash (optional): 37 | 38 | | option | description | 39 | |------------|----------------------------------------------------------------------------| 40 | | unique | Unique attribute. Default: `true` for primary key, `false` otherwise. | 41 | | sql | Customize column SQL. | 42 | | nulls | If set to `:first` or `:last`, orders `NULL`s accordingly. | 43 | 44 | If no unique column is specified, `[primary_key, :asc]` is used. Unique column must be last. 45 | 46 | ### Scopes for `ORDER BY` 47 | 48 | ```ruby 49 | Post.published.order_home #=> # 50 | Post.published.order_home_reverse #=> # 51 | ``` 52 | 53 | ### Before / after, previous / next, and position 54 | 55 | First, get an `OrderQuery::Point` for the record: 56 | 57 | ```ruby 58 | p = Post.published.order_home_at(Post.find(31)) #=> # 59 | ``` 60 | 61 | It exposes these finder methods: 62 | 63 | ```ruby 64 | p.before #=> # 65 | p.after #=> # 66 | p.previous #=> # 67 | p.next #=> # 68 | p.position #=> 5 69 | ``` 70 | 71 | The `before` and `after` methods also accept a boolean argument that indicates 72 | whether the relation should exclude the given point or not. 73 | By default the given point is excluded, if you want to include it, 74 | use `before(false)` / `after(false)`. 75 | 76 | If you want to obtain only a chunk (i.e., a page), use `before` or `after` 77 | with ActiveRecord's `limit` method: 78 | 79 | ```ruby 80 | p.after.limit(20) #=> # 81 | ``` 82 | 83 | Looping to the first / last record is enabled for `next` / `previous` by default. Pass `false` to disable: 84 | 85 | ```ruby 86 | p = Post.order_home_at(Post.order_home.first) 87 | p.previous #=> # 88 | p.previous(false) #=> nil 89 | ``` 90 | 91 | Even with looping, `nil` will be returned if there is only one record. 92 | 93 | You can also get an `OrderQuery::Point` from an instance and a scope: 94 | 95 | ```ruby 96 | posts = Post.published 97 | post = posts.find(42) 98 | post.order_home(posts) #=> # 99 | ``` 100 | 101 | ### Dynamic columns 102 | 103 | Query with dynamic order columns using the `seek(*order)` class method: 104 | 105 | ```ruby 106 | space = Post.visible.seek([:id, :desc]) #=> # 107 | ``` 108 | 109 | This returns an `OrderQuery::Space` that exposes these methods: 110 | 111 | ```ruby 112 | space.scope #=> # 113 | space.scope_reverse #=> # 114 | space.first #=> scope.first 115 | space.last #=> scope_reverse.first 116 | space.at(Post.first) #=> # 117 | ``` 118 | 119 | `OrderQuery::Space` is also available for defined order_queries: 120 | 121 | ```ruby 122 | Post.visible.order_home_space #=> # 123 | ``` 124 | 125 | Alternatively, get an `OrderQuery::Point` using the `seek(scope, *order)` instance method: 126 | 127 | ```ruby 128 | Post.find(42).seek(Post.visible, [:id, :desc]) #=> # 129 | # scope defaults to Post.all 130 | Post.find(42).seek([:id, :desc]) #=> # 131 | ``` 132 | 133 | ### Advanced example 134 | 135 | ```ruby 136 | class Post < ActiveRecord::Base 137 | include OrderQuery 138 | order_query :order_home, 139 | # For an array of order values, default direction is :desc 140 | # High-priority issues will be ordered first in this example 141 | [:priority, %w(high medium low)], 142 | # A method and custom SQL can be used instead of an attribute 143 | [:valid_votes_count, :desc, sql: '(votes - suspicious_votes)'], 144 | # Default sort order for non-array columns is :asc, just like SQL 145 | [:updated_at, :desc], 146 | # pass unique: true for unique attributes to get more optimized queries 147 | # unique is true by default for primary_key 148 | [:id, :desc] 149 | def valid_votes_count 150 | votes - suspicious_votes 151 | end 152 | end 153 | ``` 154 | 155 | ## How it works 156 | 157 | Internally this gem builds a query that depends on the current record's values and looks like this: 158 | 159 | ```sql 160 | -- Current post: pinned=true published_at='2014-03-21 15:01:35.064096' id=9 161 | SELECT "posts".* FROM "posts" WHERE 162 | ("posts"."pinned" = 'f' OR 163 | "posts"."pinned" = 't' AND ( 164 | "posts"."published_at" < '2014-03-21 15:01:35.064096' OR 165 | "posts"."published_at" = '2014-03-21 15:01:35.064096' AND "posts"."id" < 9)) 166 | ORDER BY 167 | "posts"."pinned"='t' DESC, "posts"."pinned"='f' DESC, 168 | "posts"."published_at" DESC, 169 | "posts"."id" DESC 170 | LIMIT 1 171 | ``` 172 | 173 | The actual query is a bit different because `order_query` wraps the top-level `OR` with a (redundant) non-strict column `x0' AND (x0 OR ...)` 174 | for [performance reasons](https://github.com/glebm/order_query/issues/3). 175 | This can be disabled with `OrderQuery.wrap_top_level_or = false`. 176 | 177 | See the implementation in [sql/where.rb](/lib/order_query/sql/where.rb). 178 | 179 | See how this affects query planning in Markus Winand's slides on [Pagination done the Right Way](http://use-the-index-luke.com/blog/2013-07/pagination-done-the-postgresql-way). 180 | 181 | This project uses MIT license. 182 | 183 | 184 | [travis]: http://travis-ci.org/glebm/order_query 185 | [travis-badge]: http://img.shields.io/travis/glebm/order_query.svg 186 | [gemnasium]: https://gemnasium.com/glebm/order_query 187 | [coverage]: https://codeclimate.com/github/glebm/order_query 188 | [coverage-badge]: https://api.codeclimate.com/v1/badges/82e424e9ee2acb02292c/test_coverage 189 | -------------------------------------------------------------------------------- /spec/order_query_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'spec_helper' 4 | 5 | # Bare model 6 | class User < ActiveRecord::Base 7 | include OrderQuery 8 | end 9 | 10 | # Simple model 11 | class Post < ActiveRecord::Base 12 | include OrderQuery 13 | order_query :order_list, 14 | [:pinned, [true, false]], 15 | %i[published_at desc], 16 | %i[id desc] 17 | end 18 | 19 | def create_post(attr = {}) 20 | Post.create!({ pinned: false, published_at: Time.now }.merge(attr)) 21 | end 22 | 23 | # Advanced model 24 | class Issue < ActiveRecord::Base 25 | DISPLAY_ORDER = [ 26 | [:pinned, [true, false]], 27 | [:priority, %w[high medium low]], 28 | [:valid_votes_count, :desc, sql: '(votes - suspicious_votes)'], 29 | %i[updated_at desc], 30 | %i[id desc] 31 | ].freeze 32 | 33 | def valid_votes_count 34 | votes - suspicious_votes 35 | end 36 | 37 | include OrderQuery 38 | order_query :display_order, DISPLAY_ORDER 39 | order_query :id_order_asc, [%i[id asc]] 40 | end 41 | 42 | def create_issue(attr = {}) 43 | Issue.create!( 44 | { priority: 'high', votes: 3, suspicious_votes: 0, updated_at: Time.now } 45 | .merge(attr) 46 | ) 47 | end 48 | 49 | def wrap_top_level_or(value) 50 | conf = ::OrderQuery 51 | around do |ex| 52 | was = conf.wrap_top_level_or 53 | begin 54 | conf.wrap_top_level_or = value 55 | ex.run 56 | ensure 57 | conf.wrap_top_level_or = was 58 | end 59 | end 60 | end 61 | 62 | RSpec.describe 'OrderQuery' do 63 | context 'Column' do 64 | it 'fails with ArgumentError if invalid vals_and_or_dir is passed' do 65 | expect do 66 | OrderQuery::Column.new(Post.all, :pinned, :desc, :extra) 67 | end.to raise_error(ArgumentError) 68 | end 69 | end 70 | 71 | context 'Point' do 72 | context '#value' do 73 | it 'fails if nil on non-nullable column' do 74 | post = OpenStruct.new 75 | post.pinned = nil 76 | space = Post.seek([:pinned]) 77 | expect do 78 | OrderQuery::Point.new(post, space) 79 | .value(space.columns.find { |c| c.name == :pinned }) 80 | end.to raise_error(OrderQuery::Errors::NonNullableColumnIsNullError) 81 | end 82 | end 83 | end 84 | 85 | [false, true].each do |wrap_top_level_or| 86 | context "(wtlo: #{wrap_top_level_or})" do 87 | wrap_top_level_or wrap_top_level_or 88 | 89 | context 'Issue test model' do 90 | datasets = lambda { 91 | t = Time.now 92 | [ 93 | [ 94 | ['high', 5, 0, t, true], 95 | ['high', 5, 1, t, true], 96 | ['high', 5, 0, t], 97 | ['high', 5, 0, t - 1.day], 98 | ['high', 5, 1, t], 99 | ['medium', 10, 0, t], 100 | ['medium', 10, 5, t - 12.hours], 101 | ['low', 30, 0, t + 1.day] 102 | ], 103 | [ 104 | ['high', 5, 0, t], 105 | ['high', 5, 1, t], 106 | ['high', 5, 1, t - 1.day], 107 | ['low', 30, 0, t + 1.day] 108 | ], 109 | [ 110 | ['high', 5, 1, t - 1.day], 111 | ['low', 30, 0, t + 1.day] 112 | ] 113 | ] 114 | }.call 115 | 116 | datasets.each_with_index do |ds, i| 117 | it "is ordered correctly (test data #{i})" do 118 | issues = ds.map do |attr| 119 | Issue.new(priority: attr[0], votes: attr[1], 120 | suspicious_votes: attr[2], updated_at: attr[3], 121 | pinned: attr[4] || false) 122 | end 123 | issues.shuffle.reverse_each(&:save!) 124 | expect(Issue.display_order.to_a).to eq(issues) 125 | expect(Issue.display_order_reverse.to_a).to eq(issues.reverse) 126 | issues.zip(issues.rotate).each_with_index do |(cur, nxt), j| 127 | expect(cur.display_order.position).to eq(j + 1) 128 | expect(cur.display_order.next).to eq(nxt) 129 | expect(Issue.display_order_at(cur).next).to eq nxt 130 | expect(cur.display_order.space.count).to eq(Issue.count) 131 | expect( 132 | cur.display_order.before.count + 1 + 133 | cur.display_order.after.count 134 | ).to eq(nxt.display_order.count) 135 | expect(nxt.display_order.previous).to eq(cur) 136 | expect( 137 | nxt.display_order.before.to_a.reverse + [nxt] + 138 | nxt.display_order.after.to_a 139 | ).to eq(Issue.display_order.to_a) 140 | end 141 | end 142 | end 143 | 144 | it '#next returns nil when there is only 1 record' do 145 | p = create_issue.display_order 146 | expect(p.next).to be_nil 147 | expect(p.next(true)).to be_nil 148 | end 149 | 150 | it 'is ordered correctly for order query [[:id, :asc]]' do 151 | a = create_issue 152 | b = create_issue 153 | expect(a.id_order_asc.next).to eq b 154 | expect(b.id_order_asc.previous).to eq a 155 | expect([a] + a.id_order_asc.after.to_a).to eq(Issue.id_order_asc.to_a) 156 | expect(b.id_order_asc.before.reverse.to_a + [b]).to( 157 | eq Issue.id_order_asc.to_a 158 | ) 159 | expect(Issue.id_order_asc.count).to eq(2) 160 | end 161 | 162 | it '.seek works on a list of ids' do 163 | ids = Array.new(3) { create_issue.id } 164 | expect(Issue.seek([[:id, ids]]).count).to eq ids.length 165 | expect(Issue.seek([:id, ids]).count).to eq ids.length 166 | expect(Issue.seek([:id, ids]).scope.pluck(:id)).to eq ids 167 | expect(Issue.seek([:id, ids]).scope_reverse.pluck(:id)).to( 168 | eq(ids.reverse) 169 | ) 170 | end 171 | 172 | it '.seek does not mutate the given order arguments' do 173 | order = [[:priority, :desc], [:id, :asc]] 174 | Issue.seek(order) 175 | expect(order).to eq [[:priority, :desc], [:id, :asc]] 176 | end 177 | 178 | context 'partitioned on a boolean flag' do 179 | before do 180 | create_issue(active: true) 181 | create_issue(active: false) 182 | create_issue(active: true) 183 | end 184 | 185 | let!(:order) { [%i[id desc]] } 186 | let!(:active) { Issue.where(active: true).seek(order) } 187 | let!(:inactive) { Issue.where(active: false).seek(order) } 188 | 189 | it '.seek preserves scope' do 190 | expect(inactive.count).to eq 1 191 | expect(inactive.scope.count).to eq 1 192 | expect(inactive.scope_reverse.count).to eq 1 193 | expect(active.count).to eq 2 194 | expect(active.scope.count).to eq 2 195 | expect(active.scope_reverse.count).to eq 2 196 | end 197 | 198 | it 'gives a valid result if at argument is outside of the space' do 199 | expect(inactive.at(active.first).next).to_not be_active 200 | expect(inactive.at(active.last).next).to_not be_active 201 | expect(active.at(inactive.first).next).to be_active 202 | expect(active.at(inactive.last).next).to be_active 203 | end 204 | 205 | it 'next/previous(false)' do 206 | expect(active.at(active.first).next(false)).to_not be_nil 207 | expect(active.at(active.last).next(false)).to be_nil 208 | expect(inactive.at(inactive.first).previous(false)).to be_nil 209 | # there is only one, so previous(last) is also nil 210 | expect(inactive.at(inactive.last).previous(false)).to be_nil 211 | end 212 | 213 | it 'previous(true) with only 1 record' do 214 | expect(inactive.at(inactive.last).previous(true)).to be_nil 215 | end 216 | end 217 | 218 | it '#seek falls back to scope when order column is missing self' do 219 | a = create_issue(priority: 'medium') 220 | b = create_issue(priority: 'high') 221 | expect( 222 | a.seek( 223 | Issue.display_order, 224 | [[:priority, %w[wontfix askbob]], %i[id desc]] 225 | ).next 226 | ).to eq(b) 227 | end 228 | 229 | context 'nil in string enum' do 230 | display = ->(issue) { "##{issue.id}-#{issue.priority || 'NULL'}" } 231 | priorities = [nil, 'low', 'medium', 'high'] 232 | let!(:issues) do 233 | priorities.flat_map do |p| 234 | [create_issue(priority: p), create_issue(priority: p)] 235 | end 236 | end 237 | priorities.permutation do |perm| 238 | it "works for #{perm} (desc)" do 239 | expect_order( 240 | Issue.seek([:priority, perm]), 241 | issues.sort_by { |x| [perm.index(x.priority), x.id] }, 242 | &display 243 | ) 244 | end 245 | it "works for #{perm} (asc)" do 246 | expect_order( 247 | Issue.seek([:priority, perm, :asc]), 248 | issues.sort_by { |x| [perm.index(x.priority), -x.id] }.reverse, 249 | &display 250 | ) 251 | end 252 | end 253 | end 254 | 255 | before do 256 | Issue.delete_all 257 | end 258 | 259 | before :all do 260 | ActiveRecord::Schema.define do 261 | self.verbose = false 262 | 263 | create_table :issues do |t| 264 | t.column :pinned, :boolean, null: false, default: false 265 | t.column :priority, :string 266 | t.column :votes, :integer 267 | t.column :suspicious_votes, :integer 268 | t.column :announced_at, :datetime 269 | t.column :updated_at, :datetime 270 | t.column :active, :boolean, null: false, default: true 271 | end 272 | end 273 | 274 | Issue.reset_column_information 275 | end 276 | 277 | after :all do 278 | ActiveRecord::Migration.drop_table :issues 279 | end 280 | end 281 | 282 | context 'Post test model' do 283 | it '#next works' do 284 | p1 = create_post(pinned: true) 285 | o1 = p1.order_list 286 | expect(o1.next).to be_nil 287 | expect(o1.next(true)).to be_nil 288 | p2 = create_post(pinned: false) 289 | o2 = p2.order_list 290 | expect(o1.next(false)).to eq(p2) 291 | expect(o2.next(false)).to be_nil 292 | expect(o2.next(true)).to eq(p1) 293 | end 294 | 295 | context '#inspect' do 296 | it 'Column' do 297 | expect(OrderQuery::Column.new(Post, :id, :desc).inspect) 298 | .to eq '(id unique desc)' 299 | expect( 300 | OrderQuery::Column.new(Post, :virtual, :desc, sql: 'SIN(id)') 301 | .inspect 302 | ).to eq '(virtual SIN(id) desc)' 303 | end 304 | 305 | let(:space) do 306 | OrderQuery::Space.new(Post, [[:pinned, [true, false]]]) 307 | end 308 | 309 | it 'Point' do 310 | post = create_post 311 | point = OrderQuery::Point.new(post, space) 312 | # rubocop:disable Metrics/LineLength 313 | expect(point.inspect).to eq %(# @space=#>) 314 | # rubocop:enable Metrics/LineLength 315 | end 316 | 317 | it 'Space' do 318 | # rubocop:disable Metrics/LineLength 319 | expect(space.inspect).to eq '#' 320 | # rubocop:enable Metrics/LineLength 321 | end 322 | end 323 | 324 | context 'boolean enum order' do 325 | before do 326 | create_post pinned: true 327 | create_post pinned: false 328 | end 329 | after do 330 | Post.delete_all 331 | end 332 | it 'ORDER BY is collapsed' do 333 | expect(Post.seek([:pinned, [true, false]]).scope.to_sql).to( 334 | match(/ORDER BY .posts.\..pinned. DESC/) 335 | ) 336 | end 337 | it 'enum asc' do 338 | expect( 339 | Post.seek([:pinned, [false, true], :asc]).scope.pluck(:pinned) 340 | ).to eq([true, false]) 341 | expect( 342 | Post.seek([:pinned, [true, false], :asc]).scope.pluck(:pinned) 343 | ).to eq([false, true]) 344 | end 345 | it 'enum desc' do 346 | expect( 347 | Post.seek([:pinned, [false, true], :desc]).scope.pluck(:pinned) 348 | ).to eq([false, true]) 349 | expect( 350 | Post.seek([:pinned, [true, false], :desc]).scope.pluck(:pinned) 351 | ).to eq([true, false]) 352 | end 353 | end 354 | 355 | context 'nil in boolean enum' do 356 | display = ->(post) { "##{post.id}-#{post.pinned || 'NULL'}" } 357 | states = [nil, false, true] 358 | let!(:posts) do 359 | states.flat_map do |state| 360 | [create_post(pinned: state), create_post(pinned: state)] 361 | end 362 | end 363 | states.permutation do |perm| 364 | it "works for #{perm} (desc)" do 365 | expect_order( 366 | Post.seek([:pinned, perm]), 367 | posts.sort_by { |x| [perm.index(x.pinned), x.id] }, 368 | &display 369 | ) 370 | end 371 | it "works for #{perm} (asc)" do 372 | expect_order( 373 | Post.seek([:pinned, perm, :asc]), 374 | posts.sort_by { |x| [-perm.index(x.pinned), x.id] }, 375 | &display 376 | ) 377 | end 378 | end 379 | end 380 | 381 | context 'nil published_at' do 382 | display = ->(post) { post.title } 383 | 384 | let! :null_1 do 385 | Post.create!(title: 'null_1', published_at: nil).reload 386 | end 387 | let! :null_2 do 388 | Post.create!(title: 'null_2', published_at: nil).reload 389 | end 390 | let! :older do 391 | Post.create!(title: 'older', published_at: Time.now + 1.hour) 392 | end 393 | let! :newer do 394 | Post.create!(title: 'newer', published_at: Time.now - 1.hour) 395 | end 396 | 397 | it 'orders nulls first (desc)' do 398 | space = Post.seek([:published_at, :desc, nulls: :first]) 399 | expect_order space, [null_1, null_2, older, newer], &display 400 | end 401 | 402 | it 'orders nulls first (asc)' do 403 | space = Post.seek([:published_at, :asc, nulls: :first]) 404 | expect_order space, [null_1, null_2, newer, older], &display 405 | end 406 | 407 | it 'orders nulls last (desc)' do 408 | space = Post.seek([:published_at, :desc, nulls: :last]) 409 | expect_order space, [older, newer, null_1, null_2], &display 410 | end 411 | 412 | it 'orders nulls last (asc)' do 413 | space = Post.seek([:published_at, :asc, nulls: :last]) 414 | expect_order space, [newer, older, null_1, null_2], &display 415 | end 416 | end 417 | 418 | context 'after/before no strict' do 419 | context 'by middle attribute in search order' do 420 | let! :base do 421 | Post.create! pinned: true, published_at: Time.now 422 | end 423 | let! :older do 424 | Post.create! pinned: true, published_at: Time.now + 1.hour 425 | end 426 | let! :newer do 427 | Post.create! pinned: true, published_at: Time.now - 1.hour 428 | end 429 | 430 | it 'includes first element' do 431 | point = Post.order_list_at(base) 432 | 433 | expect(point.after.count).to eq 1 434 | expect(point.after.to_a).to eq [newer] 435 | 436 | expect(point.after(false).count).to eq 2 437 | expect(point.after(false).to_a).to eq [base, newer] 438 | expect(point.before(false).to_a).to eq [base, older] 439 | end 440 | end 441 | 442 | context 'by last attribute in search order' do 443 | let!(:base) do 444 | Post.create! pinned: true, 445 | published_at: Time.new(2016, 5, 1, 5, 4, 3), 446 | id: 6 447 | end 448 | let!(:previous) do 449 | Post.create! pinned: true, 450 | published_at: Time.new(2016, 5, 1, 5, 4, 3), 451 | id: 4 452 | end 453 | let!(:next_one) do 454 | Post.create! pinned: true, 455 | published_at: Time.new(2016, 5, 1, 5, 4, 3), 456 | id: 9 457 | end 458 | 459 | it 'includes first element' do 460 | point = Post.order_list_at(base) 461 | 462 | expect(point.after.count).to eq 1 463 | expect(point.after.to_a).to eq [previous] 464 | 465 | expect(point.after(false).count).to eq 2 466 | expect(point.after(false).to_a).to eq [base, previous] 467 | expect(point.before(false).to_a).to eq [base, next_one] 468 | end 469 | end 470 | end 471 | 472 | before do 473 | Post.delete_all 474 | end 475 | before :all do 476 | ActiveRecord::Schema.define do 477 | self.verbose = false 478 | create_table :posts do |t| 479 | t.string :title 480 | t.boolean :pinned 481 | t.datetime :published_at 482 | end 483 | end 484 | Post.reset_column_information 485 | end 486 | after :all do 487 | ActiveRecord::Migration.drop_table :posts 488 | end 489 | end 490 | end 491 | end 492 | 493 | context 'SQL generation' do 494 | context 'wrap top-level OR on' do 495 | wrap_top_level_or true 496 | it 'wraps top-level OR' do 497 | after_scope = User.create!(updated_at: Date.parse('2014-09-06')) 498 | .seek([%i[updated_at desc], %i[id desc]]).after 499 | expect(after_scope.to_sql).to include('<=') 500 | end 501 | end 502 | 503 | context 'wrap top-level OR off' do 504 | wrap_top_level_or false 505 | it 'does not wrap top-level OR' do 506 | after_scope = User.create!(updated_at: Date.parse('2014-09-06')) 507 | .seek([%i[updated_at desc], %i[id desc]]).after 508 | expect(after_scope.to_sql).to_not include('<=') 509 | end 510 | end 511 | 512 | before do 513 | User.delete_all 514 | end 515 | 516 | before :all do 517 | ActiveRecord::Schema.define do 518 | self.verbose = false 519 | create_table :users do |t| 520 | t.datetime :updated_at, null: false 521 | end 522 | end 523 | User.reset_column_information 524 | end 525 | 526 | after :all do 527 | ActiveRecord::Migration.drop_table :users 528 | end 529 | end 530 | end 531 | --------------------------------------------------------------------------------