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