order_query 0.3.4 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 5665c0c52a581228b9afdc57825cd1cfbec7dcae
4
- data.tar.gz: a125032c1dfef35a5dfb251c0b4fb4d4f326c1d6
3
+ metadata.gz: d3d5bb8b4f22e2ed2f205ed413e75a41edd4348d
4
+ data.tar.gz: 1f216dcc1ec1d112126c68af4a20e28e949663de
5
5
  SHA512:
6
- metadata.gz: 4a5e1c93db80ef5b81da56f2e551f283ac86c3502df3caf73b0f75f717c4a5cb42e7e31dcc9223eb2b05f565db05f57b613b85c5b6da04629708e1eba657d7b3
7
- data.tar.gz: 94809f7775c4634d6f933095cc4671957d0c9fc17b7b7036adbf4c1d6c89b7c4ae9a45dace66c88cc0c42ae0610ff9f335ab7bf7721c4908c34da8aa5a7f8c5c
6
+ metadata.gz: c3f31357b9b6c3d53d3003c90fdce074bb80bd8f314d81c34e2f096da1d61b18d79b9ea053d3f103c11d962daa155ff4b941c54c23e1b91e7d3d170a43afc054
7
+ data.tar.gz: c1c644de117104e827dbc58422c313f77d892f2d6671533e852fa4e64ae26feb57274a1415b1f9a30696f943541501e522b6de7fd36f060fee1eb880ba165388
data/CHANGES.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.4.0
2
+
3
+ * Adds nulls ordering options `nulls: :first` and `nulls: :last`.
4
+ * Now supports Rails 5.2.
5
+ * Dropped support for Rails < 5 and Ruby < 2.3.
6
+
1
7
  ## 0.3.4
2
8
 
3
9
  * The `before` and `after` methods now accept a boolean argument that indicates
data/Gemfile CHANGED
@@ -1,5 +1,12 @@
1
+ # frozen_string_literal: true
2
+
1
3
  source 'https://rubygems.org'
2
4
 
3
5
  gemspec
4
6
 
7
+ # TODO: remove these lines and update spec/gemfiles/rails_5_2.gemfile once
8
+ # Rails 5.2 is out.
9
+ gem 'activerecord', '~> 5.2.0.rc1'
10
+ gem 'activesupport', '~> 5.2.0.rc1'
11
+
5
12
  eval_gemfile './shared.gemfile'
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # order_query [![Build Status][travis-badge]][travis] [![Code Climate][codeclimate-badge]][codeclimate] [![Coverage Status][coverage-badge]][coverage]
1
+ # order_query [![Build Status][travis-badge]][travis] [![Coverage Status][coverage-badge]][coverage]
2
2
 
3
3
  <a href="http://use-the-index-luke.com/no-offset">
4
4
  <img src="http://use-the-index-luke.com/img/no-offset.q200.png" alt="100% offset-free" align="right" width="106" height="106">
@@ -11,33 +11,35 @@ This gem finds the next or previous record(s) relative to the current one effici
11
11
  Add to Gemfile:
12
12
 
13
13
  ```ruby
14
- gem 'order_query', '~> 0.3.4'
14
+ gem 'order_query', '~> 0.4.0'
15
15
  ```
16
16
 
17
17
  ## Usage
18
18
 
19
- Define a named list of attributes to order by with `order_query(name, *order)`:
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:
20
21
 
21
22
  ```ruby
22
23
  class Post < ActiveRecord::Base
23
24
  include OrderQuery
24
25
  order_query :order_home,
25
- [:pinned, [true, false]],
26
- [:published_at, :desc]
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
27
28
  end
28
29
  ```
29
30
 
30
- Each attributes is specified as:
31
+ Each order option specified in `order_query` is an array in the following form:
31
32
 
32
- 1. Attribute name.
33
- 2. Optionally, values to order by, such as `%w(high medium low)` or `[true, false]`.
34
- 3. Sort direction, `:asc` or `:desc`. Default: `:asc`; `:desc` when values to order by are specified.
35
- 4. Options:
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):
36
37
 
37
38
  | option | description |
38
39
  |------------|----------------------------------------------------------------------------|
39
40
  | unique | Unique attribute. Default: `true` for primary key, `false` otherwise. |
40
41
  | sql | Customize column SQL. |
42
+ | nulls | If set to `:first` or `:last`, orders `NULL`s accordingly. |
41
43
 
42
44
  If no unique column is specified, `[primary_key, :asc]` is used. Unique column must be last.
43
45
 
@@ -66,11 +68,18 @@ p.next #=> #<Post>
66
68
  p.position #=> 5
67
69
  ```
68
70
 
69
- The `before` and `after` methods also accept a boolean argument that indicates
71
+ The `before` and `after` methods also accept a boolean argument that indicates
70
72
  whether the relation should exclude the given point or not.
71
73
  By default the given point is excluded, if you want to include it,
72
74
  use `before(false)` / `after(false)`.
73
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) #=> #<ActiveRecord::Relation>
81
+ ```
82
+
74
83
  Looping to the first / last record is enabled for `next` / `previous` by default. Pass `false` to disable:
75
84
 
76
85
  ```ruby
@@ -175,7 +184,5 @@ This project uses MIT license.
175
184
  [travis]: http://travis-ci.org/glebm/order_query
176
185
  [travis-badge]: http://img.shields.io/travis/glebm/order_query.svg
177
186
  [gemnasium]: https://gemnasium.com/glebm/order_query
178
- [codeclimate]: https://codeclimate.com/github/glebm/order_query
179
- [codeclimate-badge]: http://img.shields.io/codeclimate/github/glebm/order_query.svg
180
187
  [coverage]: https://codeclimate.com/github/glebm/order_query
181
- [coverage-badge]: https://codeclimate.com/github/glebm/order_query/badges/coverage.svg
188
+ [coverage-badge]: https://api.codeclimate.com/v1/badges/82e424e9ee2acb02292c/test_coverage
data/Rakefile CHANGED
@@ -1,14 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  begin
2
4
  require 'bundler/setup'
3
5
  rescue LoadError
4
6
  puts 'You must `gem install bundler` and `bundle install` to run rake tasks'
5
7
  end
6
8
 
9
+ require 'bundler/gem_tasks'
7
10
  require 'rspec/core/rake_task'
8
11
  RSpec::Core::RakeTask.new(:spec)
9
12
 
10
13
  task default: :spec
11
14
 
15
+ # rubocop:disable Metrics/BlockLength,Style/StderrPuts
16
+
12
17
  desc 'Test all Gemfiles from spec/*.gemfile'
13
18
  task :test_all_gemfiles do
14
19
  require 'pty'
@@ -16,25 +21,29 @@ task :test_all_gemfiles do
16
21
  cmd = 'bundle install --quiet && bundle exec rake --trace'
17
22
  statuses = Dir.glob('./spec/gemfiles/*{[!.lock]}').map do |gemfile|
18
23
  Bundler.with_clean_env do
19
- env = {'BUNDLE_GEMFILE' => gemfile}
20
- $stderr.puts "Testing #{File.basename(gemfile)}:\n export #{env.map { |k, v| "#{k}=#{Shellwords.escape v}" } * ' '}; #{cmd}"
24
+ env = { 'BUNDLE_GEMFILE' => gemfile }
25
+ $stderr.puts "Testing #{File.basename(gemfile)}:
26
+ export #{env.map { |k, v| "#{k}=#{Shellwords.escape v}" } * ' '}; #{cmd}"
21
27
  PTY.spawn(env, cmd) do |r, _w, pid|
22
28
  begin
23
29
  r.each_line { |l| puts l }
24
- rescue Errno::EIO
30
+ rescue Errno::EIO # rubocop:disable Lint/HandleExceptions
25
31
  # Errno:EIO error means that the process has finished giving output.
26
32
  ensure
27
33
  ::Process.wait pid
28
34
  end
29
35
  end
30
- [$? && $?.exitstatus == 0, gemfile]
36
+ [$CHILD_STATUS&.exitstatus&.zero?, gemfile]
31
37
  end
32
38
  end
33
- failed_gemfiles = statuses.reject(&:first).map { |(_status, gemfile)| gemfile }
39
+ failed_gemfiles = statuses.reject(&:first).map { |(_, gemfile)| gemfile }
34
40
  if failed_gemfiles.empty?
35
41
  $stderr.puts "✓ Tests pass with all #{statuses.size} gemfiles"
36
42
  else
37
- $stderr.puts "❌ FAILING (#{failed_gemfiles.size} / #{statuses.size})\n#{failed_gemfiles * "\n"}"
43
+ $stderr.puts "❌ FAILING (#{failed_gemfiles.size} / #{statuses.size})
44
+ #{failed_gemfiles * "\n"}"
38
45
  exit 1
39
46
  end
40
47
  end
48
+
49
+ # rubocop:enable Metrics/BlockLength,Style/StderrPuts
data/lib/order_query.rb CHANGED
@@ -1,12 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'active_support'
2
4
  require 'active_record'
3
5
  require 'order_query/space'
4
6
  require 'order_query/point'
5
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.
6
10
  module OrderQuery
7
11
  extend ActiveSupport::Concern
8
12
 
9
- # @param [ActiveRecord::Relation] scope optional first argument (default: self.class.all)
13
+ # @param [ActiveRecord::Relation] scope optional first argument
14
+ # (default: self.class.all)
10
15
  # @param [Array<Array<Symbol,String>>, OrderQuery::Spec] order_spec
11
16
  # @return [OrderQuery::Point]
12
17
  # @example
@@ -15,13 +20,15 @@ module OrderQuery
15
20
  # next_user = user.seek(users, [:activated_at, :desc], [:id, :desc]).next
16
21
  def seek(*spec)
17
22
  fst = spec.first
18
- if fst.nil? || fst.is_a?(ActiveRecord::Relation) || fst.is_a?(ActiveRecord::Base)
23
+ if fst.nil? || fst.is_a?(ActiveRecord::Relation) ||
24
+ fst.is_a?(ActiveRecord::Base)
19
25
  scope = spec.shift
20
26
  end
21
27
  scope ||= self.class.all
22
28
  scope.seek(*spec).at(self)
23
29
  end
24
30
 
31
+ # Top-level functions.
25
32
  module ClassMethods
26
33
  # @return [OrderQuery::Space]
27
34
  def seek(*spec)
@@ -31,7 +38,9 @@ module OrderQuery
31
38
  end
32
39
 
33
40
  #= DSL
41
+
34
42
  protected
43
+
35
44
  # @param [Symbol] name
36
45
  # @param [Array<Array<Symbol,String>>] order_spec
37
46
  # @example
@@ -60,7 +69,7 @@ module OrderQuery
60
69
  # #<OrderQuery::Point...>
61
70
  def order_query(name, *spec)
62
71
  define_singleton_method(:"#{name}_space") { seek(*spec) }
63
- class_eval <<-RUBY, __FILE__, __LINE__
72
+ class_eval <<-RUBY, __FILE__, __LINE__ + 1
64
73
  scope :#{name}, -> { #{name}_space.scope }
65
74
  scope :#{name}_reverse, -> { #{name}_space.scope_reverse }
66
75
  def self.#{name}_at(record)
@@ -1,41 +1,89 @@
1
- # coding: utf-8
1
+ # frozen_string_literal: true
2
+
2
3
  require 'order_query/direction'
4
+ require 'order_query/nulls_direction'
3
5
  require 'order_query/sql/column'
4
6
  module OrderQuery
5
7
  # An order column (sort column)
6
8
  class Column
7
- attr_reader :name, :order_enum, :options
8
- delegate :column_name, :quote, to: :@sql
9
+ attr_reader :name, :order_enum, :custom_sql
10
+ delegate :column_name, :quote, :scope, to: :@sql_builder
9
11
 
10
- # @option spec [String] :unique Mark the attribute as unique to avoid redundant columns
11
- def initialize(spec, scope)
12
- spec = spec.dup
13
- options = spec.extract_options!
14
- @name = spec[0]
15
- if spec[1].is_a?(Array)
16
- @order_enum = spec.delete_at(1)
17
- spec[1] ||= :desc
18
- end
19
- @direction = Direction.parse!(spec[1] || :asc)
20
- @options = options.reverse_merge(
21
- unique: name.to_s == scope.primary_key,
22
- complete: true
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)
23
35
  )
24
- @unique = @options[:unique]
25
- @sql = SQL::Column.new(self, scope)
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 && (@order_enum[0].nil? || @order_enum[-1].nil?)
42
+ fail ArgumentError, '`nulls` cannot be set if a value is null' if nulls
43
+ @nullable = true
44
+ @nulls = if @order_enum[0].nil?
45
+ @direction == :desc ? :first : :last
46
+ else
47
+ @direction == :desc ? :last : :first
48
+ end
49
+ else
50
+ @nullable = !!nulls # rubocop:disable Style/DoubleNegation
51
+ @nulls = NullsDirection.parse!(
52
+ nulls || NullsDirection.default(scope, @direction)
53
+ )
54
+ end
55
+ @custom_sql = sql
56
+ @sql_builder = SQL::Column.new(scope, self)
26
57
  end
58
+ # rubocop:enable Metrics/ParameterLists,Metrics/AbcSize
59
+ # rubocop:enable Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity
60
+ # rubocop:enable Metrics/MethodLength
27
61
 
28
62
  def direction(reverse = false)
29
63
  reverse ? Direction.reverse(@direction) : @direction
30
64
  end
31
65
 
66
+ # @return [:first, :last]
67
+ def nulls_direction(reverse = false)
68
+ reverse ? NullsDirection.reverse(@nulls) : @nulls
69
+ end
70
+
71
+ # @return [:first, :last]
72
+ def default_nulls_direction(reverse = false)
73
+ NullsDirection.default(scope, direction(reverse))
74
+ end
75
+
76
+ def nullable?
77
+ @nullable
78
+ end
79
+
32
80
  def unique?
33
81
  @unique
34
82
  end
35
83
 
36
84
  # @param [Object] value
37
85
  # @param [:before, :after] side
38
- # @return [Array] valid order values before / after passed (depending on the side)
86
+ # @return [Array] valid order values before / after the given value.
39
87
  # @example for [:difficulty, ['Easy', 'Normal', 'Hard']]:
40
88
  # enum_side('Normal', :after) #=> ['Hard']
41
89
  # enum_side('Normal', :after, false) #=> ['Normal', 'Hard']
@@ -57,11 +105,11 @@ module OrderQuery
57
105
 
58
106
  def inspect
59
107
  parts = [
60
- @name,
61
- (@order_enum.inspect if order_enum),
62
- ('unique' if @unique),
63
- (column_name if options[:sql]),
64
- @direction
108
+ @name,
109
+ (@order_enum.inspect if order_enum),
110
+ ('unique' if @unique),
111
+ (column_name if @custom_sql),
112
+ @direction
65
113
  ].compact
66
114
  "(#{parts.join(' ')})"
67
115
  end
@@ -1,9 +1,11 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
4
  # Responsible for handling :asc and :desc
3
5
  module Direction
4
- extend self
6
+ module_function
5
7
 
6
- DIRECTIONS = [:asc, :desc].freeze
8
+ DIRECTIONS = %i[asc desc].freeze
7
9
 
8
10
  def all
9
11
  DIRECTIONS
@@ -15,14 +17,14 @@ module OrderQuery
15
17
  all[(all.index(direction) + 1) % 2].to_sym
16
18
  end
17
19
 
18
- # @param [:asc, :desc, String] direction
20
+ # @param [:asc, :desc] direction
19
21
  # @raise [ArgumentError]
20
22
  # @return [:asc, :desc]
21
23
  def parse!(direction)
22
- if all.include?(direction)
23
- direction
24
- end or
25
- raise ArgumentError.new("sort direction must be in #{all.map(&:inspect).join(', ')}, is #{direction.inspect}")
24
+ all.include?(direction) && direction or
25
+ fail ArgumentError,
26
+ "sort direction must be in #{all.map(&:inspect).join(', ')}, "\
27
+ "is #{direction.inspect}"
26
28
  end
27
29
  end
28
30
  end
@@ -0,0 +1,11 @@
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
@@ -0,0 +1,44 @@
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 scope.connection_config[:adapter]
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
+ end
44
+ end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'order_query/space'
2
4
  require 'order_query/sql/where'
5
+ require 'order_query/errors'
3
6
 
4
7
  module OrderQuery
5
8
  # Search around a record in an order space
@@ -15,7 +18,8 @@ module OrderQuery
15
18
  @where_sql = SQL::Where.new(self)
16
19
  end
17
20
 
18
- # @params [true, false] loop if true, consider last and first as adjacent (unless they are equal)
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.
19
23
  # @return [ActiveRecord::Base]
20
24
  def next(loop = true)
21
25
  unless_record_eq after.first || (first if loop)
@@ -31,20 +35,23 @@ module OrderQuery
31
35
  space.count - after.count
32
36
  end
33
37
 
34
- # @param [true, false] strict choose if the given scope should include or not the record, default not to include it (strict true)
38
+ # @param [true, false] strict if false, the given scope will include the
39
+ # record at this point.
35
40
  # @return [ActiveRecord::Relation]
36
41
  def after(strict = true)
37
42
  side :after, strict
38
43
  end
39
44
 
40
- # @param [true, false] strict choose if the given scope should include or not the record, default not to include it (strict true)
45
+ # @param [true, false] strict if false, the given scope will include the
46
+ # record at this point.
41
47
  # @return [ActiveRecord::Relation]
42
48
  def before(strict = true)
43
49
  side :before, strict
44
50
  end
45
51
 
46
52
  # @param [:before, :after] side
47
- # @param [true, false] strict choose if the given scope should include or not the record, default not to include it (strict true)
53
+ # @param [true, false] strict if false, the given scope will include the
54
+ # record at this point.
48
55
  # @return [ActiveRecord::Relation]
49
56
  def side(side, strict = true)
50
57
  query, query_args = @where_sql.build(side, strict)
@@ -56,8 +63,15 @@ module OrderQuery
56
63
  scope.where(query, *query_args)
57
64
  end
58
65
 
59
- def value(cond)
60
- record.send(cond.name)
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.inspect}. "\
72
+ 'Set the `nulls` option to :first or :last.'
73
+ end
74
+ v
61
75
  end
62
76
 
63
77
  def inspect