order_query 0.3.3 → 0.5.1
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 +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
|