order_query 0.2.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 04dd1e3cb69ac7b1cd363629e1449f2eec4bd921
4
- data.tar.gz: 1d2533afcfea18fe8a1b06ac63dec068ef59cf8f
3
+ metadata.gz: 02a08c0101e3e15512098357d8e9159941b2606f
4
+ data.tar.gz: a11460b00d5f8e3dc16dc28f0fc264d4c17f1fef
5
5
  SHA512:
6
- metadata.gz: f708515aa23195a7cb3051fca8848d1382190429ed9f2055cf0d0bc07b7b66b83c8792d5f1c3fdbbe6c7d3df2f62ec0571cc95755baeec621470c6464c838a05
7
- data.tar.gz: 7650728e46013f47e129d9a4e939ef531c687bda7315f8326737d628f1b8bee509395a072c079e342d04e2e34640ea9cde5b4f504eed25625ee05082c61f0a3a
6
+ metadata.gz: 9e450de5cb30e692f068ae9551459879a3315c9feed3527809a50f82412954ce258a495f54bbefa18f04e1a823310fd1eba55d44caf1d623bb94ac7712e0f905
7
+ data.tar.gz: b2c92a65bdb70275836a7fede9c457056fdbb9ba7fe768a8fe3b5608790264dec600ad857f504135a7d465905fa3201d7c740f03b71546d68bbd8ea1e92090af
data/CHANGES.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 0.3.0
2
+
3
+ * `order_query` now accepts conditions as varargs. Array form is still supported.
4
+ * `order_by` renamed to `seek`
5
+
1
6
  ## 0.2.1
2
7
 
3
8
  * `complete` now defaults to true for list attributes as well.
data/Gemfile CHANGED
@@ -3,7 +3,7 @@ source 'https://rubygems.org'
3
3
  gemspec
4
4
 
5
5
  group :test, :development do
6
- gem 'coveralls', require: false
6
+ gem 'codeclimate-test-reporter', group: :test, require: nil
7
7
  gem 'byebug', platform: :mri_21, require: false
8
8
  end
9
9
 
data/README.md CHANGED
@@ -1,132 +1,143 @@
1
- # order_query [![Build Status][travis-badge]][travis] [![Code Climate][codeclimate-badge]][codeclimate] [![Coverage Status][coveralls-badge]][coveralls]
1
+ # order_query [![Build Status][travis-badge]][travis] [![Code Climate][codeclimate-badge]][codeclimate] [![Coverage Status][coverage-badge]][coverage]
2
2
 
3
- order_query gives you next or previous records relative to the current one efficiently.
3
+ <a href="http://use-the-index-luke.com/no-offset">
4
+ <img src="http://use-the-index-luke.com/img/no-offset.q200.png" alt="100% offset-free" align="right" width="106" height="106">
5
+ </a>
4
6
 
5
- For example, you have a list of items, sorted by priority. You have 10,000 items!
6
- If you are showing the user a single item, how do you provide buttons for the user to see the previous item or the next item?
7
-
8
- You could pass the item's position to the item page and use `OFFSET` in your SQL query.
9
- The downside of this, apart from having to pass a number that may change, is that the database cannot jump to the offset; it has to read every record until it reaches, say, the 9001st record.
10
- This is slow. Here is where `order_query` comes in!
11
-
12
- `order_query` uses the same `ORDER BY` query, but also includes a `WHERE` clause that excludes records before (for next) or after (for prev) the current one.
7
+ This gem finds the next or previous record(s) relative to the current one efficiently. It is also useful for implementing infinite scroll.
8
+ It uses [keyset pagination](http://use-the-index-luke.com/no-offset) to achieve this.
13
9
 
14
10
  ## Installation
15
11
 
16
12
  Add to Gemfile:
17
13
 
18
14
  ```ruby
19
- gem 'order_query', '~> 0.2.1'
15
+ gem 'order_query', '~> 0.3.0'
20
16
  ```
21
17
 
22
18
  ## Usage
23
19
 
24
- Define a list of order conditions with `order_query`:
20
+ Define named order conditions with `order_query`:
25
21
 
26
22
  ```ruby
27
23
  class Post < ActiveRecord::Base
28
24
  include OrderQuery
29
- order_query :order_for_index, [
25
+ order_query :order_home,
30
26
  [:pinned, [true, false]],
31
27
  [:published_at, :desc],
32
28
  [:id, :desc]
33
- ]
34
29
  end
35
30
  ```
36
31
 
37
- An order condition is specified as an attribute name, optionally an ordered list of values, and a sort direction.
38
- Additional options are:
32
+ Order query accepts a list of order conditions as varargs or one array, each one specified as:
33
+
34
+ ```ruby
35
+ [<attribute name>, (attribute values in order), (:asc or :desc), (options hash)]
36
+ ```
37
+
38
+ Available options:
39
39
 
40
40
  | option | description |
41
41
  |------------|----------------------------------------------------------------------------|
42
42
  | unique | Unique attribute. Default: `true` for primary key, `false` otherwise. |
43
- | complete | Enum attribute contains all the possible values. Default: `true`. |
43
+ | complete | Specified attribute values are the only possible values. Default: `true`. |
44
44
  | sql | Customize attribute value SQL |
45
45
 
46
- ### Order scopes
47
46
 
48
- Order scopes are defined by `order_query`:
47
+ ### Scopes for `ORDER BY`
49
48
 
50
49
  ```ruby
51
- Post.order_for_index #=> ActiveRecord::Relation<...>
52
- Post.reverse_order_for_index #=> ActiveRecord::Relation<...>
50
+ Post.published.order_home #=> #<ActiveRecord::Relation>
51
+ Post.published.order_home_reverse #=> #<ActiveRecord::Relation>
53
52
  ```
54
53
 
55
- ### Before, after, previous, and next
54
+ ### Before / after, previous / next, and position
56
55
 
57
- An method is added by `order_query` to query around a record:
56
+ First, get an `OrderQuery::Point` for the record:
58
57
 
59
58
  ```ruby
60
- # get the order object, scope default: Post.all
61
- p = Post.find(31).order_for_index(scope) #=> OrderQuery::RelativeOrder<...>
62
- p.before #=> ActiveRecord::Relation<...>
63
- p.previous #=> Post<...>
64
- # pass true to #next and #previous in order to loop onto the the first / last record
65
- # will not loop onto itself
66
- p.previous(true) #=> Post<...>
67
- p.position #=> 5
68
- p.next #=> Post<...>
69
- p.after #=> ActiveRecord::Relation<...>
59
+ p = Post.published.order_home_at(Post.find(31)) #=> #<OrderQuery::Point>
70
60
  ```
71
61
 
72
- #### Order conditions, advanced example
62
+ It exposes these finder methods:
73
63
 
74
64
  ```ruby
75
- class Issue < ActiveRecord::Base
76
- include OrderQuery
77
- order_query :order_display, [
78
- # Pass an array for attribute order, and an optional sort direction for the array,
79
- # default is *:desc*, so that first in the array <=> first in the result
80
- [:priority, %w(high medium low), :desc],
81
- # Sort attribute can be a method name, provided you pass :sql for the attribute
82
- [:valid_votes_count, :desc, sql: '(votes - suspicious_votes)'],
83
- # Default sort order for non-array attributes is :asc, just like SQL
84
- [:updated_at, :desc],
85
- # pass unique: true for unique attributes to get more optimized queries
86
- # default: true for primary_key, false otherwise
87
- [:id, :desc, unique: true]
88
- ]
89
- def valid_votes_count
90
- votes - suspicious_votes
91
- end
92
- end
65
+ p.before #=> #<ActiveRecord::Relation>
66
+ p.after #=> #<ActiveRecord::Relation>
67
+ p.previous #=> #<Post>
68
+ p.next #=> #<Post>
69
+ p.position #=> 5
70
+ ```
71
+
72
+ Looping to the first / last record is enabled by default. Pass `false` to disable:
73
+
74
+ ```ruby
75
+ p = Post.order_home_at(Post.order_home.first)
76
+ p.previous #=> #<Post>
77
+ p.previous(false) #=> nil
93
78
  ```
94
79
 
95
- ### Dynamic order conditions
80
+ Even with looping, `nil` will be returned if there is only one record.
96
81
 
97
- To query with dynamic order conditions use `Model.order_by` and `Model#order_by`:
82
+ You can also get an `OrderQuery::Point` from an instance and a scope:
98
83
 
99
84
  ```ruby
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<...>
85
+ posts = Post.published
86
+ post = posts.find(42)
87
+ post.order_home(posts) #=> #<OrderQuery::Point>
104
88
  ```
105
89
 
106
- For example, consider ordering by a list of ids returned from an elasticsearch query:
90
+ ### Dynamic conditions
91
+
92
+ Query with dynamic order conditions using the `seek(*spec)` class method:
107
93
 
108
94
  ```ruby
109
- ids = Issue.keyword_search('ruby') #=> [7, 3, 5]
110
- Issue.where(id: ids).order_by([[:id, ids]]).first(2).to_a #=> [Issue<id=7>, Issue<id=3>]
95
+ space = Post.visible.seek([:id, :desc]) #=> #<OrderQuery::Space>
111
96
  ```
112
97
 
113
- ## How it works
98
+ This returns an `OrderQuery::Space` that exposes these methods:
114
99
 
115
- Internally this gem builds a query that depends on the current record's order values and looks like:
100
+ ```ruby
101
+ space.scope #=> #<ActiveRecord::Relation>
102
+ space.scope_reverse #=> #<ActiveRecord::Relation>
103
+ space.first #=> scope.first
104
+ space.last #=> scope_reverse.first
105
+ space.at(Post.first) #=> #<OrderQuery::Point>
106
+ ```
116
107
 
117
- ```sql
118
- SELECT ... WHERE
119
- x0 OR
120
- y0 AND (x1 OR
121
- y1 AND (x2 OR
122
- y2 AND ...))
123
- ORDER BY ...
124
- LIMIT 1
108
+ Alternatively, get an `OrderQuery::Point` using the `seek(scope, *spec)` instance method:
109
+
110
+ ```ruby
111
+ Post.find(42).seek(Post.visible, [:id, :desc]) #=> #<OrderQuery::Point>
112
+ # scope defaults to Post.all
113
+ Post.find(42).seek([:id, :desc]) #=> #<OrderQuery::Point>
114
+ ```
115
+
116
+ #### Advanced options example
117
+
118
+ ```ruby
119
+ class Post < ActiveRecord::Base
120
+ include OrderQuery
121
+ order_query :order_home,
122
+ # For an array of order values, default direction is :desc
123
+ # High-priority issues will be ordered first in this example
124
+ [:priority, %w(high medium low)],
125
+ # A method and custom SQL can be used instead of an attribute
126
+ [:valid_votes_count, :desc, sql: '(votes - suspicious_votes)'],
127
+ # Default sort order for non-array conditions is :asc, just like SQL
128
+ [:updated_at, :desc],
129
+ # pass unique: true for unique attributes to get more optimized queries
130
+ # unique is true by default for primary_key
131
+ [:id, :desc]
132
+ def valid_votes_count
133
+ votes - suspicious_votes
134
+ end
135
+ end
125
136
  ```
126
137
 
127
- Where `x` correspond to `>` / `<` terms, and `y` to `=` terms (for resolving ties), per order criterion.
138
+ ## How it works
128
139
 
129
- A query may then look like this:
140
+ Internally this gem builds a query that depends on the current record's values and looks like this:
130
141
 
131
142
  ```sql
132
143
  -- Current post: pinned=true published_at='2014-03-21 15:01:35.064096' id=9
@@ -142,32 +153,12 @@ ORDER BY
142
153
  LIMIT 1
143
154
  ```
144
155
 
145
- A query for the advanced example would look like this:
146
-
147
- ```sql
148
- -- Current issue: priority='high' (votes - suspicious_votes)=4 updated_at='2014-03-19 10:23:18.671039' id=9
149
- SELECT "issues".* FROM "issues" WHERE
150
- ("issues"."priority" IN ('medium','low') OR
151
- "issues"."priority" = 'high' AND (
152
- (votes - suspicious_votes) < 4 OR
153
- (votes - suspicious_votes) = 4 AND (
154
- "issues"."updated_at" < '2014-03-19 10:23:18.671039' OR
155
- "issues"."updated_at" = '2014-03-19 10:23:18.671039' AND
156
- "issues"."id" < 9)))
157
- ORDER BY
158
- "issues"."priority"='high' DESC,
159
- "issues"."priority"='medium' DESC,
160
- "issues"."priority"='low' DESC,
161
- (votes - suspicious_votes) DESC,
162
- "issues"."updated_at" DESC,
163
- "issues"."id" DESC
164
- LIMIT 1
165
- ```
166
-
167
156
  The actual query is a bit different because `order_query` wraps the top-level `OR` with a (redundant) non-strict condition `x0' AND (x0 OR ...)`
168
157
  for [performance reasons](https://github.com/glebm/order_query/issues/3).
169
158
  This can be disabled with `OrderQuery.wrap_top_level_or = false`.
170
159
 
160
+ See the implementation in [sql/where.rb](/lib/order_query/sql/where.rb).
161
+
171
162
  See how this affects query planning in Markus Winand's slides on [Pagination done the Right Way](http://use-the-index-luke.com/blog/2013-07/pagination-done-the-postgresql-way).
172
163
 
173
164
  This project uses MIT license.
@@ -178,5 +169,5 @@ This project uses MIT license.
178
169
  [gemnasium]: https://gemnasium.com/glebm/order_query
179
170
  [codeclimate]: https://codeclimate.com/github/glebm/order_query
180
171
  [codeclimate-badge]: http://img.shields.io/codeclimate/github/glebm/order_query.svg
181
- [coveralls]: https://coveralls.io/r/glebm/order_query
182
- [coveralls-badge]: http://img.shields.io/coveralls/glebm/order_query.svg
172
+ [coverage]: https://codeclimate.com/github/glebm/order_query
173
+ [coverage-badge]: https://codeclimate.com/github/glebm/order_query/badges/coverage.svg
@@ -1,7 +1,10 @@
1
+ # coding: utf-8
1
2
  require 'order_query/sql/condition'
2
3
  module OrderQuery
4
+ # An order condition (sort column)
3
5
  class Condition
4
- attr_reader :name, :order, :order_enum, :options, :sql
6
+ attr_reader :name, :order, :order_enum, :options
7
+ delegate :column_name, :quote, to: :@sql
5
8
 
6
9
  # @option spec [String] :unique Mark the attribute as unique to avoid redundant conditions
7
10
  # @option spec [String] :complete Mark the condition's domain as complete to avoid redundant conditions (only for array conditions)
@@ -22,7 +25,7 @@ module OrderQuery
22
25
  )
23
26
  @unique = @options[:unique]
24
27
  @complete = @options[:complete]
25
- @sql = SQL::Condition.new(self, scope)
28
+ @sql = SQL::Condition.new(self, scope)
26
29
  end
27
30
 
28
31
  def unique?
@@ -56,7 +59,15 @@ module OrderQuery
56
59
  end
57
60
 
58
61
  def inspect
59
- "Condition(#{@name.inspect}#{" #{@order_enum.inspect}" if @order_enum} #{@order.to_s.upcase} #{'unique ' if @unique}#{@complete ? 'complete' : 'partial' if @order_enum})"
62
+ parts = [
63
+ @name,
64
+ (@order_enum.inspect if order_enum),
65
+ ('unique' if @unique),
66
+ ('complete' if order_enum && complete?),
67
+ (column_name if options[:sql]),
68
+ {desc: '▼', asc: '▲'}[@order]
69
+ ].compact
70
+ "(#{parts.join(' ')})"
60
71
  end
61
72
  end
62
73
  end
@@ -5,7 +5,7 @@ module OrderQuery
5
5
  # Search around a record in an order space
6
6
  class Point
7
7
  attr_reader :record, :space
8
- delegate :scope, :reverse_scope, to: :space
8
+ delegate :first, :last, :count, to: :space
9
9
 
10
10
  # @param [ActiveRecord::Base] record
11
11
  # @param [OrderQuery::Space] space
@@ -15,26 +15,6 @@ module OrderQuery
15
15
  @where_sql = SQL::Where.new(self)
16
16
  end
17
17
 
18
- # @return [ActiveRecord::Base]
19
- def first
20
- scope.first
21
- end
22
-
23
- # @return [ActiveRecord::Base]
24
- def last
25
- reverse_scope.first
26
- end
27
-
28
- # @return [Integer]
29
- def count
30
- @total ||= scope.count
31
- end
32
-
33
- # @return [Integer]
34
- def position
35
- count - after.count
36
- end
37
-
38
18
  # @params [true, false] loop if true, consider last and first as adjacent (unless they are equal)
39
19
  # @return [ActiveRecord::Base]
40
20
  def next(loop = true)
@@ -46,6 +26,11 @@ module OrderQuery
46
26
  unless_record_eq before.first || (last if loop)
47
27
  end
48
28
 
29
+ # @return [Integer]
30
+ def position
31
+ space.count - after.count
32
+ end
33
+
49
34
  # @return [ActiveRecord::Relation]
50
35
  def after
51
36
  side :after
@@ -63,7 +48,7 @@ module OrderQuery
63
48
  scope = if side == :after
64
49
  space.scope
65
50
  else
66
- space.reverse_scope
51
+ space.scope_reverse
67
52
  end
68
53
  if query.present?
69
54
  scope.where(query, *query_args)
@@ -76,6 +61,10 @@ module OrderQuery
76
61
  record.send(cond.name)
77
62
  end
78
63
 
64
+ def inspect
65
+ "#<OrderQuery::Point @record=#{@record.inspect} @space=#{@space.inspect}>"
66
+ end
67
+
79
68
  protected
80
69
 
81
70
  # @param [ActiveRecord::Base] rec
@@ -3,28 +3,46 @@ require 'order_query/sql/order_by'
3
3
  module OrderQuery
4
4
  # Order specification and a scope
5
5
  class Space
6
- # @return [Array<Condition>]
6
+ # @return [Array<OrderQuery::Condition>]
7
7
  attr_reader :conditions
8
- # @return [ActiveRecord::Relation]
9
- attr_reader :base_scope
10
8
 
11
- # @param [ActiveRecord::Relation] scope
12
- # @param [Array<Array<Symbol,String>>] order_spec
9
+ # @param [ActiveRecord::Relation] base_scope
10
+ # @param [Array<Array<Symbol,String>>, OrderQuery::Spec] order_spec
13
11
  def initialize(base_scope, order_spec)
14
12
  @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)
13
+ @conditions = order_spec.map { |cond_spec| Condition.new(cond_spec, base_scope) }
14
+ @order_by_sql = SQL::OrderBy.new(@conditions)
15
+ end
16
+
17
+ # @return [Point]
18
+ def at(record)
19
+ Point.new(record, self)
18
20
  end
19
21
 
20
22
  # @return [ActiveRecord::Relation] scope ordered by conditions
21
23
  def scope
22
- @base_scope.order(@order_by_sql.build)
24
+ @scope ||= @base_scope.order(@order_by_sql.build)
23
25
  end
24
26
 
25
27
  # @return [ActiveRecord::Relation] scope ordered by conditions in reverse
26
- def reverse_scope
27
- @base_scope.order(@order_by_sql.build_reverse)
28
+ def scope_reverse
29
+ @scope_reverse ||= @base_scope.order(@order_by_sql.build_reverse)
30
+ end
31
+
32
+ # @return [ActiveRecord::Base]
33
+ def first
34
+ scope.first
35
+ end
36
+
37
+ # @return [ActiveRecord::Base]
38
+ def last
39
+ scope_reverse.first
40
+ end
41
+
42
+ delegate :count, :empty?, to: :@base_scope
43
+
44
+ def inspect
45
+ "#<OrderQuery::Space @conditions=#{@conditions.inspect} @base_scope=#{@base_scope.inspect}>"
28
46
  end
29
47
  end
30
48
  end
@@ -12,8 +12,7 @@ module OrderQuery
12
12
  @column_name ||= begin
13
13
  sql = condition.options[:sql]
14
14
  if sql
15
- sql = sql.call if sql.respond_to?(:call)
16
- sql
15
+ sql.respond_to?(:call) ? sql.call : sql
17
16
  else
18
17
  connection.quote_table_name(scope.table_name) + '.' + connection.quote_column_name(condition.name)
19
18
  end
@@ -1,35 +1,33 @@
1
1
  module OrderQuery
2
2
  module SQL
3
3
  class OrderBy
4
- attr_reader :space
5
-
6
4
  # @param [Array<Condition>]
7
- def initialize(space)
8
- @space = space
5
+ def initialize(conditions)
6
+ @conditions = conditions
9
7
  end
10
8
 
11
9
  # @return [String]
12
10
  def build
13
- join_order_by_clauses order_by_sql_clauses
11
+ @sql ||= join_order_by_clauses order_by_sql_clauses
14
12
  end
15
13
 
16
14
  # @return [String]
17
15
  def build_reverse
18
- join_order_by_clauses order_by_reverse_sql_clauses
16
+ @reverse_sql ||= join_order_by_clauses order_by_reverse_sql_clauses
19
17
  end
20
18
 
21
19
  protected
22
20
 
23
21
  # @return [Array<String>]
24
22
  def order_by_sql_clauses
25
- space.conditions.map { |cond| condition_clause cond }
23
+ @conditions.map { |cond| condition_clause cond }
26
24
  end
27
25
 
28
26
  def condition_clause(cond)
29
27
  dir_sql = sort_direction_sql cond.order
30
- col_sql = cond.sql.column_name
28
+ col_sql = cond.column_name
31
29
  if cond.order_enum
32
- cond.order_enum.map { |v| "#{col_sql}=#{cond.sql.quote v} #{dir_sql}" }.join(', ').freeze
30
+ cond.order_enum.map { |v| "#{col_sql}=#{cond.quote v} #{dir_sql}" }.join(', ').freeze
33
31
  else
34
32
  "#{col_sql} #{dir_sql}".freeze
35
33
  end
@@ -7,7 +7,8 @@ module OrderQuery
7
7
 
8
8
  # @param [OrderQuery::Point] point
9
9
  def initialize(point)
10
- @point = point
10
+ @point = point
11
+ @conditions = point.space.conditions
11
12
  end
12
13
 
13
14
  # Join condition pairs with OR, and nest within each other with AND
@@ -20,7 +21,7 @@ module OrderQuery
20
21
  # ... ))
21
22
  def build(side)
22
23
  # generate pairs of terms such as sales < 5, sales = 5
23
- parts = point.space.conditions.map { |cond|
24
+ parts = @conditions.map { |cond|
24
25
  [where_side(cond, side, true), where_tie(cond)].reject { |x| x == WHERE_IDENTITY }
25
26
  }
26
27
  # group pairwise with OR, and nest with AND
@@ -66,7 +67,7 @@ module OrderQuery
66
67
  top_pair_idx = pairs.index(&:present?)
67
68
  if top_pair_idx &&
68
69
  (top_pair = pairs[top_pair_idx]).length == 2 &&
69
- top_pair.first != (redundant_cond = where_side(point.space.conditions[top_pair_idx], side, false))
70
+ top_pair.first != (redundant_cond = where_side(@conditions[top_pair_idx], side, false))
70
71
  join_terms 'AND'.freeze, redundant_cond, wrap_term_with_parens(query)
71
72
  else
72
73
  query
@@ -104,19 +105,19 @@ module OrderQuery
104
105
  when 1
105
106
  where_eq cond, values[0]
106
107
  else
107
- ["#{cond.sql.column_name} IN (?)".freeze, [values]]
108
+ ["#{cond.column_name} IN (?)".freeze, [values]]
108
109
  end
109
110
  end
110
111
 
111
112
  def where_eq(cond, value = point.value(cond))
112
- [%Q(#{cond.sql.column_name} = ?).freeze, [value]]
113
+ [%Q(#{cond.column_name} = ?).freeze, [value]]
113
114
  end
114
115
 
115
116
  def where_ray(cond, from, mode, strict = true)
116
117
  ops = %w(< >)
117
118
  ops = ops.reverse if mode == :after
118
119
  op = {asc: ops[0], desc: ops[1]}[cond.order || :asc]
119
- ["#{cond.sql.column_name} #{op}#{'=' unless strict} ?".freeze, [from]]
120
+ ["#{cond.column_name} #{op}#{'=' unless strict} ?".freeze, [from]]
120
121
  end
121
122
 
122
123
  WHERE_IDENTITY = [''.freeze, [].freeze].freeze
@@ -1,3 +1,3 @@
1
1
  module OrderQuery
2
- VERSION = '0.2.1'
2
+ VERSION = '0.3.0'
3
3
  end
data/lib/order_query.rb CHANGED
@@ -6,20 +6,28 @@ require 'order_query/point'
6
6
  module OrderQuery
7
7
  extend ActiveSupport::Concern
8
8
 
9
- # @param [ActiveRecord::Relation] scope
10
- # @param [Array<Array<Symbol,String>>] order_spec
11
- def order_by(scope = nil, order_spec)
9
+ # @param [ActiveRecord::Relation] scope optional first argument (default: self.class.all)
10
+ # @param [Array<Array<Symbol,String>>, OrderQuery::Spec] order_spec
11
+ # @return [OrderQuery::Point]
12
+ # @example
13
+ # users = User.active
14
+ # user = users.find(42)
15
+ # next_user = user.seek(users, [:activated_at, :desc], [:id, :desc]).next
16
+ def seek(*spec)
17
+ fst = spec.first
18
+ if fst.nil? || fst.is_a?(ActiveRecord::Relation) || fst.is_a?(ActiveRecord::Base)
19
+ scope = spec.shift
20
+ end
12
21
  scope ||= self.class.all
13
- Point.new(self, Space.new(scope, order_spec))
22
+ scope.seek(*spec).at(self)
14
23
  end
15
24
 
16
25
  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
26
+ # @return [OrderQuery::Space]
27
+ def seek(*spec)
28
+ # allow passing without a splat, as we can easily distinguish
29
+ spec = spec.first if spec.length == 1 && spec.first.first.is_a?(Array)
30
+ Space.new(all, spec)
23
31
  end
24
32
 
25
33
  #= DSL
@@ -27,18 +35,46 @@ module OrderQuery
27
35
  # @param [Symbol] name
28
36
  # @param [Array<Array<Symbol,String>>] order_spec
29
37
  # @example
30
- # class Issue
31
- # order_query :order_display, [[:created_at, :desc], [:id, :desc]]
38
+ # class Post < ActiveRecord::Base
39
+ # include OrderQuery
40
+ # order_query :order_home,
41
+ # [:pinned, [true, false]]
42
+ # [:published_at, :desc],
43
+ # [:id, :desc]
32
44
  # end
33
45
  #
34
- # Issue.order_display #=> <ActiveRecord::Relation#...>
35
- # Issue.active.find(31).display_order(Issue.active).next #=> <Issue#...>
36
- def order_query(name, order_spec)
37
- scope name, -> { order_by(order_spec) }
38
- scope :"reverse_#{name}", -> { reverse_order_by(order_spec) }
39
- define_method name do |scope = nil|
40
- order_by scope, order_spec
41
- end
46
+ #== Scopes
47
+ # .order_home
48
+ # #<ActiveRecord::Relation...>
49
+ # .order_home_reverse
50
+ # #<ActiveRecord::Relation...>
51
+ #
52
+ #== Class methods
53
+ # .order_home_at(post)
54
+ # #<OrderQuery::Point...>
55
+ # .order_home_space
56
+ # #<OrderQuery::Space...>
57
+ #
58
+ #== Instance methods
59
+ # .order_home(scope)
60
+ # #<OrderQuery::Point...>
61
+ def order_query(name, *spec)
62
+ space_method = :"#{name}_space"
63
+ define_singleton_method space_method, -> {
64
+ seek(*spec)
65
+ }
66
+ scope name, -> {
67
+ send(space_method).scope
68
+ }
69
+ scope :"#{name}_reverse", -> {
70
+ send(space_method).scope_reverse
71
+ }
72
+ define_singleton_method "#{name}_at", -> (record) {
73
+ send(space_method).at(record)
74
+ }
75
+ define_method(name) { |scope = nil|
76
+ (scope || self.class).send(space_method).at(self)
77
+ }
42
78
  end
43
79
  end
44
80
 
@@ -8,11 +8,10 @@ end
8
8
  # Simple model
9
9
  class Post < ActiveRecord::Base
10
10
  include OrderQuery
11
- order_query :order_list, [
12
- [:pinned, [true, false], complete: true],
13
- [:published_at, :desc],
14
- [:id, :desc]
15
- ]
11
+ order_query :order_list,
12
+ [:pinned, [true, false], complete: true],
13
+ [:published_at, :desc],
14
+ [:id, :desc]
16
15
  end
17
16
 
18
17
  def create_post(attr = {})
@@ -94,7 +93,7 @@ describe 'OrderQuery' do
94
93
  cur ||= issues.first
95
94
  expect(prev.display_order.next).to eq(cur)
96
95
  expect(cur.display_order.previous).to eq(prev)
97
- expect(cur.display_order.scope.count).to eq(Issue.count)
96
+ expect(cur.display_order.space.count).to eq(Issue.count)
98
97
  expect(cur.display_order.before.count + 1 + cur.display_order.after.count).to eq(cur.display_order.count)
99
98
 
100
99
  expect(cur.display_order.before.to_a.reverse + [cur] + cur.display_order.after.to_a).to eq(Issue.display_order.to_a)
@@ -118,21 +117,54 @@ describe 'OrderQuery' do
118
117
  expect(Issue.id_order_asc.count).to eq(2)
119
118
  end
120
119
 
121
- it '.order_by works on a list of ids' do
122
- ids = (1..3).map { create_issue.id }
123
- expect(Issue.order_by([[:id, ids]]).size).to eq ids.length
120
+ it '.seek works on a list of ids' do
121
+ ids = 3.times.map { create_issue.id }
122
+ expect(Issue.seek([[:id, ids]]).scope.count).to eq ids.length
123
+ expect(Issue.seek([:id, ids]).scope.count).to eq ids.length
124
124
  end
125
125
 
126
- it '.order_by preserves previous' do
127
- create_issue(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
126
+ context 'partitioned on a boolean flag' do
127
+ before do
128
+ create_issue(active: true)
129
+ create_issue(active: false)
130
+ create_issue(active: true)
131
+ end
132
+
133
+ let!(:order) { [[:id, :desc]] }
134
+ let!(:active) { Issue.where(active: true).seek(order) }
135
+ let!(:inactive) { Issue.where(active: false).seek(order) }
136
+
137
+ it '.seek preserves scope' do
138
+ expect(inactive.scope.count).to eq 1
139
+ expect(inactive.count).to eq 1
140
+ expect(active.count).to eq 2
141
+ expect(active.scope.count).to eq 2
142
+ end
143
+
144
+ it 'gives a valid result if at argument is outside of the space' do
145
+ expect(inactive.at(active.first).next).to_not be_active
146
+ expect(inactive.at(active.last).next).to_not be_active
147
+ expect(active.at(inactive.first).next).to be_active
148
+ expect(active.at(inactive.last).next).to be_active
149
+ end
150
+
151
+ it 'next/previous(false)' do
152
+ expect(active.at(active.first).next(false)).to_not be_nil
153
+ expect(active.at(active.last).next(false)).to be_nil
154
+ expect(inactive.at(inactive.first).previous(false)).to be_nil
155
+ # there is only one, so previous(last) is also nil
156
+ expect(inactive.at(inactive.last).previous(false)).to be_nil
157
+ end
158
+
159
+ it 'previous(true) with only 1 record' do
160
+ expect(inactive.at(inactive.last).previous(true)).to be_nil
161
+ end
130
162
  end
131
163
 
132
- it '#order_by falls back to scope when order condition is missing self' do
164
+ it '#seek falls back to scope when order condition is missing self' do
133
165
  a = create_issue(priority: 'medium')
134
166
  b = create_issue(priority: 'high')
135
- expect(a.order_by(Issue.display_order, [[:priority, ['wontfix', 'askbob']], [:id, :desc]]).next).to eq(b)
167
+ expect(a.seek(Issue.display_order, [[:priority, ['wontfix', 'askbob']], [:id, :desc]]).next).to eq(b)
136
168
  end
137
169
 
138
170
  before do
@@ -149,7 +181,7 @@ describe 'OrderQuery' do
149
181
  t.column :suspicious_votes, :integer
150
182
  t.column :announced_at, :datetime
151
183
  t.column :updated_at, :datetime
152
- t.column :active, :boolen, null: false, default: true
184
+ t.column :active, :boolean, null: false, default: true
153
185
  end
154
186
  end
155
187
 
@@ -197,7 +229,7 @@ describe 'OrderQuery' do
197
229
  context 'wrap top-level OR on' do
198
230
  wrap_top_level_or true
199
231
  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
232
+ after_scope = User.create!(updated_at: Date.parse('2014-09-06')).seek([[:updated_at, :desc], [:id, :desc]]).after
201
233
  expect(after_scope.to_sql).to include('<=')
202
234
  end
203
235
  end
@@ -205,7 +237,7 @@ describe 'OrderQuery' do
205
237
  context 'wrap top-level OR off' do
206
238
  wrap_top_level_or false
207
239
  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
240
+ after_scope = User.create!(updated_at: Date.parse('2014-09-06')).seek([[:updated_at, :desc], [:id, :desc]]).after
209
241
  expect(after_scope.to_sql).to_not include('<=')
210
242
  end
211
243
  end
data/spec/spec_helper.rb CHANGED
@@ -1,13 +1,9 @@
1
1
  # -*- encoding : utf-8 -*-
2
2
  # Configure Rails Environment
3
3
  ENV['RAILS_ENV'] = ENV['RACK_ENV'] = 'test'
4
- unless defined?(RUBY_ENGINE) && RUBY_ENGINE == 'rbx'
5
- begin
6
- require 'coveralls'
7
- Coveralls.wear!
8
- rescue LoadError
9
- false
10
- end
4
+ if ENV['TRAVIS'] && !(defined?(RUBY_ENGINE) && RUBY_ENGINE == 'rbx')
5
+ require 'codeclimate-test-reporter'
6
+ CodeClimate::TestReporter.start
11
7
  end
12
8
  require 'order_query'
13
9
 
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.2.1
4
+ version: 0.3.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-06 00:00:00.000000000 Z
11
+ date: 2014-09-07 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activerecord