order_query 0.1.3 → 0.2.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 +4 -0
- data/README.md +8 -8
- data/lib/order_query.rb +23 -11
- data/lib/order_query/condition.rb +65 -0
- data/lib/order_query/{relative_order.rb → point.rb} +13 -14
- data/lib/order_query/space.rb +30 -0
- data/lib/order_query/sql/condition.rb +34 -0
- data/lib/order_query/sql/order_by.rb +60 -0
- data/lib/order_query/sql/where.rb +120 -0
- data/lib/order_query/version.rb +1 -1
- data/spec/order_query_spec.rb +55 -15
- metadata +8 -6
- data/lib/order_query/order_condition.rb +0 -71
- data/lib/order_query/order_space.rb +0 -74
- data/lib/order_query/where_builder.rb +0 -124
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 14a1cc9219fd6bb2221967eef338dcabee8854f1
|
4
|
+
data.tar.gz: e4a8c908c803e2c8afad23a0b278473043087cc7
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: e93eb1ec39fab70c1895d34aeef9bbfab92c8da4b7c2962c7a822f4e0df5501331c4a5c733a471fdcfb61b287d4e9cebfd8ea668b258d957755e942011384ee1
|
7
|
+
data.tar.gz: 4ec32294d12907c1245607f2f2f79a9f205e9f7c7d7396f4b7a1bd1877fb597262cf29e7aa53d81aad512efc36a9737dbf68601f9828f69b7b16adaa77dc77eb
|
data/CHANGES.md
CHANGED
data/README.md
CHANGED
@@ -16,7 +16,7 @@ This is slow. Here is where `order_query` comes in!
|
|
16
16
|
Add to Gemfile:
|
17
17
|
|
18
18
|
```ruby
|
19
|
-
gem 'order_query', '~> 0.
|
19
|
+
gem 'order_query', '~> 0.2.0'
|
20
20
|
```
|
21
21
|
|
22
22
|
## Usage
|
@@ -94,20 +94,20 @@ end
|
|
94
94
|
|
95
95
|
### Dynamic order conditions
|
96
96
|
|
97
|
-
To query with dynamic order conditions use `Model.
|
97
|
+
To query with dynamic order conditions use `Model.order_by` and `Model#order_by`:
|
98
98
|
|
99
99
|
```ruby
|
100
|
-
Issue.
|
101
|
-
Issue.
|
102
|
-
Issue.find(31).
|
103
|
-
Issue.find(31).
|
100
|
+
Issue.order_by([[:id, :desc]]) #=> ActiveRecord::Relation<...>
|
101
|
+
Issue.visible.reverse_order_by([[:id, :desc]]) #=> ActiveRecord::Relation<...>
|
102
|
+
Issue.find(31).order_by([[:id, :desc]]).next #=> Issue<...>
|
103
|
+
Issue.find(31).order_by(Issue.visible, [[:id, :desc]]).next #=> Issue<...>
|
104
104
|
```
|
105
105
|
|
106
|
-
For example, consider ordering by a list of ids returned from an
|
106
|
+
For example, consider ordering by a list of ids returned from an elasticsearch query:
|
107
107
|
|
108
108
|
```ruby
|
109
109
|
ids = Issue.keyword_search('ruby') #=> [7, 3, 5]
|
110
|
-
Issue.where(id: ids).
|
110
|
+
Issue.where(id: ids).order_by([[:id, ids]]).first(2).to_a #=> [Issue<id=7>, Issue<id=3>]
|
111
111
|
```
|
112
112
|
|
113
113
|
## How it works
|
data/lib/order_query.rb
CHANGED
@@ -1,22 +1,28 @@
|
|
1
1
|
require 'active_support'
|
2
2
|
require 'active_record'
|
3
|
-
require 'order_query/
|
3
|
+
require 'order_query/space'
|
4
|
+
require 'order_query/point'
|
4
5
|
|
5
6
|
module OrderQuery
|
6
7
|
extend ActiveSupport::Concern
|
7
8
|
|
8
|
-
included do
|
9
|
-
scope :order_by_query, ->(order_spec) { OrderSpace.new(self, order_spec).scope }
|
10
|
-
scope :reverse_order_by_query, ->(order_spec) { OrderSpace.new(self, order_spec).reverse_scope }
|
11
|
-
end
|
12
|
-
|
13
9
|
# @param [ActiveRecord::Relation] scope
|
14
10
|
# @param [Array<Array<Symbol,String>>] order_spec
|
15
|
-
def
|
16
|
-
|
11
|
+
def order_by(scope = nil, order_spec)
|
12
|
+
scope ||= self.class.all
|
13
|
+
Point.new(self, Space.new(scope, order_spec))
|
17
14
|
end
|
18
15
|
|
19
16
|
module ClassMethods
|
17
|
+
def order_by(order_spec)
|
18
|
+
Space.new(self, order_spec).scope
|
19
|
+
end
|
20
|
+
|
21
|
+
def reverse_order_by(order_spec)
|
22
|
+
Space.new(self, order_spec).reverse_scope
|
23
|
+
end
|
24
|
+
|
25
|
+
#= DSL
|
20
26
|
protected
|
21
27
|
# @param [Symbol] name
|
22
28
|
# @param [Array<Array<Symbol,String>>] order_spec
|
@@ -28,11 +34,17 @@ module OrderQuery
|
|
28
34
|
# Issue.order_display #=> <ActiveRecord::Relation#...>
|
29
35
|
# Issue.active.find(31).display_order(Issue.active).next #=> <Issue#...>
|
30
36
|
def order_query(name, order_spec)
|
31
|
-
scope name, -> {
|
32
|
-
scope :"reverse_#{name}", -> {
|
37
|
+
scope name, -> { order_by(order_spec) }
|
38
|
+
scope :"reverse_#{name}", -> { reverse_order_by(order_spec) }
|
33
39
|
define_method name do |scope = nil|
|
34
|
-
|
40
|
+
order_by scope, order_spec
|
35
41
|
end
|
36
42
|
end
|
37
43
|
end
|
44
|
+
|
45
|
+
class << self
|
46
|
+
attr_accessor :wrap_top_level_or
|
47
|
+
end
|
48
|
+
# Wrap top-level or with an AND and a redundant condition for performance
|
49
|
+
self.wrap_top_level_or = true
|
38
50
|
end
|
@@ -0,0 +1,65 @@
|
|
1
|
+
require 'order_query/sql/condition'
|
2
|
+
module OrderQuery
|
3
|
+
class Condition
|
4
|
+
attr_reader :name, :order, :order_enum, :options, :sql
|
5
|
+
|
6
|
+
# @option spec [String] :unique Mark the attribute as unique to avoid redundant conditions
|
7
|
+
# @option spec [String] :complete Mark the condition's domain as complete to avoid redundant conditions (only for array conditions)
|
8
|
+
def initialize(spec, scope)
|
9
|
+
spec = spec.dup
|
10
|
+
options = spec.extract_options!
|
11
|
+
@name = spec[0]
|
12
|
+
case spec[1]
|
13
|
+
when Array
|
14
|
+
@order_enum = spec[1]
|
15
|
+
@order = spec[2] || :desc
|
16
|
+
else
|
17
|
+
@order = spec[1] || :asc
|
18
|
+
end
|
19
|
+
@options = options
|
20
|
+
@unique = if options.key?(:unique)
|
21
|
+
!!options[:unique]
|
22
|
+
else
|
23
|
+
name.to_s == scope.primary_key
|
24
|
+
end
|
25
|
+
@complete = if options.key?(:complete)
|
26
|
+
!!options[:complete]
|
27
|
+
else
|
28
|
+
!@order_enum
|
29
|
+
end
|
30
|
+
|
31
|
+
@sql = SQL::Condition.new(self, scope)
|
32
|
+
end
|
33
|
+
|
34
|
+
def unique?
|
35
|
+
@unique
|
36
|
+
end
|
37
|
+
|
38
|
+
def complete?
|
39
|
+
@complete
|
40
|
+
end
|
41
|
+
|
42
|
+
# @param [Object] value
|
43
|
+
# @param [:before, :after] mode
|
44
|
+
# @return [Array] valid order values before / after passed (depending on the mode)
|
45
|
+
def filter_values(value, mode, strict = true)
|
46
|
+
ord = order_enum
|
47
|
+
pos = ord.index(value)
|
48
|
+
if pos
|
49
|
+
dir = order
|
50
|
+
if mode == :after && dir == :desc || mode == :before && dir == :asc
|
51
|
+
ord.from pos + (strict ? 1 : 0)
|
52
|
+
else
|
53
|
+
ord.first pos + (strict ? 0 : 1)
|
54
|
+
end
|
55
|
+
else
|
56
|
+
# default to all if current is not in sort order values
|
57
|
+
ord
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
def inspect
|
62
|
+
"Condition(#{@name.inspect}#{" #{@order_enum.inspect}" if @order_enum} #{@order.to_s.upcase} #{'unique ' if @unique}#{@complete ? 'complete' : 'partial' if @order_enum})"
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
@@ -1,19 +1,18 @@
|
|
1
|
-
require 'order_query/
|
2
|
-
require 'order_query/
|
1
|
+
require 'order_query/space'
|
2
|
+
require 'order_query/sql/where'
|
3
3
|
|
4
4
|
module OrderQuery
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
delegate :scope, :reverse_scope, to: :order
|
5
|
+
# Search around a record in an order space
|
6
|
+
class Point
|
7
|
+
attr_reader :record, :space
|
8
|
+
delegate :scope, :reverse_scope, to: :space
|
10
9
|
|
11
10
|
# @param [ActiveRecord::Base] record
|
12
|
-
# @param [OrderQuery::
|
13
|
-
def initialize(record,
|
14
|
-
@record
|
15
|
-
@
|
16
|
-
@
|
11
|
+
# @param [OrderQuery::Space] space
|
12
|
+
def initialize(record, space)
|
13
|
+
@record = record
|
14
|
+
@space = space
|
15
|
+
@where_sql = SQL::Where.new(self)
|
17
16
|
end
|
18
17
|
|
19
18
|
# @return [ActiveRecord::Base]
|
@@ -60,8 +59,8 @@ module OrderQuery
|
|
60
59
|
# @param [:before, :after] direction
|
61
60
|
# @return [ActiveRecord::Relation]
|
62
61
|
def records(direction)
|
63
|
-
|
64
|
-
|
62
|
+
query, query_args = @where_sql.build(direction)
|
63
|
+
scope = (direction == :after ? space.scope : space.reverse_scope)
|
65
64
|
if query.present?
|
66
65
|
scope.where(query, *query_args)
|
67
66
|
else
|
@@ -0,0 +1,30 @@
|
|
1
|
+
require 'order_query/condition'
|
2
|
+
require 'order_query/sql/order_by'
|
3
|
+
module OrderQuery
|
4
|
+
# Order specification and a scope
|
5
|
+
class Space
|
6
|
+
# @return [Array<Condition>]
|
7
|
+
attr_reader :conditions
|
8
|
+
# @return [ActiveRecord::Relation]
|
9
|
+
attr_reader :base_scope
|
10
|
+
|
11
|
+
# @param [ActiveRecord::Relation] scope
|
12
|
+
# @param [Array<Array<Symbol,String>>] order_spec
|
13
|
+
def initialize(base_scope, order_spec)
|
14
|
+
@base_scope = base_scope
|
15
|
+
order_spec = [order_spec] unless order_spec.empty? || order_spec.first.is_a?(Array)
|
16
|
+
@conditions = order_spec.map { |spec| Condition.new(spec, base_scope) }
|
17
|
+
@order_by_sql = SQL::OrderBy.new(self)
|
18
|
+
end
|
19
|
+
|
20
|
+
# @return [ActiveRecord::Relation] scope ordered by conditions
|
21
|
+
def scope
|
22
|
+
@base_scope.order(@order_by_sql.build)
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [ActiveRecord::Relation] scope ordered by conditions in reverse
|
26
|
+
def reverse_scope
|
27
|
+
@base_scope.order(@order_by_sql.build_reverse)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module OrderQuery
|
2
|
+
module SQL
|
3
|
+
class Condition
|
4
|
+
attr_reader :condition, :scope
|
5
|
+
|
6
|
+
def initialize(condition, scope)
|
7
|
+
@condition = condition
|
8
|
+
@scope = scope
|
9
|
+
end
|
10
|
+
|
11
|
+
def column_name
|
12
|
+
@column_name ||= begin
|
13
|
+
sql = condition.options[:sql]
|
14
|
+
if sql
|
15
|
+
sql = sql.call if sql.respond_to?(:call)
|
16
|
+
sql
|
17
|
+
else
|
18
|
+
connection.quote_table_name(scope.table_name) + '.' + connection.quote_column_name(condition.name)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
def quote(value)
|
24
|
+
connection.quote value
|
25
|
+
end
|
26
|
+
|
27
|
+
protected
|
28
|
+
|
29
|
+
def connection
|
30
|
+
scope.connection
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
module OrderQuery
|
2
|
+
module SQL
|
3
|
+
class OrderBy
|
4
|
+
attr_reader :space
|
5
|
+
|
6
|
+
# @param [Array<Condition>]
|
7
|
+
def initialize(space)
|
8
|
+
@space = space
|
9
|
+
end
|
10
|
+
|
11
|
+
# @return [String]
|
12
|
+
def build
|
13
|
+
join_order_by_clauses order_by_sql_clauses
|
14
|
+
end
|
15
|
+
|
16
|
+
# @return [String]
|
17
|
+
def build_reverse
|
18
|
+
join_order_by_clauses order_by_reverse_sql_clauses
|
19
|
+
end
|
20
|
+
|
21
|
+
protected
|
22
|
+
|
23
|
+
# @return [Array<String>]
|
24
|
+
def order_by_sql_clauses
|
25
|
+
space.conditions.map { |cond|
|
26
|
+
if cond.order_enum
|
27
|
+
cond.order_enum.map { |v|
|
28
|
+
"#{cond.sql.column_name}=#{cond.sql.quote v} #{cond.order.to_s.upcase}"
|
29
|
+
}.join(', ').freeze
|
30
|
+
else
|
31
|
+
"#{cond.sql.column_name} #{sort_direction_sql cond.order}".freeze
|
32
|
+
end
|
33
|
+
}
|
34
|
+
end
|
35
|
+
|
36
|
+
SORT_DIRECTIONS = [:asc, :desc].freeze
|
37
|
+
# @return [String]
|
38
|
+
def sort_direction_sql(direction)
|
39
|
+
if SORT_DIRECTIONS.include?(direction)
|
40
|
+
direction.to_s.upcase.freeze
|
41
|
+
else
|
42
|
+
raise ArgumentError.new("sort direction must be in #{SORT_DIRECTIONS.map(&:inspect).join(', ')}, is #{direction.inspect}")
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# @param [Array<String>] clauses
|
47
|
+
def join_order_by_clauses(clauses)
|
48
|
+
clauses.join(', ').freeze
|
49
|
+
end
|
50
|
+
|
51
|
+
# @return [Array<String>]
|
52
|
+
def order_by_reverse_sql_clauses
|
53
|
+
swap = {'DESC' => 'ASC', 'ASC' => 'DESC'}
|
54
|
+
order_by_sql_clauses.map { |s|
|
55
|
+
s.gsub(/DESC|ASC/) { |m| swap[m] }
|
56
|
+
}
|
57
|
+
end
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,120 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
module OrderQuery
|
3
|
+
module SQL
|
4
|
+
# Build where clause for searching around a record in an order space
|
5
|
+
class Where
|
6
|
+
attr_reader :point
|
7
|
+
|
8
|
+
# @param [OrderQuery::Point] point
|
9
|
+
def initialize(point)
|
10
|
+
@point = point
|
11
|
+
end
|
12
|
+
|
13
|
+
# @param [:before or :after] mode
|
14
|
+
# @return [query, parameters] conditions that exclude all elements not before / after the current one
|
15
|
+
def build(mode)
|
16
|
+
conditions = point.space.conditions
|
17
|
+
# pairs of [x0, y0]
|
18
|
+
pairs = conditions.map { |cond|
|
19
|
+
[where_relative(cond, mode, true), (where_eq(cond) unless cond.unique?)].reject { |x|
|
20
|
+
x.nil? || x == WHERE_IDENTITY || x == WHERE_NONE
|
21
|
+
}.compact
|
22
|
+
}
|
23
|
+
query = group_operators pairs
|
24
|
+
return query unless ::OrderQuery.wrap_top_level_or
|
25
|
+
# Wrap top level OR clause for performance, see https://github.com/glebm/order_query/issues/3
|
26
|
+
top_pair_idx = pairs.index(&:present?)
|
27
|
+
if top_pair_idx &&
|
28
|
+
(top_pair = pairs[top_pair_idx]).length == 2 &&
|
29
|
+
(top_level_cond = conditions[top_pair_idx]) &&
|
30
|
+
(redundant_cond = where_relative(top_level_cond, mode, false)) != top_pair.first
|
31
|
+
join_terms 'AND'.freeze, redundant_cond, wrap_parens(query)
|
32
|
+
else
|
33
|
+
query
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
# Join condition pairs internally with OR, and nested within each other with AND
|
38
|
+
# @param [Array] term_pairs of query terms [[x0, y0], [x1, y1], ...],
|
39
|
+
# xi, yi are pairs of [query, parameters]
|
40
|
+
# @return [query, parameters]
|
41
|
+
# x0 OR
|
42
|
+
# y0 AND (x1 OR
|
43
|
+
# y1 AND (x2 OR
|
44
|
+
# y2 AND x3))
|
45
|
+
#
|
46
|
+
# Since x matches order criteria with values that come before / after the current record,
|
47
|
+
# and y matches order criteria with values equal to the current record's value (for resolving ties),
|
48
|
+
# the resulting condition matches just the elements that come before / after the record
|
49
|
+
def group_operators(term_pairs)
|
50
|
+
# create "x OR y" string
|
51
|
+
disjunctive = join_terms 'OR'.freeze, *term_pairs[0]
|
52
|
+
rest = term_pairs.from(1)
|
53
|
+
if rest.present?
|
54
|
+
# nest the remaining pairs recursively, appending them with " AND "
|
55
|
+
rest_grouped = group_operators rest
|
56
|
+
join_terms 'AND'.freeze, disjunctive, (rest.length == 1 ? rest_grouped : wrap_parens(rest_grouped))
|
57
|
+
else
|
58
|
+
disjunctive
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def wrap_parens(t)
|
63
|
+
["(#{t[0]})", t[1]]
|
64
|
+
end
|
65
|
+
|
66
|
+
# joins terms with an operator
|
67
|
+
# @return [query, parameters]
|
68
|
+
def join_terms(op, *terms)
|
69
|
+
[terms.map { |t| t.first.presence }.compact.join(" #{op} "),
|
70
|
+
terms.map(&:second).reduce(:+) || []]
|
71
|
+
end
|
72
|
+
|
73
|
+
# @param [:before or :after] mode
|
74
|
+
# @return [query, params] return query conditions for attribute values before / after the current one
|
75
|
+
def where_relative(cond, mode, strict = true)
|
76
|
+
value = attr_value cond
|
77
|
+
if cond.order_enum
|
78
|
+
values = cond.filter_values(value, mode, strict)
|
79
|
+
if cond.complete? && values.length == cond.order_enum.length
|
80
|
+
WHERE_IDENTITY
|
81
|
+
else
|
82
|
+
where_in cond, values
|
83
|
+
end
|
84
|
+
else
|
85
|
+
where_ray cond, value, mode, strict
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
|
90
|
+
def where_in(cond, values)
|
91
|
+
case values.length
|
92
|
+
when 0
|
93
|
+
WHERE_NONE
|
94
|
+
when 1
|
95
|
+
where_eq cond, values[0]
|
96
|
+
else
|
97
|
+
["#{cond.sql.column_name} IN (?)".freeze, [values]]
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def where_eq(cond, value = attr_value(cond))
|
102
|
+
[%Q(#{cond.sql.column_name} = ?).freeze, [value]]
|
103
|
+
end
|
104
|
+
|
105
|
+
def where_ray(cond, from, mode, strict = true)
|
106
|
+
ops = %w(< >)
|
107
|
+
ops = ops.reverse if mode == :after
|
108
|
+
op = {asc: ops[0], desc: ops[1]}[cond.order || :asc]
|
109
|
+
["#{cond.sql.column_name} #{op}#{'=' unless strict} ?".freeze, [from]]
|
110
|
+
end
|
111
|
+
|
112
|
+
WHERE_IDENTITY = [''.freeze, [].freeze].freeze
|
113
|
+
WHERE_NONE = ['∅'.freeze, [].freeze].freeze
|
114
|
+
|
115
|
+
def attr_value(cond)
|
116
|
+
point.record.send cond.name
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
data/lib/order_query/version.rb
CHANGED
data/spec/order_query_spec.rb
CHANGED
@@ -1,6 +1,11 @@
|
|
1
1
|
require 'spec_helper'
|
2
2
|
|
3
|
-
#
|
3
|
+
# Bare model
|
4
|
+
class User < ActiveRecord::Base
|
5
|
+
include OrderQuery
|
6
|
+
end
|
7
|
+
|
8
|
+
# Simple model
|
4
9
|
class Post < ActiveRecord::Base
|
5
10
|
include OrderQuery
|
6
11
|
order_query :order_list, [
|
@@ -14,7 +19,7 @@ def create_post(attr = {})
|
|
14
19
|
Post.create!({pinned: false, published_at: Time.now}.merge(attr))
|
15
20
|
end
|
16
21
|
|
17
|
-
# Advanced
|
22
|
+
# Advanced model
|
18
23
|
class Issue < ActiveRecord::Base
|
19
24
|
DISPLAY_ORDER = [
|
20
25
|
[:priority, %w(high medium low), complete: true],
|
@@ -36,15 +41,15 @@ def create_issue(attr = {})
|
|
36
41
|
Issue.create!({priority: 'high', votes: 3, suspicious_votes: 0, updated_at: Time.now}.merge(attr))
|
37
42
|
end
|
38
43
|
|
39
|
-
def
|
40
|
-
|
44
|
+
def wrap_top_level_or(value)
|
45
|
+
conf = ::OrderQuery
|
41
46
|
around do |ex|
|
42
|
-
was =
|
47
|
+
was = conf.wrap_top_level_or
|
43
48
|
begin
|
44
|
-
|
49
|
+
conf.wrap_top_level_or = value
|
45
50
|
ex.run
|
46
51
|
ensure
|
47
|
-
|
52
|
+
conf.wrap_top_level_or = was
|
48
53
|
end
|
49
54
|
end
|
50
55
|
end
|
@@ -53,7 +58,7 @@ describe 'OrderQuery' do
|
|
53
58
|
|
54
59
|
[false, true].each do |wrap_top_level_or|
|
55
60
|
context "(wrap_top_level_or: #{wrap_top_level_or})" do
|
56
|
-
|
61
|
+
wrap_top_level_or wrap_top_level_or
|
57
62
|
|
58
63
|
context 'Issue test model' do
|
59
64
|
t = Time.now
|
@@ -113,21 +118,21 @@ describe 'OrderQuery' do
|
|
113
118
|
expect(Issue.id_order_asc.count).to eq(2)
|
114
119
|
end
|
115
120
|
|
116
|
-
it '.
|
121
|
+
it '.order_by works on a list of ids' do
|
117
122
|
ids = (1..3).map { create_issue.id }
|
118
|
-
expect(Issue.
|
123
|
+
expect(Issue.order_by([[:id, ids]]).size).to eq ids.length
|
119
124
|
end
|
120
125
|
|
121
|
-
it '.
|
126
|
+
it '.order_by preserves previous' do
|
122
127
|
create_issue(active: true)
|
123
|
-
expect(Issue.where(active: false).
|
124
|
-
expect(Issue.where(active: true).
|
128
|
+
expect(Issue.where(active: false).order_by([[:id, :desc]])).to be_empty
|
129
|
+
expect(Issue.where(active: true).order_by([[:id, :desc]]).size).to eq 1
|
125
130
|
end
|
126
131
|
|
127
|
-
it '#
|
132
|
+
it '#order_by falls back to scope when order condition is missing self' do
|
128
133
|
a = create_issue(priority: 'medium')
|
129
134
|
b = create_issue(priority: 'high')
|
130
|
-
expect(a.
|
135
|
+
expect(a.order_by(Issue.display_order, [[:priority, ['wontfix', 'askbob']], [:id, :desc]]).next).to eq(b)
|
131
136
|
end
|
132
137
|
|
133
138
|
before do
|
@@ -187,4 +192,39 @@ describe 'OrderQuery' do
|
|
187
192
|
end
|
188
193
|
end
|
189
194
|
end
|
195
|
+
|
196
|
+
context 'SQL generation' do
|
197
|
+
context 'wrap top-level OR on' do
|
198
|
+
wrap_top_level_or true
|
199
|
+
it 'wraps top-level OR' do
|
200
|
+
after_scope = User.create!(updated_at: Date.parse('2014-09-06')).order_by([[:updated_at, :desc], [:id, :desc]]).after
|
201
|
+
expect(after_scope.to_sql).to include('<=')
|
202
|
+
end
|
203
|
+
end
|
204
|
+
|
205
|
+
context 'wrap top-level OR off' do
|
206
|
+
wrap_top_level_or false
|
207
|
+
it 'does not wrap top-level OR' do
|
208
|
+
after_scope = User.create!(updated_at: Date.parse('2014-09-06')).order_by([[:updated_at, :desc], [:id, :desc]]).after
|
209
|
+
expect(after_scope.to_sql).to_not include('<=')
|
210
|
+
end
|
211
|
+
end
|
212
|
+
|
213
|
+
before do
|
214
|
+
User.delete_all
|
215
|
+
end
|
216
|
+
|
217
|
+
before :all do
|
218
|
+
ActiveRecord::Schema.define do
|
219
|
+
self.verbose = false
|
220
|
+
create_table :users do |t|
|
221
|
+
t.datetime :updated_at, null: false
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
after :all do
|
227
|
+
ActiveRecord::Migration.drop_table :users
|
228
|
+
end
|
229
|
+
end
|
190
230
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: order_query
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.2.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Gleb Mazovetskiy
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2014-09-
|
11
|
+
date: 2014-09-06 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: activerecord
|
@@ -78,11 +78,13 @@ files:
|
|
78
78
|
- README.md
|
79
79
|
- Rakefile
|
80
80
|
- lib/order_query.rb
|
81
|
-
- lib/order_query/
|
82
|
-
- lib/order_query/
|
83
|
-
- lib/order_query/
|
81
|
+
- lib/order_query/condition.rb
|
82
|
+
- lib/order_query/point.rb
|
83
|
+
- lib/order_query/space.rb
|
84
|
+
- lib/order_query/sql/condition.rb
|
85
|
+
- lib/order_query/sql/order_by.rb
|
86
|
+
- lib/order_query/sql/where.rb
|
84
87
|
- lib/order_query/version.rb
|
85
|
-
- lib/order_query/where_builder.rb
|
86
88
|
- spec/order_query_spec.rb
|
87
89
|
- spec/spec_helper.rb
|
88
90
|
homepage: https://github.com/glebm/order_query
|
@@ -1,71 +0,0 @@
|
|
1
|
-
module OrderQuery
|
2
|
-
class OrderCondition
|
3
|
-
attr_reader :name, :order, :order_order, :options, :scope
|
4
|
-
|
5
|
-
# @option spec [String] :unique Mark the attribute as unique to avoid redundant conditions
|
6
|
-
# @option spec [String] :complete Mark the condition's domain as complete to avoid redundant conditions (only for array conditions)
|
7
|
-
def initialize(scope, spec)
|
8
|
-
spec = spec.dup
|
9
|
-
options = spec.extract_options!
|
10
|
-
@name = spec[0]
|
11
|
-
@order = spec[1] || :asc
|
12
|
-
@order_order = spec[2] || :desc
|
13
|
-
@options = options
|
14
|
-
@scope = scope
|
15
|
-
@unique = if options.key?(:unique)
|
16
|
-
!!options[:unique]
|
17
|
-
else
|
18
|
-
name.to_s == scope.primary_key
|
19
|
-
end
|
20
|
-
@complete = if options.key?(:complete)
|
21
|
-
!!options[:complete]
|
22
|
-
else
|
23
|
-
!list?
|
24
|
-
end
|
25
|
-
end
|
26
|
-
|
27
|
-
def unique?
|
28
|
-
@unique
|
29
|
-
end
|
30
|
-
|
31
|
-
def complete?
|
32
|
-
@complete
|
33
|
-
end
|
34
|
-
|
35
|
-
# @return [Boolean] whether order is specified as a list of values
|
36
|
-
def list?
|
37
|
-
order.is_a?(Enumerable)
|
38
|
-
end
|
39
|
-
|
40
|
-
# @param [Object] value
|
41
|
-
# @param [:before, :after] mode
|
42
|
-
# @return [Array] valid order values before / after passed (depending on the mode)
|
43
|
-
def filter_values(value, mode, strict = true)
|
44
|
-
ord = order
|
45
|
-
pos = ord.index(value)
|
46
|
-
if pos
|
47
|
-
dir = order_order
|
48
|
-
if mode == :after && dir == :desc || mode == :before && dir == :asc
|
49
|
-
ord.from pos + (strict ? 1 : 0)
|
50
|
-
else
|
51
|
-
ord.first pos + (strict ? 0 : 1)
|
52
|
-
end
|
53
|
-
else
|
54
|
-
# default to all if current is not in sort order values
|
55
|
-
ord
|
56
|
-
end
|
57
|
-
end
|
58
|
-
|
59
|
-
def col_name_sql
|
60
|
-
@col_name_sql ||= begin
|
61
|
-
sql = options[:sql]
|
62
|
-
if sql
|
63
|
-
sql = sql.call if sql.respond_to?(:call)
|
64
|
-
sql
|
65
|
-
else
|
66
|
-
scope.connection.quote_table_name(scope.table_name) + '.' + scope.connection.quote_column_name(name)
|
67
|
-
end
|
68
|
-
end
|
69
|
-
end
|
70
|
-
end
|
71
|
-
end
|
@@ -1,74 +0,0 @@
|
|
1
|
-
require 'order_query/order_condition'
|
2
|
-
module OrderQuery
|
3
|
-
# Combine order specification with a scope
|
4
|
-
class OrderSpace
|
5
|
-
attr_reader :conditions
|
6
|
-
|
7
|
-
# @param [ActiveRecord::Relation] scope
|
8
|
-
# @param [Array<Array<Symbol,String>>] order_spec
|
9
|
-
def initialize(scope, order_spec)
|
10
|
-
@scope = scope
|
11
|
-
@conditions = order_spec.map { |spec| OrderCondition.new(scope, spec) }
|
12
|
-
end
|
13
|
-
|
14
|
-
# @return [ActiveRecord::Relation]
|
15
|
-
def scope
|
16
|
-
@scope.order(order_by_sql)
|
17
|
-
end
|
18
|
-
|
19
|
-
# @return [ActiveRecord::Relation]
|
20
|
-
def reverse_scope
|
21
|
-
@scope.order(order_by_reverse_sql)
|
22
|
-
end
|
23
|
-
|
24
|
-
SORT_DIRECTIONS = [:asc, :desc].freeze
|
25
|
-
|
26
|
-
# @return [String]
|
27
|
-
def sort_direction_sql(direction)
|
28
|
-
if SORT_DIRECTIONS.include?(direction)
|
29
|
-
direction.to_s.upcase.freeze
|
30
|
-
else
|
31
|
-
raise ArgumentError.new("sort direction must be in #{SORT_DIRECTIONS.map(&:inspect).join(', ')}, is #{direction.inspect}")
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
# @return [Array<String>]
|
36
|
-
def order_by_sql_clauses
|
37
|
-
conditions.map { |cond|
|
38
|
-
case order_spec = cond.order
|
39
|
-
when Symbol
|
40
|
-
"#{cond.col_name_sql} #{sort_direction_sql order_spec}".freeze
|
41
|
-
when Enumerable
|
42
|
-
order_spec.map { |v|
|
43
|
-
"#{cond.col_name_sql}=#{@scope.connection.quote v} #{cond.order_order.to_s.upcase}"
|
44
|
-
}.join(', ').freeze
|
45
|
-
else
|
46
|
-
raise ArgumentError.new("Invalid order #{order_spec.inspect} (#{cond.inspect})")
|
47
|
-
end
|
48
|
-
}
|
49
|
-
end
|
50
|
-
|
51
|
-
# @return [Array<String>]
|
52
|
-
def order_by_reverse_sql_clauses
|
53
|
-
swap = {'DESC' => 'ASC', 'ASC' => 'DESC'}
|
54
|
-
order_by_sql_clauses.map { |s|
|
55
|
-
s.gsub(/DESC|ASC/) { |m| swap[m] }
|
56
|
-
}
|
57
|
-
end
|
58
|
-
|
59
|
-
# @return [String]
|
60
|
-
def order_by_reverse_sql
|
61
|
-
join_order_by_clauses order_by_reverse_sql_clauses
|
62
|
-
end
|
63
|
-
|
64
|
-
# @return [String]
|
65
|
-
def order_by_sql
|
66
|
-
join_order_by_clauses order_by_sql_clauses
|
67
|
-
end
|
68
|
-
|
69
|
-
# @param [Array<String>] clauses
|
70
|
-
def join_order_by_clauses(clauses)
|
71
|
-
clauses.join(', ').freeze
|
72
|
-
end
|
73
|
-
end
|
74
|
-
end
|
@@ -1,124 +0,0 @@
|
|
1
|
-
# coding: utf-8
|
2
|
-
module OrderQuery
|
3
|
-
# Build where clause for searching around a record in an order space
|
4
|
-
class WhereBuilder
|
5
|
-
# @return [ActiveRecord::Base]
|
6
|
-
attr_reader :record
|
7
|
-
# @return [OrderQuery::OrderSpace]
|
8
|
-
attr_reader :order
|
9
|
-
|
10
|
-
# @param [ActiveRecord::Base] record
|
11
|
-
# @param [OrderQuery::OrderSpace] order_space
|
12
|
-
def initialize(record, order_space)
|
13
|
-
@order = order_space
|
14
|
-
@record = record
|
15
|
-
end
|
16
|
-
|
17
|
-
# @param [:before or :after] mode
|
18
|
-
# @return [query, parameters] conditions that exclude all elements not before / after the current one
|
19
|
-
def build_query(mode)
|
20
|
-
# pairs of [x0, y0]
|
21
|
-
pairs = order.conditions.map { |cond|
|
22
|
-
[where_relative(cond, mode, true), (where_eq(cond) unless cond.unique?)].reject { |x|
|
23
|
-
x.nil? || x == WHERE_IDENTITY || x == WHERE_NONE
|
24
|
-
}.compact
|
25
|
-
}
|
26
|
-
query = group_operators pairs
|
27
|
-
return query unless self.class.wrap_top_level_or
|
28
|
-
# Wrap top level OR clause for performance, see https://github.com/glebm/order_query/issues/3
|
29
|
-
top_pair_idx = pairs.index(&:present?)
|
30
|
-
if top_pair_idx && pairs[top_pair_idx].length == 2 && (top_level_cond = order.conditions[top_pair_idx])
|
31
|
-
join_terms 'AND'.freeze, where_relative(top_level_cond, mode, false), wrap_parens(query)
|
32
|
-
else
|
33
|
-
query
|
34
|
-
end
|
35
|
-
end
|
36
|
-
|
37
|
-
# Join condition pairs internally with OR, and nested within each other with AND
|
38
|
-
# @param [Array] term_pairs of query terms [[x0, y0], [x1, y1], ...],
|
39
|
-
# xi, yi are pairs of [query, parameters]
|
40
|
-
# @return [query, parameters]
|
41
|
-
# x0 OR
|
42
|
-
# y0 AND (x1 OR
|
43
|
-
# y1 AND (x2 OR
|
44
|
-
# y2 AND x3))
|
45
|
-
#
|
46
|
-
# Since x matches order criteria with values that come before / after the current record,
|
47
|
-
# and y matches order criteria with values equal to the current record's value (for resolving ties),
|
48
|
-
# the resulting condition matches just the elements that come before / after the record
|
49
|
-
def group_operators(term_pairs)
|
50
|
-
# create "x OR y" string
|
51
|
-
disjunctive = join_terms 'OR'.freeze, *term_pairs[0]
|
52
|
-
rest = term_pairs.from(1)
|
53
|
-
if rest.present?
|
54
|
-
# nest the remaining pairs recursively, appending them with " AND "
|
55
|
-
rest_grouped = group_operators rest
|
56
|
-
join_terms 'AND'.freeze, disjunctive, (rest.length == 1 ? rest_grouped : wrap_parens(rest_grouped))
|
57
|
-
else
|
58
|
-
disjunctive
|
59
|
-
end
|
60
|
-
end
|
61
|
-
|
62
|
-
def wrap_parens(t)
|
63
|
-
["(#{t[0]})", t[1]]
|
64
|
-
end
|
65
|
-
|
66
|
-
# joins terms with an operator
|
67
|
-
# @return [query, parameters]
|
68
|
-
def join_terms(op, *terms)
|
69
|
-
[terms.map { |t| t.first.presence }.compact.join(" #{op} "),
|
70
|
-
terms.map(&:second).reduce(:+) || []]
|
71
|
-
end
|
72
|
-
|
73
|
-
# @param [:before or :after] mode
|
74
|
-
# @return [query, params] return query conditions for attribute values before / after the current one
|
75
|
-
def where_relative(cond, mode, strict = true, skip_complete = true)
|
76
|
-
value = attr_value cond
|
77
|
-
if cond.list?
|
78
|
-
values = cond.filter_values(value, mode, strict)
|
79
|
-
if cond.complete? && values.length == cond.order.length
|
80
|
-
WHERE_IDENTITY
|
81
|
-
else
|
82
|
-
where_in cond, values
|
83
|
-
end
|
84
|
-
else
|
85
|
-
where_ray cond, value, mode, strict
|
86
|
-
end
|
87
|
-
end
|
88
|
-
|
89
|
-
|
90
|
-
def where_in(cond, values)
|
91
|
-
case values.length
|
92
|
-
when 0
|
93
|
-
WHERE_NONE
|
94
|
-
when 1
|
95
|
-
where_eq cond, values[0]
|
96
|
-
else
|
97
|
-
["#{cond.col_name_sql} IN (?)".freeze, [values]]
|
98
|
-
end
|
99
|
-
end
|
100
|
-
|
101
|
-
def where_eq(cond, value = attr_value(cond))
|
102
|
-
[%Q(#{cond.col_name_sql} = ?).freeze, [value]]
|
103
|
-
end
|
104
|
-
|
105
|
-
def where_ray(cond, from, mode, strict = true)
|
106
|
-
ops = %w(< >)
|
107
|
-
ops = ops.reverse if mode == :after
|
108
|
-
op = {asc: ops[0], desc: ops[1]}[cond.order || :asc]
|
109
|
-
["#{cond.col_name_sql} #{op}#{'=' unless strict} ?".freeze, [from]]
|
110
|
-
end
|
111
|
-
|
112
|
-
WHERE_IDENTITY = [''.freeze, [].freeze].freeze
|
113
|
-
WHERE_NONE = ['∅'.freeze, [].freeze].freeze
|
114
|
-
|
115
|
-
def attr_value(cond)
|
116
|
-
record.send cond.name
|
117
|
-
end
|
118
|
-
|
119
|
-
class << self
|
120
|
-
attr_accessor :wrap_top_level_or
|
121
|
-
end
|
122
|
-
self.wrap_top_level_or = true
|
123
|
-
end
|
124
|
-
end
|