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.
@@ -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