order_query 0.3.4 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'order_query/column'
2
4
  require 'order_query/sql/order_by'
3
5
  module OrderQuery
@@ -8,14 +10,19 @@ module OrderQuery
8
10
  delegate :count, :empty?, to: :@base_scope
9
11
 
10
12
  # @param [ActiveRecord::Relation] base_scope
11
- # @param [Array<Array<Symbol,String>>, OrderQuery::Spec] order_spec
13
+ # @param [Array<Array<Symbol,String>>] order_spec
14
+ # @see Column#initialize for the order_spec element format.
12
15
  def initialize(base_scope, order_spec)
13
- @base_scope = base_scope
14
- @columns = order_spec.map { |cond_spec| Column.new(cond_spec, base_scope) }
16
+ @base_scope = base_scope
17
+ @columns = order_spec.map do |cond_spec|
18
+ Column.new(base_scope, *cond_spec)
19
+ end
15
20
  # add primary key if columns are not unique
16
21
  unless @columns.last.unique?
17
- raise ArgumentError.new('Unique column must be last') if @columns.detect(&:unique?)
18
- @columns << Column.new([base_scope.primary_key], base_scope)
22
+ if @columns.detect(&:unique?)
23
+ fail ArgumentError, 'Unique column must be last'
24
+ end
25
+ @columns << Column.new(base_scope, base_scope.primary_key)
19
26
  end
20
27
  @order_by_sql = SQL::OrderBy.new(@columns)
21
28
  end
@@ -27,12 +34,13 @@ module OrderQuery
27
34
 
28
35
  # @return [ActiveRecord::Relation] scope ordered by columns
29
36
  def scope
30
- @scope ||= @base_scope.order(@order_by_sql.build)
37
+ @scope ||= @base_scope.order(Arel.sql(@order_by_sql.build))
31
38
  end
32
39
 
33
40
  # @return [ActiveRecord::Relation] scope ordered by columns in reverse
34
41
  def scope_reverse
35
- @scope_reverse ||= @base_scope.order(@order_by_sql.build_reverse)
42
+ @scope_reverse ||= @base_scope
43
+ .order(Arel.sql(@order_by_sql.build_reverse))
36
44
  end
37
45
 
38
46
  # @return [ActiveRecord::Base]
@@ -46,7 +54,8 @@ module OrderQuery
46
54
  end
47
55
 
48
56
  def inspect
49
- "#<OrderQuery::Space @columns=#{@columns.inspect} @base_scope=#{@base_scope.inspect}>"
57
+ "#<OrderQuery::Space @columns=#{@columns.inspect} "\
58
+ "@base_scope=#{@base_scope.inspect}>"
50
59
  end
51
60
  end
52
61
  end
@@ -1,20 +1,24 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
4
  module SQL
5
+ # A column in the given scope.
3
6
  class Column
4
- attr_reader :column, :scope
7
+ attr_reader :scope, :column
5
8
 
6
- def initialize(column, scope)
7
- @column = column
9
+ def initialize(scope, column)
8
10
  @scope = scope
11
+ @column = column
9
12
  end
10
13
 
11
14
  def column_name
12
15
  @column_name ||= begin
13
- sql = column.options[:sql]
16
+ sql = column.custom_sql
14
17
  if sql
15
18
  sql.respond_to?(:call) ? sql.call : sql
16
19
  else
17
- connection.quote_table_name(scope.table_name) + '.' + connection.quote_column_name(column.name)
20
+ "#{connection.quote_table_name(scope.table_name)}."\
21
+ "#{connection.quote_column_name(column.name)}"
18
22
  end
19
23
  end
20
24
  end
@@ -1,5 +1,8 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
4
  module SQL
5
+ # Constructs SQL for ORDER BY.
3
6
  class OrderBy
4
7
  # @param [Array<Column>]
5
8
  def initialize(columns)
@@ -31,23 +34,63 @@ module OrderQuery
31
34
  end
32
35
  end
33
36
 
34
- def column_clause_ray(col, reverse = false)
35
- "#{col.column_name} #{sort_direction_sql(col, reverse)}".freeze
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
36
44
  end
37
45
 
46
+ # rubocop:disable Metrics/AbcSize
47
+
38
48
  def column_clause_enum(col, reverse = false)
39
- enum = col.order_enum
40
- # Collapse boolean enum to `ORDER BY column ASC|DESC`
41
- if enum == [false, true] || enum == [true, false]
42
- return column_clause_ray col, reverse ^ enum.last
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)
43
53
  end
44
- enum.map { |v|
45
- "#{order_by_value_sql col, v} #{sort_direction_sql(col, reverse)}"
46
- }.join(', ').freeze
54
+ clauses = []
55
+ with_nulls = false
56
+ if col.order_enum.include?(nil)
57
+ with_nulls = true
58
+ elsif needs_null_sort?(col, reverse)
59
+ clauses << order_by_nulls_sql(col, reverse)
60
+ end
61
+ clauses.concat(col.order_enum.map do |v|
62
+ "#{order_by_value_sql col, v, with_nulls} " \
63
+ "#{sort_direction_sql(col, reverse)}"
64
+ end)
65
+ clauses.join(', ').freeze
47
66
  end
67
+ # rubocop:enable Metrics/AbcSize
48
68
 
49
- def order_by_value_sql(col, v)
50
- "#{col.column_name}=#{col.quote v}"
69
+ def needs_null_sort?(col, reverse,
70
+ nulls_direction = col.nulls_direction(reverse))
71
+ return false unless col.nullable?
72
+ nulls_direction != col.default_nulls_direction(reverse)
73
+ end
74
+
75
+ def order_by_nulls_sql(col, reverse)
76
+ if col.default_nulls_direction !=
77
+ (col.direction == :asc ? :first : :last)
78
+ reverse = !reverse
79
+ end
80
+ "#{col.column_name} IS NULL #{sort_direction_sql(col, reverse)}"
81
+ end
82
+
83
+ def order_by_value_sql(col, v, with_nulls = false)
84
+ if with_nulls
85
+ if v.nil?
86
+ "#{col.column_name} IS NULL"
87
+ else
88
+ "#{col.column_name} IS NOT NULL AND " \
89
+ "#{col.column_name}=#{col.quote v}"
90
+ end
91
+ else
92
+ "#{col.column_name}=#{col.quote v}"
93
+ end
51
94
  end
52
95
 
53
96
  # @return [String]
@@ -59,6 +102,35 @@ module OrderQuery
59
102
  def join_order_by_clauses(clauses)
60
103
  clauses.join(', ').freeze
61
104
  end
105
+
106
+ private
107
+
108
+ def optimize_enum_bools?(col)
109
+ col.order_enum == [false, true] || col.order_enum == [true, false]
110
+ end
111
+
112
+ def optimize_enum_bools(col, reverse)
113
+ column_clause_ray(col, col.order_enum[-1] ^ reverse)
114
+ end
115
+
116
+ ENUM_SET_TRUE_FALSE_NIL = Set[false, true, nil]
117
+
118
+ def optimize_enum_bools_nil?(col)
119
+ Set.new(col.order_enum) == ENUM_SET_TRUE_FALSE_NIL &&
120
+ !col.order_enum[1].nil?
121
+ end
122
+
123
+ def optimize_enum_bools_nil(col, reverse)
124
+ last_bool_true = if col.order_enum[-1].nil?
125
+ col.order_enum[-2]
126
+ else
127
+ col.order_enum[-1]
128
+ end
129
+ reverse_override = last_bool_true ^ reverse
130
+ with_nulls_sort =
131
+ needs_null_sort?(col, reverse_override, col.nulls_direction(reverse))
132
+ column_clause_ray(col, reverse_override, with_nulls_sort)
133
+ end
62
134
  end
63
135
  end
64
136
  end
@@ -1,7 +1,8 @@
1
- # coding: utf-8
1
+ # frozen_string_literal: true
2
+
2
3
  module OrderQuery
3
4
  module SQL
4
- # Build where clause for searching around a record in an order space
5
+ # Builds where clause for searching around a record in an order space.
5
6
  class Where
6
7
  attr_reader :point
7
8
 
@@ -13,7 +14,9 @@ module OrderQuery
13
14
 
14
15
  # Join column pairs with OR, and nest within each other with AND
15
16
  # @param [:before or :after] side
16
- # @return [query, parameters] WHERE columns matching records strictly before / after this one
17
+ # @return [query, parameters] WHERE columns matching records strictly
18
+ # before / after this one.
19
+ #
17
20
  # sales < 5 OR
18
21
  # sales = 5 AND (
19
22
  # invoice < 3 OR
@@ -21,12 +24,14 @@ module OrderQuery
21
24
  # ... ))
22
25
  def build(side, strict = true)
23
26
  # generate pairs of terms such as sales < 5, sales = 5
24
- terms = @columns.map.with_index { |col, i|
25
- be_strict = (i != @columns.size - 1) ? true : strict
26
- [where_side(col, side, be_strict), where_tie(col)].reject { |x| x == WHERE_IDENTITY }
27
- }
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
28
33
  # group pairwise with OR, and nest with AND
29
- query = foldr_terms terms.map { |pair| join_terms 'OR'.freeze, *pair }, 'AND'.freeze
34
+ query = foldr_terms terms.map { |pair| join_terms 'OR', *pair }, 'AND'
30
35
  if ::OrderQuery.wrap_top_level_or
31
36
  # wrap in a redundant AND clause for performance
32
37
  query = wrap_top_level_or query, terms, side
@@ -48,7 +53,8 @@ module OrderQuery
48
53
  # joins terms with an operator, empty terms are skipped
49
54
  # @return [query, parameters]
50
55
  def join_terms(op, *terms)
51
- [terms.map(&:first).reject(&:empty?).join(" #{op} "), terms.map(&:second).reduce([], :+)]
56
+ [terms.map(&:first).reject(&:empty?).join(" #{op} "),
57
+ terms.map(&:second).reduce([], :+)]
52
58
  end
53
59
 
54
60
  def wrap_term_with_parens(t)
@@ -66,8 +72,11 @@ module OrderQuery
66
72
  # Read more at https://github.com/glebm/order_query/issues/3
67
73
  def wrap_top_level_or(query, terms, side)
68
74
  top_term_i = terms.index(&:present?)
69
- if top_term_i && terms[top_term_i].length == 2 && !(col = @columns[top_term_i]).order_enum
70
- join_terms 'AND'.freeze, where_side(col, side, false), wrap_term_with_parens(query)
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)
71
80
  else
72
81
  query
73
82
  end
@@ -83,7 +92,8 @@ module OrderQuery
83
92
  end
84
93
 
85
94
  # @param [:before or :after] side
86
- # @return [query, params] return query fragment for column values before / after the current one
95
+ # @return [query, params] return query fragment for column values
96
+ # before / after the current one.
87
97
  def where_side(col, side, strict = true, value = point.value(col))
88
98
  if col.order_enum
89
99
  where_in col, col.enum_side(value, side, strict)
@@ -93,26 +103,49 @@ module OrderQuery
93
103
  end
94
104
 
95
105
  def where_in(col, values)
96
- case values.length
97
- when 0
98
- WHERE_IDENTITY
99
- when 1
100
- where_eq col, values[0]
101
- else
102
- ["#{col.column_name} IN (?)".freeze, [values]]
103
- end
106
+ join_terms 'OR',
107
+ (values.include?(nil) ? where_eq(col, nil) : WHERE_IDENTITY),
108
+ case (non_nil_values = values - [nil]).length
109
+ when 0
110
+ WHERE_IDENTITY
111
+ when 1
112
+ where_eq col, non_nil_values
113
+ else
114
+ ["#{col.column_name} IN (?)", [non_nil_values]]
115
+ end
104
116
  end
105
117
 
106
118
  def where_eq(col, value = point.value(col))
107
- [%Q(#{col.column_name} = ?).freeze, [value]]
119
+ if value.nil?
120
+ ["#{col.column_name} IS NULL", []]
121
+ else
122
+ ["#{col.column_name} = ?", [value]]
123
+ end
108
124
  end
109
125
 
110
- RAY_OP = {asc: '>'.freeze, desc: '<'.freeze}.freeze
126
+ RAY_OP = { asc: '>', desc: '<' }.freeze
127
+ NULLS_ORD = { first: 'IS NOT NULL', last: 'IS NULL' }.freeze
128
+
129
+ # rubocop:disable Metrics/AbcSize
130
+
111
131
  def where_ray(col, from, mode, strict = true)
112
- ["#{col.column_name} #{RAY_OP[col.direction(mode == :before)]}#{'=' unless strict} ?".freeze, [from]]
132
+ reverse = (mode == :before)
133
+ if from.nil?
134
+ ["#{col.column_name} #{NULLS_ORD[col.nulls_direction(reverse)]}", []]
135
+ else
136
+ ["#{col.column_name} " \
137
+ "#{RAY_OP[col.direction(reverse)]}#{'=' unless strict} ?",
138
+ [from]].tap do |ray|
139
+ if col.nullable? && col.nulls_direction(reverse) == :last
140
+ ray[0] += " OR #{col.column_name} IS NULL"
141
+ ray[0] = "(#{ray[0]})"
142
+ end
143
+ end
144
+ end
113
145
  end
146
+ # rubocop:enable Metrics/AbcSize
114
147
 
115
- WHERE_IDENTITY = [''.freeze, [].freeze].freeze
148
+ WHERE_IDENTITY = ['', [].freeze].freeze
116
149
 
117
150
  private
118
151
 
@@ -121,8 +154,8 @@ module OrderQuery
121
154
  # Read more about folds:
122
155
  # * http://www.haskell.org/haskellwiki/Fold
123
156
  # * http://en.wikipedia.org/wiki/Fold_(higher-order_function)
124
- def foldr_i(z, xs, &f)
125
- xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| f.call a, b, i }
157
+ def foldr_i(z, xs)
158
+ xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| yield a, b, i }
126
159
  end
127
160
  end
128
161
  end
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module OrderQuery
2
- VERSION = '0.3.4'
4
+ VERSION = '0.4.0'
3
5
  end
@@ -5,4 +5,4 @@ gemspec path: '../../'
5
5
  gem 'activerecord', '~> 5.0.5'
6
6
  gem 'activesupport', '~> 5.0.5'
7
7
 
8
- eval_gemfile './shared.gemfile'
8
+ eval_gemfile '../../shared.gemfile'
@@ -5,4 +5,4 @@ gemspec path: '../../'
5
5
  gem 'activerecord', '~> 5.1.3'
6
6
  gem 'activesupport', '~> 5.1.3'
7
7
 
8
- eval_gemfile './shared.gemfile'
8
+ eval_gemfile '../../shared.gemfile'
@@ -0,0 +1,8 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec path: '../../'
4
+
5
+ gem 'activerecord', '~> 5.2.0.rc1'
6
+ gem 'activesupport', '~> 5.2.0.rc1'
7
+
8
+ eval_gemfile '../../shared.gemfile'
@@ -1,3 +1,5 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'spec_helper'
2
4
 
3
5
  # Bare model
@@ -10,23 +12,23 @@ class Post < ActiveRecord::Base
10
12
  include OrderQuery
11
13
  order_query :order_list,
12
14
  [:pinned, [true, false]],
13
- [:published_at, :desc],
14
- [:id, :desc]
15
+ %i[published_at desc],
16
+ %i[id desc]
15
17
  end
16
18
 
17
19
  def create_post(attr = {})
18
- Post.create!({pinned: false, published_at: Time.now}.merge(attr))
20
+ Post.create!({ pinned: false, published_at: Time.now }.merge(attr))
19
21
  end
20
22
 
21
23
  # Advanced model
22
24
  class Issue < ActiveRecord::Base
23
25
  DISPLAY_ORDER = [
24
- [:pinned, [true, false]],
25
- [:priority, %w(high medium low)],
26
- [:valid_votes_count, :desc, sql: '(votes - suspicious_votes)'],
27
- [:updated_at, :desc],
28
- [:id, :desc]
29
- ]
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
30
32
 
31
33
  def valid_votes_count
32
34
  votes - suspicious_votes
@@ -34,11 +36,14 @@ class Issue < ActiveRecord::Base
34
36
 
35
37
  include OrderQuery
36
38
  order_query :display_order, DISPLAY_ORDER
37
- order_query :id_order_asc, [[:id, :asc]]
39
+ order_query :id_order_asc, [%i[id asc]]
38
40
  end
39
41
 
40
42
  def create_issue(attr = {})
41
- Issue.create!({priority: 'high', votes: 3, suspicious_votes: 0, updated_at: Time.now}.merge(attr))
43
+ Issue.create!(
44
+ { priority: 'high', votes: 3, suspicious_votes: 0, updated_at: Time.now }
45
+ .merge(attr)
46
+ )
42
47
  end
43
48
 
44
49
  def wrap_top_level_or(value)
@@ -54,53 +59,84 @@ def wrap_top_level_or(value)
54
59
  end
55
60
  end
56
61
 
57
- describe 'OrderQuery' do
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
58
84
 
59
85
  [false, true].each do |wrap_top_level_or|
60
86
  context "(wtlo: #{wrap_top_level_or})" do
61
87
  wrap_top_level_or wrap_top_level_or
62
88
 
63
89
  context 'Issue test model' do
64
- t = Time.now
65
- datasets = [
90
+ datasets = lambda {
91
+ t = Time.now
92
+ [
66
93
  [
67
- ['high', 5, 0, t, true],
68
- ['high', 5, 1, t, true],
69
- ['high', 5, 0, t],
70
- ['high', 5, 0, t - 1.day],
71
- ['high', 5, 1, t],
72
- ['medium', 10, 0, t],
73
- ['medium', 10, 5, t - 12.hours],
74
- ['low', 30, 0, t + 1.day]
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]
75
102
  ],
76
103
  [
77
- ['high', 5, 0, t],
78
- ['high', 5, 1, t],
79
- ['high', 5, 1, t - 1.day],
80
- ['low', 30, 0, t + 1.day]
104
+ ['high', 5, 0, t],
105
+ ['high', 5, 1, t],
106
+ ['high', 5, 1, t - 1.day],
107
+ ['low', 30, 0, t + 1.day]
81
108
  ],
82
109
  [
83
- ['high', 5, 1, t - 1.day],
84
- ['low', 30, 0, t + 1.day]
85
- ],
86
- ]
110
+ ['high', 5, 1, t - 1.day],
111
+ ['low', 30, 0, t + 1.day]
112
+ ]
113
+ ]
114
+ }.call
87
115
 
88
116
  datasets.each_with_index do |ds, i|
89
117
  it "is ordered correctly (test data #{i})" do
90
118
  issues = ds.map do |attr|
91
- Issue.new(priority: attr[0], votes: attr[1], suspicious_votes: attr[2], updated_at: attr[3], pinned: attr[4] || false)
119
+ Issue.new(priority: attr[0], votes: attr[1],
120
+ suspicious_votes: attr[2], updated_at: attr[3],
121
+ pinned: attr[4] || false)
92
122
  end
93
123
  issues.shuffle.reverse_each(&:save!)
94
124
  expect(Issue.display_order.to_a).to eq(issues)
95
125
  expect(Issue.display_order_reverse.to_a).to eq(issues.reverse)
96
- issues.zip(issues.rotate).each_with_index do |(cur, nxt), i|
97
- expect(cur.display_order.position).to eq(i + 1)
126
+ issues.zip(issues.rotate).each_with_index do |(cur, nxt), j|
127
+ expect(cur.display_order.position).to eq(j + 1)
98
128
  expect(cur.display_order.next).to eq(nxt)
99
129
  expect(Issue.display_order_at(cur).next).to eq nxt
100
130
  expect(cur.display_order.space.count).to eq(Issue.count)
101
- expect(cur.display_order.before.count + 1 + cur.display_order.after.count).to eq(nxt.display_order.count)
131
+ expect(
132
+ cur.display_order.before.count + 1 +
133
+ cur.display_order.after.count
134
+ ).to eq(nxt.display_order.count)
102
135
  expect(nxt.display_order.previous).to eq(cur)
103
- expect(nxt.display_order.before.to_a.reverse + [nxt] + nxt.display_order.after.to_a).to eq(Issue.display_order.to_a)
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)
104
140
  end
105
141
  end
106
142
  end
@@ -117,16 +153,20 @@ describe 'OrderQuery' do
117
153
  expect(a.id_order_asc.next).to eq b
118
154
  expect(b.id_order_asc.previous).to eq a
119
155
  expect([a] + a.id_order_asc.after.to_a).to eq(Issue.id_order_asc.to_a)
120
- expect(b.id_order_asc.before.reverse.to_a + [b]).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
+ )
121
159
  expect(Issue.id_order_asc.count).to eq(2)
122
160
  end
123
161
 
124
162
  it '.seek works on a list of ids' do
125
- ids = 3.times.map { create_issue.id }
163
+ ids = Array.new(3) { create_issue.id }
126
164
  expect(Issue.seek([[:id, ids]]).count).to eq ids.length
127
165
  expect(Issue.seek([:id, ids]).count).to eq ids.length
128
166
  expect(Issue.seek([:id, ids]).scope.pluck(:id)).to eq ids
129
- expect(Issue.seek([:id, ids]).scope_reverse.pluck(:id)).to eq ids.reverse
167
+ expect(Issue.seek([:id, ids]).scope_reverse.pluck(:id)).to(
168
+ eq(ids.reverse)
169
+ )
130
170
  end
131
171
 
132
172
  context 'partitioned on a boolean flag' do
@@ -136,7 +176,7 @@ describe 'OrderQuery' do
136
176
  create_issue(active: true)
137
177
  end
138
178
 
139
- let!(:order) { [[:id, :desc]] }
179
+ let!(:order) { [%i[id desc]] }
140
180
  let!(:active) { Issue.where(active: true).seek(order) }
141
181
  let!(:inactive) { Issue.where(active: false).seek(order) }
142
182
 
@@ -172,7 +212,31 @@ describe 'OrderQuery' do
172
212
  it '#seek falls back to scope when order column is missing self' do
173
213
  a = create_issue(priority: 'medium')
174
214
  b = create_issue(priority: 'high')
175
- expect(a.seek(Issue.display_order, [[:priority, ['wontfix', 'askbob']], [:id, :desc]]).next).to eq(b)
215
+ expect(
216
+ a.seek(
217
+ Issue.display_order,
218
+ [[:priority, %w[wontfix askbob]], %i[id desc]]
219
+ ).next
220
+ ).to eq(b)
221
+ end
222
+
223
+ context 'nil in string enum' do
224
+ priorities = [nil, 'low', 'medium', 'high']
225
+ let!(:issues) { priorities.map { |p| create_issue(priority: p) } }
226
+ priorities.permutation do |p|
227
+ it "works for #{p} (desc)" do
228
+ scope = Issue.seek([:priority, p]).scope
229
+ actual = scope.all.map(&:priority)
230
+ expected = p
231
+ expect(actual).to eq(expected), scope.to_sql
232
+ end
233
+ it "works for #{p} (asc)" do
234
+ scope = Issue.seek([:priority, p, :asc]).scope
235
+ actual = scope.all.map(&:priority)
236
+ expected = p.reverse
237
+ expect(actual).to eq(expected), scope.to_sql
238
+ end
239
+ end
176
240
  end
177
241
 
178
242
  before do
@@ -217,28 +281,33 @@ describe 'OrderQuery' do
217
281
 
218
282
  context '#inspect' do
219
283
  it 'Column' do
220
- expect(OrderQuery::Column.new([:id, :desc], Post).inspect).to eq '(id unique desc)'
221
- expect(OrderQuery::Column.new([:virtual, :desc, sql: 'SIN(id)'], Post).inspect).to eq '(virtual SIN(id) desc)'
284
+ expect(OrderQuery::Column.new(Post, :id, :desc).inspect)
285
+ .to eq '(id unique desc)'
286
+ expect(
287
+ OrderQuery::Column.new(Post, :virtual, :desc, sql: 'SIN(id)')
288
+ .inspect
289
+ ).to eq '(virtual SIN(id) desc)'
222
290
  end
223
291
 
224
- let(:space) {
292
+ let(:space) do
225
293
  OrderQuery::Space.new(Post, [[:pinned, [true, false]]])
226
- }
294
+ end
227
295
 
228
296
  it 'Point' do
229
297
  post = create_post
230
298
  point = OrderQuery::Point.new(post, space)
231
- expect(point.inspect).to(
232
- eq %Q(#<OrderQuery::Point @record=#<Post id: #{post.id}, pinned: false, published_at: #{post.attribute_for_inspect(:published_at)}> @space=#<OrderQuery::Space @columns=[(pinned [true, false] desc), (id unique asc)] @base_scope=Post(id: integer, pinned: boolean, published_at: datetime)>>)
233
- )
299
+ # rubocop:disable Metrics/LineLength
300
+ expect(point.inspect).to eq %(#<OrderQuery::Point @record=#<Post id: #{post.id}, title: nil, pinned: false, published_at: #{post.attribute_for_inspect(:published_at)}> @space=#<OrderQuery::Space @columns=[(pinned [true, false] desc), (id unique asc)] @base_scope=Post(id: integer, title: string, pinned: boolean, published_at: datetime)>>)
301
+ # rubocop:enable Metrics/LineLength
234
302
  end
235
303
 
236
304
  it 'Space' do
237
- expect(space.inspect).to eq '#<OrderQuery::Space @columns=[(pinned [true, false] desc), (id unique asc)] @base_scope=Post(id: integer, pinned: boolean, published_at: datetime)>'
305
+ # rubocop:disable Metrics/LineLength
306
+ expect(space.inspect).to eq '#<OrderQuery::Space @columns=[(pinned [true, false] desc), (id unique asc)] @base_scope=Post(id: integer, title: string, pinned: boolean, published_at: datetime)>'
307
+ # rubocop:enable Metrics/LineLength
238
308
  end
239
309
  end
240
310
 
241
-
242
311
  context 'boolean enum order' do
243
312
  before do
244
313
  create_post pinned: true
@@ -248,55 +317,168 @@ describe 'OrderQuery' do
248
317
  Post.delete_all
249
318
  end
250
319
  it 'ORDER BY is collapsed' do
251
- expect(Post.seek([:pinned, [true, false]]).scope.to_sql).to include('ORDER BY "posts"."pinned" DESC')
320
+ expect(Post.seek([:pinned, [true, false]]).scope.to_sql).to(
321
+ match(/ORDER BY .posts.\..pinned. DESC/)
322
+ )
252
323
  end
253
324
  it 'enum asc' do
254
- expect(Post.seek([:pinned, [false, true], :asc]).scope.pluck(:pinned)).to eq([true, false])
255
- expect(Post.seek([:pinned, [true, false], :asc]).scope.pluck(:pinned)).to eq([false, true])
325
+ expect(
326
+ Post.seek([:pinned, [false, true], :asc]).scope.pluck(:pinned)
327
+ ).to eq([true, false])
328
+ expect(
329
+ Post.seek([:pinned, [true, false], :asc]).scope.pluck(:pinned)
330
+ ).to eq([false, true])
256
331
  end
257
332
  it 'enum desc' do
258
- expect(Post.seek([:pinned, [false, true], :desc]).scope.pluck(:pinned)).to eq([false, true])
259
- expect(Post.seek([:pinned, [true, false], :desc]).scope.pluck(:pinned)).to eq([true, false])
333
+ expect(
334
+ Post.seek([:pinned, [false, true], :desc]).scope.pluck(:pinned)
335
+ ).to eq([false, true])
336
+ expect(
337
+ Post.seek([:pinned, [true, false], :desc]).scope.pluck(:pinned)
338
+ ).to eq([true, false])
260
339
  end
261
340
  end
262
341
 
263
- xcontext 'nil in enum' do
342
+ context 'nil in boolean enum' do
264
343
  states = [nil, false, true]
265
344
  let!(:posts) { states.map { |state| create_post(pinned: state) } }
266
345
  states.permutation do |p|
267
- # There is no cross-DB SQL that can be generated to position nil results
268
- # http://use-the-index-luke.com/sql/sorting-grouping/order-by-asc-desc-nulls-last
269
- next unless p.first.nil? || p.last.nil?
270
- # Positioning NULLs first or last can be achieved, but remains on the ToDo / contributions welcome list
271
- it "nil in enum works for #{p}" do
272
- expect(Post.seek([:pinned, p]).scope.all.map(&:pinned)).to eq(p)
273
- expect(Post.seek([:pinned, p, :asc]).scope.all.map(&:pinned)).to eq(p.reverse)
346
+ it "works for #{p} (desc)" do
347
+ scope = Post.seek([:pinned, p]).scope
348
+ actual = scope.all.map(&:pinned)
349
+ expected = p
350
+ expect(actual).to eq(expected), scope.to_sql
351
+ end
352
+ it "works for #{p} (asc)" do
353
+ scope = Post.seek([:pinned, p, :asc]).scope
354
+ actual = scope.all.map(&:pinned)
355
+ expected = p.reverse
356
+ expect(actual).to eq(expected), scope.to_sql
274
357
  end
275
358
  end
276
359
  end
277
360
 
361
+ context 'nil published_at' do
362
+ # rubocop:disable Metrics/AbcSize
363
+
364
+ def expect_next(space, post, next_post)
365
+ point = space.at(post)
366
+ actual = point.next
367
+ failure_message =
368
+ "expected: #{post.title}.next == #{next_post.title}\n" \
369
+ " got: #{actual ? actual.title : 'nil'}\n" \
370
+ " all: #{space.scope.all.map(&:title)}\n" \
371
+ " sql: #{space.at(older).before.limit(1).to_sql}"
372
+ expect(actual ? actual.title : nil).to eq(next_post.title),
373
+ failure_message
374
+ end
375
+
376
+ def expect_prev(space, post, prev_post)
377
+ point = space.at(post)
378
+ actual = point.previous
379
+ failure_message =
380
+ "expected: #{post.title}.previous == #{prev_post.title}\n" \
381
+ " got: #{actual ? actual.title : 'nil'}\n" \
382
+ " all: #{space.scope.all.map(&:title)}\n" \
383
+ " sql: #{space.at(older).before.limit(1).to_sql}"
384
+ expect(actual ? actual.title : nil).to eq(prev_post.title),
385
+ failure_message
386
+ end
387
+ # rubocop:enable Metrics/AbcSize
388
+
389
+ let! :null do
390
+ Post.create!(title: 'null', published_at: nil).reload
391
+ end
392
+ let! :older do
393
+ Post.create!(title: 'older', published_at: Time.now + 1.hour)
394
+ end
395
+ let! :newer do
396
+ Post.create!(title: 'newer', published_at: Time.now - 1.hour)
397
+ end
398
+
399
+ it 'orders nulls first (desc)' do
400
+ space = Post.seek([:published_at, :desc, nulls: :first])
401
+ scope = space.scope
402
+ actual = scope.all.map(&:title)
403
+ expected = [null, older, newer].map(&:title)
404
+ expect(actual).to eq(expected), scope.to_sql
405
+ expect_next space, older, newer
406
+ expect_prev space, newer, older
407
+ expect_prev space, older, null
408
+ expect_next space, null, older
409
+ end
410
+
411
+ it 'orders nulls first (asc)' do
412
+ space = Post.seek([:published_at, :asc, nulls: :first])
413
+ scope = space.scope
414
+ actual = scope.all.map(&:title)
415
+ expected = [null, newer, older].map(&:title)
416
+ expect(actual).to eq(expected), scope.to_sql
417
+ expect_prev space, newer, null
418
+ expect_next space, null, newer
419
+ end
420
+
421
+ it 'orders nulls last (desc)' do
422
+ space = Post.seek([:published_at, :desc, nulls: :last])
423
+ scope = space.scope
424
+ actual = scope.all.map(&:title)
425
+ expected = [older, newer, null].map(&:title)
426
+ expect(actual).to eq(expected), scope.to_sql
427
+ expect_next space, newer, null
428
+ expect_prev space, null, newer
429
+ end
430
+
431
+ it 'orders nulls last (asc)' do
432
+ space = Post.seek([:published_at, :asc, nulls: :last])
433
+ scope = space.scope
434
+ actual = scope.all.map(&:title)
435
+ expected = [newer, older, null].map(&:title)
436
+ expect(actual).to eq(expected), scope.to_sql
437
+ expect_next space, older, null
438
+ expect_prev space, null, older
439
+ end
440
+ end
441
+
278
442
  context 'after/before no strict' do
279
443
  context 'by middle attribute in search order' do
280
- let!(:base) { Post.create! pinned: true, published_at: Time.now }
281
- let!(:older) { Post.create! pinned: true, published_at: Time.now + 1.hour }
282
- let!(:younger) { Post.create! pinned: true, published_at: Time.now - 1.hour }
444
+ let! :base do
445
+ Post.create! pinned: true, published_at: Time.now
446
+ end
447
+ let! :older do
448
+ Post.create! pinned: true, published_at: Time.now + 1.hour
449
+ end
450
+ let! :newer do
451
+ Post.create! pinned: true, published_at: Time.now - 1.hour
452
+ end
283
453
 
284
454
  it 'includes first element' do
285
455
  point = Post.order_list_at(base)
286
456
 
287
457
  expect(point.after.count).to eq 1
288
- expect(point.after.to_a).to eq [younger]
458
+ expect(point.after.to_a).to eq [newer]
289
459
 
290
460
  expect(point.after(false).count).to eq 2
291
- expect(point.after(false).to_a).to eq [base, younger]
461
+ expect(point.after(false).to_a).to eq [base, newer]
292
462
  expect(point.before(false).to_a).to eq [base, older]
293
463
  end
294
464
  end
295
465
 
296
466
  context 'by last attribute in search order' do
297
- let!(:base) { Post.create! pinned: true, published_at: Time.new(2016, 5, 1, 5, 4, 3), id: 6 }
298
- let!(:previous) { Post.create! pinned: true, published_at: Time.new(2016, 5, 1, 5, 4, 3), id: 4 }
299
- let!(:next_one) { Post.create! pinned: true, published_at: Time.new(2016, 5, 1, 5, 4, 3), id: 9 }
467
+ let!(:base) do
468
+ Post.create! pinned: true,
469
+ published_at: Time.new(2016, 5, 1, 5, 4, 3),
470
+ id: 6
471
+ end
472
+ let!(:previous) do
473
+ Post.create! pinned: true,
474
+ published_at: Time.new(2016, 5, 1, 5, 4, 3),
475
+ id: 4
476
+ end
477
+ let!(:next_one) do
478
+ Post.create! pinned: true,
479
+ published_at: Time.new(2016, 5, 1, 5, 4, 3),
480
+ id: 9
481
+ end
300
482
 
301
483
  it 'includes first element' do
302
484
  point = Post.order_list_at(base)
@@ -318,10 +500,12 @@ describe 'OrderQuery' do
318
500
  ActiveRecord::Schema.define do
319
501
  self.verbose = false
320
502
  create_table :posts do |t|
503
+ t.string :title
321
504
  t.boolean :pinned
322
505
  t.datetime :published_at
323
506
  end
324
507
  end
508
+ Post.reset_column_information
325
509
  end
326
510
  after :all do
327
511
  ActiveRecord::Migration.drop_table :posts
@@ -334,7 +518,8 @@ describe 'OrderQuery' do
334
518
  context 'wrap top-level OR on' do
335
519
  wrap_top_level_or true
336
520
  it 'wraps top-level OR' do
337
- after_scope = User.create!(updated_at: Date.parse('2014-09-06')).seek([[:updated_at, :desc], [:id, :desc]]).after
521
+ after_scope = User.create!(updated_at: Date.parse('2014-09-06'))
522
+ .seek([%i[updated_at desc], %i[id desc]]).after
338
523
  expect(after_scope.to_sql).to include('<=')
339
524
  end
340
525
  end
@@ -342,7 +527,8 @@ describe 'OrderQuery' do
342
527
  context 'wrap top-level OR off' do
343
528
  wrap_top_level_or false
344
529
  it 'does not wrap top-level OR' do
345
- after_scope = User.create!(updated_at: Date.parse('2014-09-06')).seek([[:updated_at, :desc], [:id, :desc]]).after
530
+ after_scope = User.create!(updated_at: Date.parse('2014-09-06'))
531
+ .seek([%i[updated_at desc], %i[id desc]]).after
346
532
  expect(after_scope.to_sql).to_not include('<=')
347
533
  end
348
534
  end
@@ -358,6 +544,7 @@ describe 'OrderQuery' do
358
544
  t.datetime :updated_at, null: false
359
545
  end
360
546
  end
547
+ User.reset_column_information
361
548
  end
362
549
 
363
550
  after :all do