Skip to content

Commit

Permalink
added countless sub-class and extra code
Browse files Browse the repository at this point in the history
  • Loading branch information
ddnexus committed Nov 19, 2018
1 parent 37cf51a commit 7b25165
Show file tree
Hide file tree
Showing 9 changed files with 187 additions and 2 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Use the official extras, or write your own in just a few lines. Extras add speci
### Backend Extras

- [array](http://ddnexus.github.io/pagy/extras/array): Paginate arrays efficiently, avoiding expensive array-wrapping and without overriding
- [countless](http://ddnexus.github.io/pagy/extras/countless): Paginate without the need of any count, saving one query per rendering
- [elasticsearch_rails](http://ddnexus.github.io/pagy/extras/elasticsearch_rails): Paginate `ElasticsearchRails::Results` objects efficiently, avoiding expensive object-wrapping and without overriding
- [searchkick](http://ddnexus.github.io/pagy/extras/searchkick): Paginate `Searchkick::Results` objects efficiently, avoiding expensive object-wrapping and without overriding

Expand Down
2 changes: 2 additions & 0 deletions docs/_layouts/default.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,12 @@ <h1 id="site-title">{{ site.title | default: site.github.repository_name }} <a c
<a href="{{ site.baseurl }}/api/pagy"><p class="indent1" {% if page.title == 'Pagy' %}id="active"{% endif %} >Pagy</p></a>
<a href="{{ site.baseurl }}/api/backend"><p class="indent1" {% if page.title == 'Pagy::Backend' %}id="active"{% endif %} >Pagy::Backend</p></a>
<a href="{{ site.baseurl }}/api/frontend"><p class="indent1" {% if page.title == 'Pagy::Frontend' %}id="active"{% endif %} >Pagy::Frontend</p></a>
<a href="{{ site.baseurl }}/api/countless"><p class="indent1" {% if page.title == 'Pagy::Countless' %}id="active"{% endif %} >Pagy::Countless</p></a>
<a href="{{ site.baseurl }}/extras"><p {% if page.title == 'Extras' %}id="active"{% endif %} >Extras</p></a>
<a href="{{ site.baseurl }}/extras/array"><p class="indent1" {% if page.title == 'Array' %}id="active"{% endif %} >Array</p></a>
<a href="{{ site.baseurl }}/extras/bootstrap"><p class="indent1" {% if page.title == 'Bootstrap' %}id="active"{% endif %} >Bootstrap</p></a>
<a href="{{ site.baseurl }}/extras/bulma"><p class="indent1" {% if page.title == 'Bulma' %}id="active"{% endif %} >Bulma</p></a>
<a href="{{ site.baseurl }}/extras/countless"><p class="indent1" {% if page.title == 'countless' %}id="active"{% endif %} >Countless</p></a>
<a href="{{ site.baseurl }}/extras/elasticsearch_rails"><p class="indent1" {% if page.title == 'Elasticsearch Rails' %}id="active"{% endif %} >Elasticsearch Rails</p></a>
<a href="{{ site.baseurl }}/extras/foundation"><p class="indent1" {% if page.title == 'Foundation' %}id="active"{% endif %} >Foundation</p></a>
<a href="{{ site.baseurl }}/extras/i18n"><p class="indent1" {% if page.title == 'I18n' %}id="active"{% endif %} >I18n</p></a>
Expand Down
55 changes: 55 additions & 0 deletions docs/api/countless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
---
title: Pagy::Countless
---
# Pagy::Countless

This is a `Pagy` [subclass](https://github.com/ddnexus/pagy/blob/master/lib/pagy/countless.rb) that provides pagination without the need of any `:count`. That may be especially useful for slow `COUNT(*)` query - result of large tables or poorly optimized DBs - or whenever you don't need the full set of pagination features.

This class is providing support for extras that don't need the full set of pagination support or need to avoid the `:count` variable (e.g. the [countless extra](../extras/countless.md)). You should not need to use it directly because it is required and used internally.

## Caveats

In this class the `:count` variable is always `nil`, hence some feature that depends on it can have limited or no support:

### Features with limited support

#### :size variable and series method

A couple if items of the `:size` array have some limitation. Regardless the actual `:size` value:

- `vars[:size][2]` is capped at 1
- `vars[:size][3]` is set to 0

A few examples:

- `[1,4,4,1]` would be treated like `[ 1,4,1,0]`
- `[1,4,3,4]` would be treated like `[ 1,4,1,0]`
- `[1,4,0,0]` would be treated like `[ 1,4,0,0]`

The `series` method reflects on the above.

#### :overflow variable

The available values for the `:overflow` variable are `:empty_page` and `:exception`, missing `:last_page`

### Features witout support

The `pagy_info` and all the `pagy_nav_compact*` helpers are not supported.

## How countless pagination works

Instead of basing all the internal calculations on the `:count` variable (passed with the constructor), this class uses the number of actually retrieved items for the page (passed in a second step with the `finalize` method), in order to deduce if there is a `next` page, or if the current page is the `last` page, or if the current request should raise a `Pagy::OverflowError` exception.

The trick is retrieving `items + 1`, and using the resulting number to calculate the variables, while eventually removing the extra item from the result. (see the [countless.rb extra](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/countless.rb))

## Methods

The construction of the `Pagy::Countless` object is splitted into 2 steps: the regular `initialize` method and the `finalize` method, which will use the retrieved items number to calculate the rest of the pagination integers.

### Pagy::Countless.new(vars)

The initial constructor takes the usual hash of variables, calculating only the requested `items` and the `offset`, useful to query the page of items.

### finalize(items)

The actual calculation of all the internal variables for the pagination is calculated using the `items` number argument. The method returns the finalized instance object.
1 change: 1 addition & 0 deletions docs/extras.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Pagy comes with a few optional extensions/extras:
| `array` | Paginate arrays efficiently avoiding expensive array-wrapping and without overriding | [array.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/array.rb), [documentation](extras/array.md) |
| `bootstrap` | Nav, responsive and compact helpers and templates for the Bootstrap [pagination component](https://getbootstrap.com/docs/4.1/components/pagination) | [bootstrap.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/bootstrap.rb), [documentation](extras/bootstrap.md) |
| `bulma` | Nav, responsive and compact helpers and templates for the Bulma [pagination component](https://bulma.io/documentation/components/pagination) | [bulma.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/bulma.rb), [documentation](extras/bulma.md) |
| `countless` | Paginate without any count, saving one query per rendering | [countless.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/countless.rb), [documentation](extras/countless.md) |
| `elasticsearch_rails` | Paginate `elasticsearch_rails` gem results efficiently | [elasticsearch_rails.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/elasticsearch_rails.rb), [documentation](extras/elasticsearch_rails.md) |
| `foundation` | Nav, responsive and compact helpers and templates for the Foundation [pagination component](https://foundation.zurb.com/sites/docs/pagination.html) | [foundation.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/foundation.rb), [documentation](extras/foundation.md) |
| `i18n` | Use the `I18n` gem instead of the pagy implementation | [i18n.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/i81n.rb), [documentation](extras/i18n.md) |
Expand Down
49 changes: 49 additions & 0 deletions docs/extras/countless.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
title: Countless
---
# Countless Extra

This extra uses the `Pagy::Countless` subclass in order to avoid to execute an otherwise needed count query. It is especially useful when used with large DB tables, where [Caching the count](../how-to.md#caching-the-count) may not be an option.

Its usage is practically the same as the regular `Pagy::Backend` module (see the [backend doc](../api/backend.md).

The pagination resulting from this extra has some limitation as documented in the [Pagy::Countless Caveats doc](../api/countless.md#caveats).

## Synopsys

See [extras](../extras.md) for general usage info.

In the `pagy.rb` initializer:

```ruby
require 'pagy/extras/countless'
```

In a controller:

```ruby
@pagy, @records = pagy_countless(some_scope, ...)
```

## Files

This extra is composed of 1 file:

- [countless.rb](https://github.com/ddnexus/pagy/blob/master/lib/pagy/extras/countless.rb)

## Methods

All the methods in this module are prefixed with the `"pagy_countless"` string, to avoid any possible conflict with your own methods when you include the module in your controller. They are also all private, so they will not be available as actions. The methods prefixed with the `"pagy_countless_get_"` string are sub-methods/getter methods that are intended to be overridden, not used directly.

### pagy_countless(collection, vars=nil)

This method is the same as the generic `pagy` method. (see the [pagy doc](../api/backend.md#pagycollection-varsnil))

### pagy_countless_get_vars(_collection, vars)

This sub-method is similar to the `pagy_get_vars` sub-method, but it is called only by the `pagy_countless` method. (see the [pagy_get_vars doc](../api/backend.md#pagy_get_varscollection-vars)).

### pagy_countless_get_items(collection, pagy)

This sub-method is the same as the `pagy_get_items` sub-method, but it is called only by the `pagy_countless` method. (see the [pagy_get_items doc](../api/backend.md#pagy_get_itemscollection-pagy)).

12 changes: 10 additions & 2 deletions docs/how-to.md
Original file line number Diff line number Diff line change
Expand Up @@ -358,12 +358,16 @@ If you need to try/compare an unmodified built-in template, you can render it ri

You may want to read also the [Pagy::Frontend API documentation](api/frontend.md) for complete control over your templates.

## Caching the collection count
## Dealing with a slow collection COUNT(*)

Every pagination gem needs the collection count in order to calculate all the other variables involved in the pagination. If you use a storage system like any SQL DB, there is no way to paginate and provide a full nav system without executing an extra query to get the collection count. That is usually not a problem if your DB is well organized and maintained, but that may not be always the case.
Every pagination gem needs the collection count in order to calculate _all_ the other variables involved in the pagination. If you use a storage system like any SQL DB, there is no way to paginate and provide a full nav system without executing an extra query to get the collection count. That is usually not a problem if your DB is well organized and maintained, but that may not be always the case.

Sometimes you may have to deal with some not very efficient legacy apps/DBs that you cannot totally control. In that case the extra count query may affect the performance of the app quite badly.

You have 2 possible solutions in order to improve the performance.

### Caching the count

Depending on the nature of the app, a possible cheap solution would be caching the count of the collection, and Pagy makes that really simple.

Pagy gets the collection count through its `pagy_get_vars` method, so you can override it in your controller. Here is an example using the rails cache:
Expand All @@ -390,6 +394,10 @@ after_destroy { Rails.cache.delete_matched /^pagy-#{self.class.name}:/}

That may work very well with static (or almost static) DBs, where there is not much writing and mostly reading. Less so with more DB writing, and probably not particularly useful with a DB in constant change.

### Avoiding the count

When the count caching is not an option, you may want to use the which totally avoid the need for a count query, still providing an acceptable subset of the full pagination features.

## Adding HTTP headers

The HTTP pagination headers may be useful for APIs, but they are currently out of scope for Pagy. However there are a couple of gems that support Pagy and do that for you in a quite automatic way.
Expand Down
4 changes: 4 additions & 0 deletions lib/config/pagy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
# See https://ddnexus.github.io/pagy/extras/array
# require 'pagy/extras/array'

# Countless: Paginate without any count, saving one query per rendering
# See https://ddnexus.github.io/pagy/extras/countless
# require 'pagy/extras/countless'

# Elasticsearch Rails: Paginate `ElasticsearchRails::Results` objects efficiently, avoiding expensive object-wrapping and without overriding.
# See https://ddnexus.github.io/pagy/extras/elasticsearch_rails
# require 'pagy/extras/elasticsearch_rails'
Expand Down
30 changes: 30 additions & 0 deletions lib/pagy/countless.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
require 'pagy'

class Pagy

class Countless < Pagy

# Merge and validate the options, do some simple aritmetic and set a few instance variables
def initialize(vars={})
@vars ||= VARS.merge(vars.delete_if{|_,v| v.nil? || v == '' }) # default vars + cleaned vars (can be ovverridden)
{ items:1, outset:0, page:1 }.each do |k,min| # validate instance variables
(@vars[k] && instance_variable_set(:"@#{k}", @vars[k].to_i) >= min) \
or raise(ArgumentError, "expected :#{k} >= #{min}; got #{@vars[k].inspect}")
end
@offset = @items * (@page - 1) + @outset # pagination offset + outset (initial offset)
end

# Finalize the instance variables based on the retrieved items
def finalize(items)
items == 0 && @page > 1 and raise(OverflowError.new(self), "page #{@page} got no items")
@pages = @last = (items > @items ? @page + 1 : @page) # set the @pages and @last
@items = items if items < @items && items > 0 # adjust items for last non-empty page
@from = items == 0 ? 0 : @offset+1 - @outset # page begins from item
@to = items == 0 ? 0 : @offset + @items - @outset # page ends to item
@prev = (@page-1 unless @page == 1) # nil if no prev page
@next = (@page+1 unless @page == @last) # nil if no next page
self
end

end
end
35 changes: 35 additions & 0 deletions lib/pagy/extras/countless.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# See the Pagy documentation: https://ddnexus.github.io/pagy/extras/countless

require 'pagy/countless'

class Pagy

# used by the items extra
COUNTLESS = true

module Backend ; private # the whole module is private so no problem with including it in a controller

# Return Pagy object and items
def pagy_countless(collection, vars={})
pagy = Pagy::Countless.new(pagy_countless_get_vars(collection, vars))
return pagy, pagy_countless_get_items(collection, pagy)
end

# Sub-method called only by #pagy_countless: here for easy customization of variables by overriding
def pagy_countless_get_vars(_collection, vars)
# Return the merged variables to initialize the Pagy object
{ page: params[vars[:page_param]||VARS[:page_param]] }.merge!(vars)
end

# Sub-method called only by #pagy_countless: here for easy customization of record-extraction by overriding
def pagy_countless_get_items(collection, pagy)
# This should work with ActiveRecord, Sequel, Mongoid...
items = collection.offset(pagy.offset).limit(pagy.items + 1).to_a
items_size = items.size
items.pop if items_size == pagy.items + 1
pagy.finalize(items_size) # finalize may adjust pagy.items, so must be used after checking the size
items
end

end
end

0 comments on commit 7b25165

Please sign in to comment.