order_query 0.3.3 → 0.5.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +5 -5
- data/CHANGES.md +34 -0
- data/Gemfile +4 -3
- data/MIT-LICENSE +3 -1
- data/README.md +25 -13
- data/Rakefile +26 -16
- data/lib/order_query.rb +12 -3
- data/lib/order_query/column.rb +74 -25
- data/lib/order_query/direction.rb +9 -7
- data/lib/order_query/errors.rb +11 -0
- data/lib/order_query/nulls_direction.rb +53 -0
- data/lib/order_query/point.rb +26 -9
- data/lib/order_query/space.rb +18 -8
- data/lib/order_query/sql/column.rb +9 -5
- data/lib/order_query/sql/order_by.rb +85 -11
- data/lib/order_query/sql/where.rb +63 -28
- data/lib/order_query/version.rb +3 -1
- data/spec/gemfiles/rails_5_0.gemfile +21 -0
- data/spec/gemfiles/rails_5_1.gemfile +21 -0
- data/spec/gemfiles/rails_5_2.gemfile +21 -0
- data/spec/gemfiles/rails_6_0.gemfile +21 -0
- data/spec/gemfiles/rails_6_1.gemfile +21 -0
- data/spec/gemfiles/rubocop.gemfile +5 -0
- data/spec/order_query_spec.rb +260 -67
- data/spec/spec_helper.rb +32 -6
- data/spec/support/order_expectation.rb +48 -0
- metadata +48 -27
- data/spec/gemfiles/rails_4.gemfile +0 -9
- data/spec/gemfiles/rails_4.gemfile.lock +0 -73
- data/spec/gemfiles/rails_5.gemfile +0 -8
- data/spec/gemfiles/rails_5.gemfile.lock +0 -76
@@ -0,0 +1,53 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module OrderQuery
|
4
|
+
# Handles nulls :first and :last direction.
|
5
|
+
module NullsDirection
|
6
|
+
module_function
|
7
|
+
|
8
|
+
DIRECTIONS = %i[first last].freeze
|
9
|
+
|
10
|
+
def all
|
11
|
+
DIRECTIONS
|
12
|
+
end
|
13
|
+
|
14
|
+
# @param [:first, :last] direction
|
15
|
+
# @return [:first, :last]
|
16
|
+
def reverse(direction)
|
17
|
+
all[(all.index(direction) + 1) % 2].to_sym
|
18
|
+
end
|
19
|
+
|
20
|
+
# @param [:first, :last] direction
|
21
|
+
# @raise [ArgumentError]
|
22
|
+
# @return [:first, :last]
|
23
|
+
def parse!(direction)
|
24
|
+
all.include?(direction) && direction or
|
25
|
+
fail ArgumentError,
|
26
|
+
"`nulls` must be in #{all.map(&:inspect).join(', ')}, "\
|
27
|
+
"is #{direction.inspect}"
|
28
|
+
end
|
29
|
+
|
30
|
+
# @param scope [ActiveRecord::Relation]
|
31
|
+
# @param dir [:asc, :desc]
|
32
|
+
# @return [:first, :last] the default nulls order, based on the given
|
33
|
+
# scope's connection adapter name.
|
34
|
+
def default(scope, dir)
|
35
|
+
case connection_adapter(scope)
|
36
|
+
when /mysql|maria|sqlite|sqlserver/i
|
37
|
+
(dir == :asc ? :first : :last)
|
38
|
+
else
|
39
|
+
# Oracle, Postgres
|
40
|
+
(dir == :asc ? :last : :first)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
def connection_adapter(scope)
|
45
|
+
if scope.respond_to?(:connection_db_config)
|
46
|
+
# Rails >= 6.1.0
|
47
|
+
scope.connection_db_config.adapter
|
48
|
+
else
|
49
|
+
scope.connection_config[:adapter]
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|
data/lib/order_query/point.rb
CHANGED
@@ -1,5 +1,8 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
1
3
|
require 'order_query/space'
|
2
4
|
require 'order_query/sql/where'
|
5
|
+
require 'order_query/errors'
|
3
6
|
|
4
7
|
module OrderQuery
|
5
8
|
# Search around a record in an order space
|
@@ -15,7 +18,8 @@ module OrderQuery
|
|
15
18
|
@where_sql = SQL::Where.new(self)
|
16
19
|
end
|
17
20
|
|
18
|
-
# @
|
21
|
+
# @param [true, false] loop if true, loops as if the last and the first
|
22
|
+
# records were adjacent, unless there is only one record.
|
19
23
|
# @return [ActiveRecord::Base]
|
20
24
|
def next(loop = true)
|
21
25
|
unless_record_eq after.first || (first if loop)
|
@@ -31,20 +35,26 @@ module OrderQuery
|
|
31
35
|
space.count - after.count
|
32
36
|
end
|
33
37
|
|
38
|
+
# @param [true, false] strict if false, the given scope will include the
|
39
|
+
# record at this point.
|
34
40
|
# @return [ActiveRecord::Relation]
|
35
|
-
def after
|
36
|
-
side :after
|
41
|
+
def after(strict = true)
|
42
|
+
side :after, strict
|
37
43
|
end
|
38
44
|
|
45
|
+
# @param [true, false] strict if false, the given scope will include the
|
46
|
+
# record at this point.
|
39
47
|
# @return [ActiveRecord::Relation]
|
40
|
-
def before
|
41
|
-
side :before
|
48
|
+
def before(strict = true)
|
49
|
+
side :before, strict
|
42
50
|
end
|
43
51
|
|
44
52
|
# @param [:before, :after] side
|
53
|
+
# @param [true, false] strict if false, the given scope will include the
|
54
|
+
# record at this point.
|
45
55
|
# @return [ActiveRecord::Relation]
|
46
|
-
def side(side)
|
47
|
-
query, query_args = @where_sql.build(side)
|
56
|
+
def side(side, strict = true)
|
57
|
+
query, query_args = @where_sql.build(side, strict)
|
48
58
|
scope = if side == :after
|
49
59
|
space.scope
|
50
60
|
else
|
@@ -53,8 +63,15 @@ module OrderQuery
|
|
53
63
|
scope.where(query, *query_args)
|
54
64
|
end
|
55
65
|
|
56
|
-
|
57
|
-
|
66
|
+
# @param column [Column]
|
67
|
+
def value(column)
|
68
|
+
v = record.send(column.name)
|
69
|
+
if v.nil? && !column.nullable?
|
70
|
+
fail Errors::NonNullableColumnIsNullError,
|
71
|
+
"Column #{column.inspect} is NULL on record #{@record.inspect}. "\
|
72
|
+
'Set the `nulls` option to :first or :last.'
|
73
|
+
end
|
74
|
+
v
|
58
75
|
end
|
59
76
|
|
60
77
|
def inspect
|
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,20 @@ 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
|
+
|
26
|
+
@columns << Column.new(base_scope, base_scope.primary_key)
|
19
27
|
end
|
20
28
|
@order_by_sql = SQL::OrderBy.new(@columns)
|
21
29
|
end
|
@@ -27,12 +35,13 @@ module OrderQuery
|
|
27
35
|
|
28
36
|
# @return [ActiveRecord::Relation] scope ordered by columns
|
29
37
|
def scope
|
30
|
-
@scope ||= @base_scope.order(@order_by_sql.build)
|
38
|
+
@scope ||= @base_scope.order(Arel.sql(@order_by_sql.build))
|
31
39
|
end
|
32
40
|
|
33
41
|
# @return [ActiveRecord::Relation] scope ordered by columns in reverse
|
34
42
|
def scope_reverse
|
35
|
-
@scope_reverse ||= @base_scope
|
43
|
+
@scope_reverse ||= @base_scope
|
44
|
+
.order(Arel.sql(@order_by_sql.build_reverse))
|
36
45
|
end
|
37
46
|
|
38
47
|
# @return [ActiveRecord::Base]
|
@@ -46,7 +55,8 @@ module OrderQuery
|
|
46
55
|
end
|
47
56
|
|
48
57
|
def inspect
|
49
|
-
"#<OrderQuery::Space @columns=#{@columns.inspect}
|
58
|
+
"#<OrderQuery::Space @columns=#{@columns.inspect} "\
|
59
|
+
"@base_scope=#{@base_scope.inspect}>"
|
50
60
|
end
|
51
61
|
end
|
52
62
|
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,65 @@ 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)
|
53
|
+
end
|
54
|
+
|
55
|
+
clauses = []
|
56
|
+
with_nulls = false
|
57
|
+
if col.order_enum.include?(nil)
|
58
|
+
with_nulls = true
|
59
|
+
elsif needs_null_sort?(col, reverse)
|
60
|
+
clauses << order_by_nulls_sql(col, reverse)
|
61
|
+
end
|
62
|
+
clauses.concat(col.order_enum.map do |v|
|
63
|
+
"#{order_by_value_sql col, v, with_nulls} " \
|
64
|
+
"#{sort_direction_sql(col, reverse)}"
|
65
|
+
end)
|
66
|
+
clauses.join(', ').freeze
|
67
|
+
end
|
68
|
+
# rubocop:enable Metrics/AbcSize
|
69
|
+
|
70
|
+
def needs_null_sort?(col, reverse,
|
71
|
+
nulls_direction = col.nulls_direction(reverse))
|
72
|
+
return false unless col.nullable?
|
73
|
+
|
74
|
+
nulls_direction != col.default_nulls_direction(reverse)
|
75
|
+
end
|
76
|
+
|
77
|
+
def order_by_nulls_sql(col, reverse)
|
78
|
+
if col.default_nulls_direction !=
|
79
|
+
(col.direction == :asc ? :first : :last)
|
80
|
+
reverse = !reverse
|
43
81
|
end
|
44
|
-
|
45
|
-
"#{order_by_value_sql col, v} #{sort_direction_sql(col, reverse)}"
|
46
|
-
}.join(', ').freeze
|
82
|
+
"#{col.column_name} IS NULL #{sort_direction_sql(col, reverse)}"
|
47
83
|
end
|
48
84
|
|
49
|
-
def order_by_value_sql(col, v)
|
50
|
-
|
85
|
+
def order_by_value_sql(col, v, with_nulls = false)
|
86
|
+
if with_nulls
|
87
|
+
if v.nil?
|
88
|
+
"#{col.column_name} IS NULL"
|
89
|
+
else
|
90
|
+
"#{col.column_name} IS NOT NULL AND " \
|
91
|
+
"#{col.column_name}=#{col.quote v}"
|
92
|
+
end
|
93
|
+
else
|
94
|
+
"#{col.column_name}=#{col.quote v}"
|
95
|
+
end
|
51
96
|
end
|
52
97
|
|
53
98
|
# @return [String]
|
@@ -59,6 +104,35 @@ module OrderQuery
|
|
59
104
|
def join_order_by_clauses(clauses)
|
60
105
|
clauses.join(', ').freeze
|
61
106
|
end
|
107
|
+
|
108
|
+
private
|
109
|
+
|
110
|
+
def optimize_enum_bools?(col)
|
111
|
+
col.order_enum == [false, true] || col.order_enum == [true, false]
|
112
|
+
end
|
113
|
+
|
114
|
+
def optimize_enum_bools(col, reverse)
|
115
|
+
column_clause_ray(col, col.order_enum[-1] ^ reverse)
|
116
|
+
end
|
117
|
+
|
118
|
+
ENUM_SET_TRUE_FALSE_NIL = Set[false, true, nil]
|
119
|
+
|
120
|
+
def optimize_enum_bools_nil?(col)
|
121
|
+
Set.new(col.order_enum) == ENUM_SET_TRUE_FALSE_NIL &&
|
122
|
+
!col.order_enum[1].nil?
|
123
|
+
end
|
124
|
+
|
125
|
+
def optimize_enum_bools_nil(col, reverse)
|
126
|
+
last_bool_true = if col.order_enum[-1].nil?
|
127
|
+
col.order_enum[-2]
|
128
|
+
else
|
129
|
+
col.order_enum[-1]
|
130
|
+
end
|
131
|
+
reverse_override = last_bool_true ^ reverse
|
132
|
+
with_nulls_sort =
|
133
|
+
needs_null_sort?(col, reverse_override, col.nulls_direction(reverse))
|
134
|
+
column_clause_ray(col, reverse_override, with_nulls_sort)
|
135
|
+
end
|
62
136
|
end
|
63
137
|
end
|
64
138
|
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,19 +14,24 @@ 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
|
20
23
|
# invoices = 3 AND (
|
21
24
|
# ... ))
|
22
|
-
def build(side)
|
25
|
+
def build(side, strict = true)
|
23
26
|
# generate pairs of terms such as sales < 5, sales = 5
|
24
|
-
terms = @columns.map
|
25
|
-
|
26
|
-
|
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
|
27
33
|
# group pairwise with OR, and nest with AND
|
28
|
-
query = foldr_terms terms.map { |pair| join_terms 'OR'
|
34
|
+
query = foldr_terms terms.map { |pair| join_terms 'OR', *pair }, 'AND'
|
29
35
|
if ::OrderQuery.wrap_top_level_or
|
30
36
|
# wrap in a redundant AND clause for performance
|
31
37
|
query = wrap_top_level_or query, terms, side
|
@@ -47,7 +53,8 @@ module OrderQuery
|
|
47
53
|
# joins terms with an operator, empty terms are skipped
|
48
54
|
# @return [query, parameters]
|
49
55
|
def join_terms(op, *terms)
|
50
|
-
[terms.map(&:first).reject(&:empty?).join(" #{op} "),
|
56
|
+
[terms.map(&:first).reject(&:empty?).join(" #{op} "),
|
57
|
+
terms.map(&:second).reduce([], :+)]
|
51
58
|
end
|
52
59
|
|
53
60
|
def wrap_term_with_parens(t)
|
@@ -65,8 +72,11 @@ module OrderQuery
|
|
65
72
|
# Read more at https://github.com/glebm/order_query/issues/3
|
66
73
|
def wrap_top_level_or(query, terms, side)
|
67
74
|
top_term_i = terms.index(&:present?)
|
68
|
-
if top_term_i && terms[top_term_i].length == 2 &&
|
69
|
-
|
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)
|
70
80
|
else
|
71
81
|
query
|
72
82
|
end
|
@@ -82,36 +92,61 @@ module OrderQuery
|
|
82
92
|
end
|
83
93
|
|
84
94
|
# @param [:before or :after] side
|
85
|
-
# @return [query, params] return query fragment for column values
|
86
|
-
|
95
|
+
# @return [query, params] return query fragment for column values
|
96
|
+
# before / after the current one.
|
97
|
+
def where_side(col, side, strict, value = point.value(col))
|
87
98
|
if col.order_enum
|
88
99
|
where_in col, col.enum_side(value, side, strict)
|
100
|
+
elsif value.nil?
|
101
|
+
where_null col, side, strict
|
89
102
|
else
|
90
103
|
where_ray col, value, side, strict
|
91
104
|
end
|
92
105
|
end
|
93
106
|
|
94
107
|
def where_in(col, values)
|
95
|
-
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
108
|
+
join_terms 'OR',
|
109
|
+
(values.include?(nil) ? where_eq(col, nil) : WHERE_IDENTITY),
|
110
|
+
case (non_nil_values = values - [nil]).length
|
111
|
+
when 0
|
112
|
+
WHERE_IDENTITY
|
113
|
+
when 1
|
114
|
+
where_eq col, non_nil_values
|
115
|
+
else
|
116
|
+
["#{col.column_name} IN (?)", [non_nil_values]]
|
117
|
+
end
|
103
118
|
end
|
104
119
|
|
105
120
|
def where_eq(col, value = point.value(col))
|
106
|
-
|
121
|
+
if value.nil?
|
122
|
+
["#{col.column_name} IS NULL", []]
|
123
|
+
else
|
124
|
+
["#{col.column_name} = ?", [value]]
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
RAY_OP = { asc: '>', desc: '<' }.freeze
|
129
|
+
NULLS_ORD = { first: 'IS NOT NULL', last: 'IS NULL' }.freeze
|
130
|
+
|
131
|
+
def where_null(col, mode, strict)
|
132
|
+
if strict && col.nulls_direction(mode == :before) != :last
|
133
|
+
["#{col.column_name} IS NOT NULL", []]
|
134
|
+
else
|
135
|
+
WHERE_IDENTITY
|
136
|
+
end
|
107
137
|
end
|
108
138
|
|
109
|
-
|
110
|
-
|
111
|
-
|
139
|
+
def where_ray(col, from, mode, strict)
|
140
|
+
["#{col.column_name} "\
|
141
|
+
"#{RAY_OP[col.direction(mode == :before)]}#{'=' unless strict} ?",
|
142
|
+
[from]].tap do |ray|
|
143
|
+
if col.nullable? && col.nulls_direction(mode == :before) == :last
|
144
|
+
ray[0] = "(#{ray[0]} OR #{col.column_name} IS NULL)"
|
145
|
+
end
|
146
|
+
end
|
112
147
|
end
|
113
148
|
|
114
|
-
WHERE_IDENTITY = [''
|
149
|
+
WHERE_IDENTITY = ['', [].freeze].freeze
|
115
150
|
|
116
151
|
private
|
117
152
|
|
@@ -120,8 +155,8 @@ module OrderQuery
|
|
120
155
|
# Read more about folds:
|
121
156
|
# * http://www.haskell.org/haskellwiki/Fold
|
122
157
|
# * http://en.wikipedia.org/wiki/Fold_(higher-order_function)
|
123
|
-
def foldr_i(z, xs
|
124
|
-
xs.reverse_each.each_with_index.inject(z) { |b, (a, i)|
|
158
|
+
def foldr_i(z, xs)
|
159
|
+
xs.reverse_each.each_with_index.inject(z) { |b, (a, i)| yield a, b, i }
|
125
160
|
end
|
126
161
|
end
|
127
162
|
end
|