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 +4 -4
- data/CHANGES.md +6 -0
- data/Gemfile +7 -0
- data/README.md +21 -14
- data/Rakefile +15 -6
- data/lib/order_query.rb +12 -3
- data/lib/order_query/column.rb +72 -24
- data/lib/order_query/direction.rb +9 -7
- data/lib/order_query/errors.rb +11 -0
- data/lib/order_query/nulls_direction.rb +44 -0
- data/lib/order_query/point.rb +20 -6
- data/lib/order_query/space.rb +17 -8
- data/lib/order_query/sql/column.rb +9 -5
- data/lib/order_query/sql/order_by.rb +83 -11
- data/lib/order_query/sql/where.rb +59 -26
- data/lib/order_query/version.rb +3 -1
- data/spec/gemfiles/rails_5_0.gemfile +1 -1
- data/spec/gemfiles/rails_5_1.gemfile +1 -1
- data/spec/gemfiles/rails_5_2.gemfile +8 -0
- data/spec/order_query_spec.rb +259 -72
- data/spec/spec_helper.rb +24 -1
- metadata +30 -14
- data/spec/gemfiles/rails_4_2.gemfile +0 -9
data/lib/order_query/space.rb
CHANGED
@@ -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
|
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
|
14
|
-
@columns
|
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
|
-
|
18
|
-
|
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
|
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}
|
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 :
|
7
|
+
attr_reader :scope, :column
|
5
8
|
|
6
|
-
def initialize(
|
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.
|
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)
|
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
|
-
|
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
|
40
|
-
|
41
|
-
if
|
42
|
-
return
|
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
|
-
|
45
|
-
|
46
|
-
|
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
|
50
|
-
|
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
|
-
#
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
2
3
|
module OrderQuery
|
3
4
|
module SQL
|
4
|
-
#
|
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
|
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
|
25
|
-
be_strict =
|
26
|
-
[where_side(col, side, be_strict), where_tie(col)].reject
|
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'
|
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} "),
|
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 &&
|
70
|
-
|
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
|
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
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
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
|
-
|
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: '>'
|
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
|
-
|
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 = [''
|
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
|
125
|
-
xs.reverse_each.each_with_index.inject(z) { |b, (a, 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
|
data/lib/order_query/version.rb
CHANGED
data/spec/order_query_spec.rb
CHANGED
@@ -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
|
-
[
|
14
|
-
[
|
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
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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, [[
|
39
|
+
order_query :id_order_asc, [%i[id asc]]
|
38
40
|
end
|
39
41
|
|
40
42
|
def create_issue(attr = {})
|
41
|
-
Issue.create!(
|
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
|
-
|
65
|
-
|
90
|
+
datasets = lambda {
|
91
|
+
t = Time.now
|
92
|
+
[
|
66
93
|
[
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
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
|
-
|
78
|
-
|
79
|
-
|
80
|
-
|
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
|
-
|
84
|
-
|
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],
|
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),
|
97
|
-
expect(cur.display_order.position).to eq(
|
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(
|
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(
|
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
|
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
|
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
|
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) { [[
|
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(
|
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(
|
221
|
-
|
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
|
-
|
232
|
-
|
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
|
-
|
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
|
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(
|
255
|
-
|
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(
|
259
|
-
|
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
|
-
|
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
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
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!
|
281
|
-
|
282
|
-
|
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 [
|
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,
|
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)
|
298
|
-
|
299
|
-
|
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'))
|
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'))
|
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
|