├── .formatter.exs ├── .github ├── dependabot.yml └── workflows │ ├── static-analysis.yml │ └── test.yml ├── .gitignore ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── paginator.ex └── paginator │ ├── config.ex │ ├── cursor.ex │ ├── ecto │ ├── query.ex │ └── query │ │ ├── asc_nulls_first.ex │ │ ├── asc_nulls_last.ex │ │ ├── desc_nulls_first.ex │ │ ├── desc_nulls_last.ex │ │ ├── dynamic_filter_builder.ex │ │ └── field_or_expression.ex │ ├── page.ex │ └── page │ └── metadata.ex ├── mix.exs ├── mix.lock └── test ├── paginator ├── config_test.exs └── cursor_test.exs ├── paginator_test.exs ├── support ├── address.ex ├── customer.ex ├── data_case.ex ├── factory.ex ├── payment.ex ├── repo.ex └── test_migration.ex └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"], 4 | import_deps: [:ecto], 5 | local_without_parens: [] 6 | ] 7 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: github-actions 4 | directory: "/" 5 | schedule: 6 | interval: daily 7 | time: "09:00" 8 | open-pull-requests-limit: 10 9 | 10 | - package-ecosystem: mix 11 | directory: "/" 12 | schedule: 13 | interval: daily 14 | time: "09:00" 15 | open-pull-requests-limit: 10 16 | -------------------------------------------------------------------------------- /.github/workflows/static-analysis.yml: -------------------------------------------------------------------------------- 1 | name: Static Analysis 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | check_duplicate_runs: 11 | name: Check for duplicate runs 12 | continue-on-error: true 13 | runs-on: ubuntu-20.04 14 | outputs: 15 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@v3.4.0 19 | with: 20 | concurrent_skipping: always 21 | cancel_others: true 22 | skip_after_successful_duplicate: true 23 | paths_ignore: '["**/README.md", "**/CHANGELOG.md", "**/LICENSE.txt"]' 24 | do_not_skip: '["pull_request"]' 25 | 26 | dialyzer: 27 | name: Static code analysis 28 | runs-on: ubuntu-20.04 29 | 30 | strategy: 31 | matrix: 32 | elixir: 33 | - "1.11" 34 | otp: 35 | - "23.0" 36 | 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v2 40 | 41 | - name: Set up Elixir 42 | uses: erlef/setup-beam@v1 43 | with: 44 | elixir-version: ${{ matrix.elixir }} 45 | otp-version: ${{ matrix.otp }} 46 | 47 | - name: Restore deps cache 48 | uses: actions/cache@v2 49 | with: 50 | path: deps 51 | key: ${{ runner.os }}-mix-{{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 52 | 53 | - name: Restore _build cache 54 | uses: actions/cache@v2 55 | with: 56 | path: _build 57 | key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 58 | restore-keys: | 59 | ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 60 | ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }} 61 | 62 | - name: Install hex 63 | run: mix local.hex --force 64 | 65 | - name: Install rebar 66 | run: mix local.rebar --force 67 | 68 | - name: Install package dependencies 69 | run: mix deps.get 70 | 71 | - name: Compile package dependencies 72 | run: mix deps.compile 73 | 74 | - name: Restore Dialyzer cache 75 | uses: actions/cache@v2 76 | with: 77 | path: priv/plts 78 | key: ${{ runner.os }}-dialyzer-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 79 | restore-keys: | 80 | ${{ runner.os }}-dialyzer-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 81 | ${{ runner.os }}-dialyzer-${{ matrix.otp }}-${{ matrix.elixir }} 82 | 83 | - name: Run dialyzer 84 | run: mix dialyzer --format dialyxir 85 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | check_duplicate_runs: 11 | name: Check for duplicate runs 12 | continue-on-error: true 13 | runs-on: ubuntu-20.04 14 | outputs: 15 | should_skip: ${{ steps.skip_check.outputs.should_skip }} 16 | steps: 17 | - id: skip_check 18 | uses: fkirc/skip-duplicate-actions@v3.4.0 19 | with: 20 | concurrent_skipping: always 21 | cancel_others: true 22 | skip_after_successful_duplicate: true 23 | paths_ignore: '["**/README.md", "**/CHANGELOG.md", "**/LICENSE.txt"]' 24 | do_not_skip: '["pull_request"]' 25 | 26 | test: 27 | name: Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }} 28 | runs-on: ubuntu-20.04 29 | needs: check_duplicate_runs 30 | if: ${{ needs.check_duplicate_runs.outputs.should_skip != 'true' }} 31 | services: 32 | postgres: 33 | image: postgres 34 | ports: 35 | - 5432:5432 36 | env: 37 | POSTGRES_DB: paginator_test 38 | POSTGRES_PASSWORD: postgres 39 | 40 | strategy: 41 | matrix: 42 | elixir: 43 | - "1.7" 44 | - "1.8" 45 | - "1.9" 46 | - "1.10" 47 | - "1.11" 48 | otp: 49 | - "20.0" 50 | - "21.0" 51 | - "22.0.2" 52 | - "23.0" 53 | exclude: 54 | - elixir: "1.10" 55 | otp: "20.0" 56 | - elixir: "1.11" 57 | otp: "20.0" 58 | - elixir: "1.9" 59 | otp: "23.0" 60 | - elixir: "1.7" 61 | otp: "23.0" 62 | - elixir: "1.8" 63 | otp: "23.0" 64 | 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v2 68 | 69 | - name: Set up Elixir 70 | uses: erlef/setup-beam@v1 71 | with: 72 | elixir-version: ${{ matrix.elixir }} 73 | otp-version: ${{ matrix.otp }} 74 | 75 | - name: Restore build and deps caches 76 | uses: actions/cache@v2 77 | with: 78 | path: | 79 | deps 80 | _build 81 | key: ${{ runner.os }}-deps-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 82 | restore-keys: | 83 | ${{ runner.os }}-deps-${{ matrix.otp }}-${{ matrix.elixir }} 84 | 85 | - name: Install package dependencies 86 | run: mix deps.get 87 | 88 | - name: Remove compiled application files 89 | run: mix clean 90 | 91 | - name: Compile dependencies 92 | run: mix compile 93 | env: 94 | MIX_ENV: test 95 | 96 | - name: Run unit tests 97 | run: mix test 98 | 99 | inch: 100 | name: Analyse Documentation 101 | runs-on: ubuntu-20.04 102 | needs: test 103 | 104 | strategy: 105 | matrix: 106 | elixir: 107 | - "1.11" 108 | otp: 109 | - "23.0" 110 | 111 | steps: 112 | - name: Checkout 113 | uses: actions/checkout@v2 114 | 115 | - name: Set up Elixir 116 | uses: erlef/setup-beam@v1 117 | with: 118 | elixir-version: ${{ matrix.elixir }} 119 | otp-version: ${{ matrix.otp }} 120 | 121 | - name: Restore build and deps caches 122 | uses: actions/cache@v2 123 | with: 124 | path: | 125 | deps 126 | _build 127 | key: ${{ runner.os }}-deps-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }} 128 | restore-keys: | 129 | ${{ runner.os }}-deps-${{ matrix.otp }}-${{ matrix.elixir }} 130 | 131 | - name: Install package dependencies 132 | run: mix deps.get 133 | 134 | - name: Remove compiled application files 135 | run: mix clean 136 | 137 | - name: Compile dependencies 138 | run: mix compile 139 | 140 | - name: Check documentation quality locally 141 | run: mix inch 142 | 143 | - name: Report documentation quality 144 | if: github.event_name == 'push' 145 | run: mix inch.report 146 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | paginator-*.tar 24 | 25 | # Temporary files for e.g. tests. 26 | /tmp 27 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## v1.2.0 - 2022-10-27 9 | * Make config raise specific domain specific error ([#176](https://github.com/duffelhq/paginator/pull/176)) 10 | * Update package dependencies 11 | * `ex_doc` 0.28.2 12 | 13 | ## v1.1.0 - 2022-02-08 14 | 15 | * Skip duplicated GitHub Actions runs: ([#110](https://github.com/duffelhq/paginator/pull/110), thanks! @dolfinus) 16 | * Fix typespec for `cursor_for_record()`: ([#114](https://github.com/duffelhq/paginator/pull/114), thanks! @kaylenmistry) 17 | * Project badges and GitHub Actions updates, thanks! @sgerrand: 18 | * ([#116](https://github.com/duffelhq/paginator/pull/116)) 19 | * ([#121](https://github.com/duffelhq/paginator/pull/121)) 20 | * ([#128](https://github.com/duffelhq/paginator/pull/128)) 21 | * ([#144](https://github.com/duffelhq/paginator/pull/128)) 22 | * Updates to project documentation: ([#122](https://github.com/duffelhq/paginator/pull/122), thanks! @ikianmeng) 23 | * Fix example for joined fields: ([#123](https://github.com/duffelhq/paginator/pull/123), thanks! @nickdichev) 24 | * Add support for sorting order combinations: ([#136](https://github.com/duffelhq/paginator/pull/136), thanks! @dgvncz0f) 25 | * Update package dependencies 26 | * `ex_doc` -> 0.28.0 27 | * `ecto` -> 3.6.2 28 | * `ecto_sql` -> 3.6.2 29 | * `plug_crypto` -> 1.2.2 30 | * `postgrex` -> 0.15.13 31 | 32 | ## v1.0.4 - 2021-03-15 33 | 34 | * Fix type errors, thanks! @djthread: 35 | * ([#96](https://github.com/duffelhq/paginator/pull/96)) 36 | * ([#98](https://github.com/duffelhq/paginator/pull/98)) 37 | * Fix tuples typo in documentation: ([#99](https://github.com/duffelhq/paginator/pull/99), thanks! @iamsekun) 38 | * Use GitHub Actions for continuous integration: ([#100](https://github.com/duffelhq/paginator/pull/100), thanks! @dolfinus) 39 | * Update package dependencies 40 | * `calendar` -> 1.0.0 41 | * `ecto` -> 3.0.9 42 | * `ex_machina` -> 2.7.0 43 | * `plug_crypto` -> 1.2.1 44 | * `postgrex` -> 0.14.3 45 | 46 | ## v1.0.3 - 2020-12-18 47 | 48 | * Fix cursor field validation bug ([#93](https://github.com/duffelhq/paginator/pull/93)) 49 | 50 | ## v1.0.2 - 2020-11-20 51 | 52 | * Update package dependencies 53 | * `inch` -> 2.0 54 | * `plug_crypto` -> 1.2.0 55 | * `ex_doc` -> 0.23.0 56 | 57 | ## v1.0.1 - 2020-08-18 58 | 59 | * Fix sorting bug in cursor query ([#73](https://github.com/duffelhq/paginator/pull/73)) 60 | 61 | ## v1.0.0 - 2020-08-17 62 | 63 | * Fix Remote Code Execution Vulnerability ([#69](https://github.com/duffelhq/paginator/pull/69) - Thank you @p-!) 64 | * Fix cursor mismatch bug ([#68]((https://github.com/duffelhq/paginator/pull/68)) 65 | 66 | ## v0.6.0 - 2018-11-20 67 | 68 | ### Changed 69 | 70 | * Add support for Ecto 3. Remove support for Ecto 2. 71 | ([#40](https://github.com/duffelhq/paginator/pull/40), thanks! @van-mronov) 72 | 73 | ## v0.5.0 - 2018-10-31 74 | 75 | ### Added 76 | 77 | * Expose the ability to generate cursors from records. 78 | ([#32](https://github.com/duffelhq/paginator/pull/32), thanks! @bernardd) 79 | 80 | ### Changed 81 | 82 | * Config is now created and checked in Paginator.paginate/4, making it easier to 83 | build your own pagination function in your Repo. 84 | 85 | ## v0.4.1 - 2018-10-23 86 | 87 | ### Fixed 88 | 89 | * Fix argument error when trying to use `nil` cursors. 90 | ([#24](https://github.com/duffelhq/paginator/pull/24), thanks! @0nkery) 91 | 92 | ## v0.4.0 - 2018-07-11 93 | 94 | ### Fixed 95 | 96 | * Fix potential DoS attack by using the `safe` option during decoding of cursors. 97 | ([#16](https://github.com/duffelhq/paginator/pull/16), thanks! @dbhobbs) 98 | 99 | ## v0.3.1 - 2018-02-20 100 | 101 | ### Fixed 102 | 103 | * Fix bug for queries with a pre-existing `where` clause. Sometimes, this clause 104 | ended up being combined with the pagination filter using an `OR`. 105 | 106 | ## v0.3.0 - 2018-02-14 107 | 108 | ### Added 109 | 110 | * `:limit` is now capped by `:maximum_limit`. By default, `:maximum_limit` is set 111 | to 500. 112 | 113 | ## v0.2.0 - 2018-02-13 114 | 115 | ### Added 116 | 117 | * `:total_count_limit` can be set to `:infinity` to return the accurate count of 118 | records. 119 | 120 | ## v0.1.0 - 2018-02-13 121 | 122 | Initial release! 🎉 123 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Steve Domin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Paginator 2 | 3 | [![Build status](https://github.com/duffelhq/paginator/actions/workflows/test.yml/badge.svg?branch=main)](https://github.com/duffelhq/paginator/actions?query=branch%3Amain) 4 | [![Module Version](https://img.shields.io/hexpm/v/paginator.svg)](https://hex.pm/packages/paginator) 5 | [![Hex Docs](https://img.shields.io/badge/hex-docs-lightgreen.svg)](https://hexdocs.pm/paginator/) 6 | [![Total Download](https://img.shields.io/hexpm/dt/paginator.svg)](https://hex.pm/packages/paginator) 7 | [![License](https://img.shields.io/hexpm/l/paginator.svg)](https://github.com/duffelhq/paginator/blob/main/LICENSE.md) 8 | [![Last Updated](https://img.shields.io/github/last-commit/duffelhq/paginator.svg)](https://github.com/duffelhq/paginator/commits/main) 9 | 10 | [Cursor based pagination](http://use-the-index-luke.com/no-offset) for Elixir [Ecto](https://github.com/elixir-ecto/ecto). 11 | 12 | ## Why? 13 | 14 | There are several ways to implement pagination in a project and they all have pros and cons depending on your situation. 15 | 16 | ### Limit-offset 17 | 18 | This is the easiest method to use and implement: you just have to set `LIMIT` and `OFFSET` on your queries and the 19 | database will return records based on this two parameters. Unfortunately, it has two major drawbacks: 20 | 21 | * Inconsistent results: if the dataset changes while you are querying, the results in the page will shift and your user 22 | might end seeing records they have already seen and missing new ones. 23 | 24 | * Inefficiency: `OFFSET N` instructs the database to skip the first N results of a query. However, the database must still 25 | fetch these rows from disk and order them before it can returns the ones requested. If the dataset you are querying is 26 | large this will result in significant slowdowns. 27 | 28 | ### Cursor-based (a.k.a keyset pagination) 29 | 30 | This method relies on opaque cursor to figure out where to start selecting records. It is more performant than 31 | `LIMIT-OFFSET` because it can filter records without traversing all of them. 32 | 33 | It's also consistent, any insertions/deletions before the current page will leave results unaffected. 34 | 35 | It has some limitations though: for instance you can't jump directly to a specific page. This may 36 | not be an issue for an API or if you use infinite scrolling on your website. 37 | 38 | ### Learn more 39 | 40 | * http://use-the-index-luke.com/no-offset 41 | * http://use-the-index-luke.com/sql/partial-results/fetch-next-page 42 | * https://www.citusdata.com/blog/2016/03/30/five-ways-to-paginate/ 43 | * https://developer.twitter.com/en/docs/tweets/timelines/guides/working-with-timelines 44 | 45 | ## Getting started 46 | 47 | ```elixir 48 | defmodule MyApp.Repo do 49 | use Ecto.Repo, 50 | otp_app: :my_app, 51 | adapter: Ecto.Adapters.Postgres 52 | 53 | use Paginator 54 | end 55 | 56 | query = from(p in Post, order_by: [asc: p.inserted_at, asc: p.id]) 57 | 58 | page = MyApp.Repo.paginate(query, cursor_fields: [:inserted_at, :id], limit: 50) 59 | 60 | # `page.entries` contains all the entries for this page. 61 | # `page.metadata` contains the metadata associated with this page (cursors, limit, total count) 62 | ``` 63 | 64 | ## Installation 65 | 66 | Add `:paginator` to your list of dependencies in `mix.exs`: 67 | 68 | ```elixir 69 | def deps do 70 | [ 71 | {:paginator, "~> 1.2.0"} 72 | ] 73 | end 74 | ``` 75 | 76 | ## Usage 77 | 78 | Add `Paginator` to your repo: 79 | 80 | ```elixir 81 | defmodule MyApp.Repo do 82 | use Ecto.Repo, 83 | otp_app: :my_app, 84 | adapter: Ecto.Adapters.Postgres 85 | 86 | use Paginator 87 | end 88 | ``` 89 | 90 | Use the `paginate` function to paginate your queries: 91 | 92 | ```elixir 93 | query = from(p in Post, order_by: [asc: p.inserted_at, asc: p.id]) 94 | 95 | # return the first 50 posts 96 | %{entries: entries, metadata: metadata} 97 | = Repo.paginate( 98 | query, 99 | cursor_fields: [:inserted_at, :id], 100 | limit: 50 101 | ) 102 | 103 | # assign the `after` cursor to a variable 104 | cursor_after = metadata.after 105 | 106 | # return the next 50 posts 107 | %{entries: entries, metadata: metadata} 108 | = Repo.paginate( 109 | query, 110 | after: cursor_after, 111 | cursor_fields: [{:inserted_at, :asc}, {:id, :asc}], 112 | limit: 50 113 | ) 114 | 115 | # assign the `before` cursor to a variable 116 | cursor_before = metadata.before 117 | 118 | # return the previous 50 posts (if no post was created in between it should be 119 | # the same list as in our first call to `paginate`) 120 | %{entries: entries, metadata: metadata} 121 | = Repo.paginate( 122 | query, 123 | before: cursor_before, 124 | cursor_fields: [:inserted_at, :id], 125 | limit: 50 126 | ) 127 | 128 | # return total count 129 | # NOTE: this will issue a separate `SELECT COUNT(*) FROM table` query to the 130 | # database. 131 | %{entries: entries, metadata: metadata} 132 | = Repo.paginate( 133 | query, 134 | include_total_count: true, 135 | cursor_fields: [:inserted_at, :id], 136 | limit: 50 137 | ) 138 | 139 | IO.puts "total count: #{metadata.total_count}" 140 | ``` 141 | 142 | ## Dynamic expressions 143 | 144 | ```elixir 145 | query = 146 | from( 147 | f in Post, 148 | # Alias for fragment must match witch cursor field name in fetch_cursor_value_fun and cursor_fields 149 | select_merge: %{ 150 | rank_value: 151 | fragment("ts_rank(document, plainto_tsquery('simple', ?)) AS rank_value", ^q) 152 | }, 153 | where: fragment("document @@ plainto_tsquery('simple', ?)", ^q), 154 | order_by: [ 155 | desc: fragment("rank_value"), 156 | desc: f.id 157 | ] 158 | ) 159 | query 160 | |> Repo.paginate( 161 | limit: 30, 162 | fetch_cursor_value_fun: fn 163 | # Here we build the rank_value for each returned row 164 | schema, :rank_value -> 165 | {:ok, %{rows: [[rank_value]]}} = 166 | Repo.query("SELECT ts_rank($1, plainto_tsquery('simple', $2))", [ 167 | schema.document, 168 | q 169 | ]) 170 | rank_value 171 | schema, field -> 172 | Paginator.default_fetch_cursor_value(schema, field) 173 | end, 174 | cursor_fields: [ 175 | {:rank_value, # Here we build the rank_value that will be used in the where clause 176 | fn -> 177 | dynamic( 178 | [x], 179 | fragment("ts_rank(document, plainto_tsquery('simple', ?))", ^q) 180 | ) 181 | end}, 182 | :id 183 | ] 184 | ) 185 | ``` 186 | 187 | ## Security Considerations 188 | 189 | `Repo.paginate/4` will throw an `ArgumentError` should it detect an executable term in the cursor parameters passed to it (`before`, `after`). 190 | This is done to protect you from potential side-effects of malicious user input, see [paginator_test.exs](https://github.com/duffelhq/paginator/blob/main/test/paginator_test.exs#L830). 191 | 192 | ## Indexes 193 | 194 | If you want to reap all the benefits of this method it is better that you create indexes on the columns you are using as 195 | cursor fields. 196 | 197 | ### Example 198 | 199 | ```elixir 200 | # If your cursor fields are: [:inserted_at, :id] 201 | # Add the following in a migration 202 | 203 | create index("posts", [:inserted_at, :id]) 204 | ``` 205 | 206 | ## Caveats 207 | 208 | * This method requires a deterministic sort order. If the columns you are currently using for sorting don't match that 209 | definition, just add any unique column and extend your index accordingly. 210 | * You need to add `:order_by` clauses yourself before passing your query to `paginate/2`. In the future we might do that 211 | for you automatically based on the fields specified in `:cursor_fields`. 212 | * There is an outstanding issue where Postgrex fails to properly builds the query if it includes custom PostgreSQL types. 213 | * This library has only be tested with PostgreSQL. 214 | 215 | ## Documentation 216 | 217 | Documentation is written into the library, you will find it in the source code, accessible from `iex` and of course, it 218 | all gets published to [hexdocs](http://hexdocs.pm/paginator). 219 | 220 | ## Contributing 221 | 222 | ### Running tests 223 | 224 | Clone the repo and fetch its dependencies: 225 | 226 | ``` 227 | $ git clone https://github.com/duffelhq/paginator.git 228 | $ cd paginator 229 | $ mix deps.get 230 | $ mix test 231 | ``` 232 | 233 | ### Building docs 234 | 235 | ``` 236 | $ mix docs 237 | ``` 238 | 239 | ## Copyright and License 240 | 241 | Copyright (c) 2017 Steve Domin. 242 | 243 | This software is licensed under [the MIT license](./LICENSE.md). 244 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | import_config "#{Mix.env()}.exs" 4 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | use Mix.Config 2 | 3 | config :paginator, ecto_repos: [Paginator.Repo] 4 | 5 | config :paginator, Paginator.Repo, 6 | pool: Ecto.Adapters.SQL.Sandbox, 7 | username: "postgres", 8 | password: "postgres", 9 | database: "paginator_test" 10 | 11 | config :logger, :console, level: :warn 12 | -------------------------------------------------------------------------------- /lib/paginator.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator do 2 | @moduledoc """ 3 | Defines a paginator. 4 | 5 | This module adds a `paginate/3` function to your `Ecto.Repo` so that you can 6 | paginate through results using opaque cursors. 7 | 8 | ## Usage 9 | 10 | defmodule MyApp.Repo do 11 | use Ecto.Repo, otp_app: :my_app 12 | use Paginator 13 | end 14 | 15 | ## Options 16 | 17 | `Paginator` can take any options accepted by `paginate/3`. This is useful when 18 | you want to enforce some options globally across your project. 19 | 20 | ### Example 21 | 22 | defmodule MyApp.Repo do 23 | use Ecto.Repo, otp_app: :my_app 24 | use Paginator, 25 | limit: 10, # sets the default limit to 10 26 | maximum_limit: 100, # sets the maximum limit to 100 27 | include_total_count: true, # include total count by default 28 | total_count_primary_key_field: :uuid # sets the total_count_primary_key_field to uuid for calculate total_count 29 | end 30 | 31 | Note that these values can be still be overridden when `paginate/3` is called. 32 | 33 | ### Use without macros 34 | 35 | If you wish to avoid use of macros or you wish to use a different name for 36 | the pagination function you can define your own function like so: 37 | 38 | defmodule MyApp.Repo do 39 | use Ecto.Repo, otp_app: :my_app 40 | 41 | def my_paginate_function(queryable, opts \\ [], repo_opts \\ []) do 42 | defaults = [limit: 10] # Default options of your choice here 43 | opts = Keyword.merge(defaults, opts) 44 | Paginator.paginate(queryable, opts, __MODULE__, repo_opts) 45 | end 46 | end 47 | """ 48 | 49 | import Ecto.Query 50 | 51 | alias Paginator.{Config, Cursor, Ecto.Query, Page, Page.Metadata} 52 | 53 | defmacro __using__(opts) do 54 | quote do 55 | @defaults unquote(opts) 56 | 57 | def paginate(queryable, opts \\ [], repo_opts \\ []) do 58 | opts = Keyword.merge(@defaults, opts) 59 | 60 | Paginator.paginate(queryable, opts, __MODULE__, repo_opts) 61 | end 62 | end 63 | end 64 | 65 | @doc """ 66 | Fetches all the results matching the query within the cursors. 67 | 68 | ## Options 69 | 70 | * `:after` - Fetch the records after this cursor. 71 | * `:before` - Fetch the records before this cursor. 72 | * `:cursor_fields` - The fields with sorting direction used to determine the 73 | cursor. In most cases, this should be the same fields as the ones used for sorting in the query. 74 | When you use named bindings in your query they can also be provided. 75 | * `:fetch_cursor_value_fun` function of arity 2 to lookup cursor values on returned records. 76 | Defaults to `Paginator.default_fetch_cursor_value/2` 77 | * `:include_total_count` - Set this to true to return the total number of 78 | records matching the query. Note that this number will be capped by 79 | `:total_count_limit`. Defaults to `false`. 80 | * `:total_count_primary_key_field` - Running count queries on specified column of the table 81 | * `:limit` - Limits the number of records returned per page. Note that this 82 | number will be capped by `:maximum_limit`. Defaults to `50`. 83 | * `:maximum_limit` - Sets a maximum cap for `:limit`. This option can be useful when `:limit` 84 | is set dynamically (e.g from a URL param set by a user) but you still want to 85 | enfore a maximum. Defaults to `500`. 86 | * `:sort_direction` - The direction used for sorting. Defaults to `:asc`. 87 | It is preferred to set the sorting direction per field in `:cursor_fields`. 88 | * `:total_count_limit` - Running count queries on tables with a large number 89 | of records is expensive so it is capped by default. Can be set to `:infinity` 90 | in order to count all the records. Defaults to `10,000`. 91 | 92 | ## Repo options 93 | 94 | This will be passed directly to `Ecto.Repo.all/2`, as such any option supported 95 | by this function can be used here. 96 | 97 | ## Simple example 98 | 99 | query = from(p in Post, order_by: [asc: p.inserted_at, asc: p.id], select: p) 100 | 101 | Repo.paginate(query, cursor_fields: [:inserted_at, :id], limit: 50) 102 | 103 | ## Example with using custom sort directions per field 104 | 105 | query = from(p in Post, order_by: [asc: p.inserted_at, desc: p.id], select: p) 106 | 107 | Repo.paginate(query, cursor_fields: [inserted_at: :asc, id: :desc], limit: 50) 108 | 109 | ## Example with sorting on columns in joined tables 110 | 111 | from( 112 | p in Post, 113 | as: :posts, 114 | join: a in assoc(p, :author), 115 | as: :author, 116 | preload: [author: a], 117 | select: p, 118 | order_by: [ 119 | {:asc, a.name}, 120 | {:asc, p.id} 121 | ] 122 | ) 123 | 124 | Repo.paginate(query, cursor_fields: [{{:author, :name}, :asc}, id: :asc], limit: 50) 125 | 126 | When sorting on columns in joined tables it is necessary to use named bindings. In 127 | this case we name it `author`. In the `cursor_fields` we refer to this named binding 128 | and its column name. 129 | 130 | To build the cursor Paginator uses the returned Ecto.Schema. When using a joined 131 | column the returned Ecto.Schema won't have the value of the joined column 132 | unless we preload it. E.g. in this case the cursor will be build up from 133 | `post.id` and `post.author.name`. This presupposes that the named of the 134 | binding is the same as the name of the relationship on the original struct. 135 | 136 | One level deep joins are supported out of the box but if we join on a second 137 | level, e.g. `post.author.company.name` a custom function can be supplied to 138 | handle the cursor value retrieval. This also applies when the named binding 139 | does not map to the name of the relationship. 140 | 141 | ## Example 142 | from( 143 | p in Post, 144 | as: :posts, 145 | join: a in assoc(p, :author), 146 | as: :author, 147 | join: c in assoc(a, :company), 148 | as: :company, 149 | preload: [author: a], 150 | select: p, 151 | order_by: [ 152 | {:asc, a.name}, 153 | {:asc, p.id} 154 | ] 155 | ) 156 | 157 | Repo.paginate(query, 158 | cursor_fields: [{{:company, :name}, :asc}, id: :asc], 159 | fetch_cursor_value_fun: fn 160 | post, {:company, name} -> 161 | post.author.company.name 162 | 163 | post, field -> 164 | Paginator.default_fetch_cursor_value(post, field) 165 | end, 166 | limit: 50 167 | ) 168 | 169 | """ 170 | @callback paginate(queryable :: Ecto.Query.t(), opts :: Keyword.t(), repo_opts :: Keyword.t()) :: 171 | Paginator.Page.t() 172 | 173 | @doc false 174 | def paginate(queryable, opts, repo, repo_opts) do 175 | config = Config.new(opts) 176 | 177 | Config.validate!(config) 178 | 179 | sorted_entries = entries(queryable, config, repo, repo_opts) 180 | paginated_entries = paginate_entries(sorted_entries, config) 181 | {total_count, total_count_cap_exceeded} = total_count(queryable, config, repo, repo_opts) 182 | 183 | %Page{ 184 | entries: paginated_entries, 185 | metadata: %Metadata{ 186 | before: before_cursor(paginated_entries, sorted_entries, config), 187 | after: after_cursor(paginated_entries, sorted_entries, config), 188 | limit: config.limit, 189 | total_count: total_count, 190 | total_count_cap_exceeded: total_count_cap_exceeded 191 | } 192 | } 193 | end 194 | 195 | @doc """ 196 | Generate a cursor for the supplied record, in the same manner as the 197 | `before` and `after` cursors generated by `paginate/3`. 198 | 199 | For the cursor to be compatible with `paginate/3`, `cursor_fields` 200 | must have the same value as the `cursor_fields` option passed to it. 201 | 202 | ### Example 203 | 204 | iex> Paginator.cursor_for_record(%Paginator.Customer{id: 1}, [:id]) 205 | "g3QAAAABZAACaWRhAQ==" 206 | 207 | iex> Paginator.cursor_for_record(%Paginator.Customer{id: 1, name: "Alice"}, [id: :asc, name: :desc]) 208 | "g3QAAAACZAACaWRhAWQABG5hbWVtAAAABUFsaWNl" 209 | """ 210 | @spec cursor_for_record( 211 | any(), 212 | [atom() | {atom(), atom()}], 213 | (map(), atom() | {atom(), atom()} -> any()) 214 | ) :: binary() 215 | def cursor_for_record( 216 | record, 217 | cursor_fields, 218 | fetch_cursor_value_fun \\ &Paginator.default_fetch_cursor_value/2 219 | ) do 220 | fetch_cursor_value(record, %Config{ 221 | cursor_fields: cursor_fields, 222 | fetch_cursor_value_fun: fetch_cursor_value_fun 223 | }) 224 | end 225 | 226 | @doc """ 227 | Default function used to get the value of a cursor field from the supplied 228 | map. This function can be overridden in the `Paginator.Config` using the 229 | `fetch_cursor_value_fun` key. 230 | 231 | When using named bindings to sort on joined columns it will attempt to get 232 | the value of joined column by using the named binding as the name of the 233 | relationship on the original Ecto.Schema. 234 | 235 | ### Example 236 | 237 | iex> Paginator.default_fetch_cursor_value(%Paginator.Customer{id: 1}, :id) 238 | 1 239 | 240 | iex> Paginator.default_fetch_cursor_value(%Paginator.Customer{id: 1, address: %Paginator.Address{city: "London"}}, {:address, :city}) 241 | "London" 242 | """ 243 | 244 | @spec default_fetch_cursor_value(map(), atom() | {atom(), atom()}) :: any() 245 | def default_fetch_cursor_value(schema, {binding, field}) 246 | when is_atom(binding) and is_atom(field) do 247 | case Map.get(schema, field) do 248 | nil -> Map.get(schema, binding) |> Map.get(field) 249 | value -> value 250 | end 251 | end 252 | 253 | def default_fetch_cursor_value(schema, field) when is_atom(field) do 254 | Map.get(schema, field) 255 | end 256 | 257 | defp before_cursor([], [], _config), do: nil 258 | 259 | defp before_cursor(_paginated_entries, _sorted_entries, %Config{after: nil, before: nil}), 260 | do: nil 261 | 262 | defp before_cursor(paginated_entries, _sorted_entries, %Config{after: c_after} = config) 263 | when not is_nil(c_after) do 264 | first_or_nil(paginated_entries, config) 265 | end 266 | 267 | defp before_cursor(paginated_entries, sorted_entries, config) do 268 | if first_page?(sorted_entries, config) do 269 | nil 270 | else 271 | first_or_nil(paginated_entries, config) 272 | end 273 | end 274 | 275 | defp first_or_nil(entries, config) do 276 | if first = List.first(entries) do 277 | fetch_cursor_value(first, config) 278 | else 279 | nil 280 | end 281 | end 282 | 283 | defp after_cursor([], [], _config), do: nil 284 | 285 | defp after_cursor(paginated_entries, _sorted_entries, %Config{before: c_before} = config) 286 | when not is_nil(c_before) do 287 | last_or_nil(paginated_entries, config) 288 | end 289 | 290 | defp after_cursor(paginated_entries, sorted_entries, config) do 291 | if last_page?(sorted_entries, config) do 292 | nil 293 | else 294 | last_or_nil(paginated_entries, config) 295 | end 296 | end 297 | 298 | defp last_or_nil(entries, config) do 299 | if last = List.last(entries) do 300 | fetch_cursor_value(last, config) 301 | else 302 | nil 303 | end 304 | end 305 | 306 | defp fetch_cursor_value(schema, %Config{ 307 | cursor_fields: cursor_fields, 308 | fetch_cursor_value_fun: fetch_cursor_value_fun 309 | }) do 310 | cursor_fields 311 | |> Enum.map(fn 312 | {{cursor_field, func}, _order} when is_atom(cursor_field) and is_function(func) -> 313 | {cursor_field, fetch_cursor_value_fun.(schema, cursor_field)} 314 | 315 | {cursor_field, func} when is_atom(cursor_field) and is_function(func) -> 316 | {cursor_field, fetch_cursor_value_fun.(schema, cursor_field)} 317 | 318 | {cursor_field, _order} -> 319 | {cursor_field, fetch_cursor_value_fun.(schema, cursor_field)} 320 | 321 | cursor_field when is_atom(cursor_field) -> 322 | {cursor_field, fetch_cursor_value_fun.(schema, cursor_field)} 323 | end) 324 | |> Map.new() 325 | |> Cursor.encode() 326 | end 327 | 328 | defp first_page?(sorted_entries, %Config{limit: limit}) do 329 | Enum.count(sorted_entries) <= limit 330 | end 331 | 332 | defp last_page?(sorted_entries, %Config{limit: limit}) do 333 | Enum.count(sorted_entries) <= limit 334 | end 335 | 336 | defp entries(queryable, config, repo, repo_opts) do 337 | queryable 338 | |> Query.paginate(config) 339 | |> repo.all(repo_opts) 340 | end 341 | 342 | defp total_count(_queryable, %Config{include_total_count: false}, _repo, _repo_opts), 343 | do: {nil, nil} 344 | 345 | defp total_count( 346 | queryable, 347 | %Config{ 348 | total_count_limit: :infinity, 349 | total_count_primary_key_field: total_count_primary_key_field 350 | }, 351 | repo, 352 | repo_opts 353 | ) do 354 | result = 355 | queryable 356 | |> exclude(:preload) 357 | |> exclude(:select) 358 | |> exclude(:order_by) 359 | |> select([e], struct(e, [total_count_primary_key_field])) 360 | |> subquery 361 | |> select(count("*")) 362 | |> repo.one(repo_opts) 363 | 364 | {result, false} 365 | end 366 | 367 | defp total_count( 368 | queryable, 369 | %Config{ 370 | total_count_limit: total_count_limit, 371 | total_count_primary_key_field: total_count_primary_key_field 372 | }, 373 | repo, 374 | repo_opts 375 | ) do 376 | result = 377 | queryable 378 | |> exclude(:preload) 379 | |> exclude(:select) 380 | |> exclude(:order_by) 381 | |> limit(^(total_count_limit + 1)) 382 | |> select([e], struct(e, [total_count_primary_key_field])) 383 | |> subquery 384 | |> select(count("*")) 385 | |> repo.one(repo_opts) 386 | 387 | { 388 | Enum.min([result, total_count_limit]), 389 | result > total_count_limit 390 | } 391 | end 392 | 393 | # `sorted_entries` returns (limit+1) records, so before 394 | # returning the page, we want to take only the first (limit). 395 | # 396 | # When we have only a before cursor, we get our results from 397 | # sorted_entries in reverse order due t 398 | defp paginate_entries(sorted_entries, %Config{before: before, after: nil, limit: limit}) 399 | when not is_nil(before) do 400 | sorted_entries 401 | |> Enum.take(limit) 402 | |> Enum.reverse() 403 | end 404 | 405 | defp paginate_entries(sorted_entries, %Config{limit: limit}) do 406 | Enum.take(sorted_entries, limit) 407 | end 408 | end 409 | -------------------------------------------------------------------------------- /lib/paginator/config.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Config do 2 | @moduledoc false 3 | 4 | alias Paginator.Cursor 5 | 6 | @type t :: %__MODULE__{} 7 | 8 | defstruct [ 9 | :after, 10 | :after_values, 11 | :before, 12 | :before_values, 13 | :cursor_fields, 14 | :fetch_cursor_value_fun, 15 | :include_total_count, 16 | :total_count_primary_key_field, 17 | :limit, 18 | :maximum_limit, 19 | :sort_direction, 20 | :total_count_limit 21 | ] 22 | 23 | defmodule ArgumentError do 24 | defexception [:message] 25 | end 26 | 27 | @default_total_count_primary_key_field :id 28 | @default_limit 50 29 | @minimum_limit 1 30 | @maximum_limit 500 31 | @default_total_count_limit 10_000 32 | @order_directions [ 33 | :asc, 34 | :asc_nulls_last, 35 | :asc_nulls_first, 36 | :desc, 37 | :desc_nulls_first, 38 | :desc_nulls_last 39 | ] 40 | 41 | def new(opts \\ []) do 42 | %__MODULE__{ 43 | after: opts[:after], 44 | after_values: Cursor.decode(opts[:after]), 45 | before: opts[:before], 46 | before_values: Cursor.decode(opts[:before]), 47 | cursor_fields: opts[:cursor_fields], 48 | fetch_cursor_value_fun: 49 | opts[:fetch_cursor_value_fun] || (&Paginator.default_fetch_cursor_value/2), 50 | include_total_count: opts[:include_total_count] || false, 51 | total_count_primary_key_field: 52 | opts[:total_count_primary_key_field] || @default_total_count_primary_key_field, 53 | limit: limit(opts), 54 | sort_direction: opts[:sort_direction], 55 | total_count_limit: opts[:total_count_limit] || @default_total_count_limit 56 | } 57 | |> convert_deprecated_config() 58 | end 59 | 60 | def validate!(%__MODULE__{} = config) do 61 | unless config.cursor_fields do 62 | raise(Paginator.Config.ArgumentError, message: "expected `:cursor_fields` to be set") 63 | end 64 | 65 | if !cursor_values_match_cursor_fields?(config.after_values, config.cursor_fields) do 66 | raise(Paginator.Config.ArgumentError, 67 | message: "expected `:after` cursor to match `:cursor_fields`" 68 | ) 69 | end 70 | 71 | if !cursor_values_match_cursor_fields?(config.before_values, config.cursor_fields) do 72 | raise(Paginator.Config.ArgumentError, 73 | message: "expected `:before` cursor to match `:cursor_fields`" 74 | ) 75 | end 76 | end 77 | 78 | defp cursor_values_match_cursor_fields?(nil = _cursor_values, _cursor_fields), do: true 79 | 80 | defp cursor_values_match_cursor_fields?(cursor_values, _cursor_fields) 81 | when is_list(cursor_values) do 82 | # Legacy cursors are valid by default 83 | true 84 | end 85 | 86 | defp cursor_values_match_cursor_fields?(cursor_values, cursor_fields) do 87 | cursor_keys = cursor_values |> Map.keys() |> Enum.sort() 88 | 89 | sorted_cursor_fields = 90 | cursor_fields 91 | |> Enum.map(fn 92 | {field, value} when is_atom(field) and value in @order_directions -> 93 | field 94 | 95 | {{schema, field}, value} 96 | when is_atom(schema) and is_atom(field) and value in @order_directions -> 97 | {schema, field} 98 | 99 | {{field, func}, value} 100 | when is_function(func) and is_atom(field) and value in @order_directions -> 101 | field 102 | 103 | {field, func} when is_function(func) and is_atom(field) -> 104 | field 105 | 106 | field when is_atom(field) -> 107 | field 108 | 109 | {schema, field} when is_atom(schema) and is_atom(field) -> 110 | {schema, field} 111 | end) 112 | |> Enum.sort() 113 | 114 | match?(^cursor_keys, sorted_cursor_fields) 115 | end 116 | 117 | defp limit(opts) do 118 | max(opts[:limit] || @default_limit, @minimum_limit) 119 | |> min(opts[:maximum_limit] || @maximum_limit) 120 | end 121 | 122 | defp convert_deprecated_config(config) do 123 | case config do 124 | %__MODULE__{sort_direction: nil} -> 125 | %{ 126 | config 127 | | cursor_fields: build_cursor_fields_from_sort_direction(config.cursor_fields, :asc) 128 | } 129 | 130 | %__MODULE__{sort_direction: direction} -> 131 | %{ 132 | config 133 | | cursor_fields: 134 | build_cursor_fields_from_sort_direction(config.cursor_fields, direction), 135 | sort_direction: nil 136 | } 137 | end 138 | end 139 | 140 | defp build_cursor_fields_from_sort_direction(nil, _sorting_direction), do: nil 141 | 142 | defp build_cursor_fields_from_sort_direction(fields, sorting_direction) do 143 | Enum.map(fields, fn 144 | {{_binding, _column}, _direction} = field -> field 145 | {_column, direction} = field when direction in @order_directions -> field 146 | {_binding, _column} = field -> {field, sorting_direction} 147 | field -> {field, sorting_direction} 148 | end) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/paginator/cursor.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Cursor do 2 | @moduledoc false 3 | 4 | def decode(nil), do: nil 5 | 6 | def decode(encoded_cursor) do 7 | encoded_cursor 8 | |> Base.url_decode64!() 9 | |> Plug.Crypto.non_executable_binary_to_term([:safe]) 10 | end 11 | 12 | def encode(values) when is_map(values) do 13 | values 14 | |> :erlang.term_to_binary() 15 | |> Base.url_encode64() 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query do 2 | @moduledoc false 3 | 4 | import Ecto.Query 5 | 6 | alias Paginator.Config 7 | alias Paginator.Ecto.Query.DynamicFilterBuilder 8 | 9 | def paginate(queryable, config \\ []) 10 | 11 | def paginate(queryable, %Config{} = config) do 12 | queryable 13 | |> maybe_where(config) 14 | |> limit(^query_limit(config)) 15 | end 16 | 17 | def paginate(queryable, opts) do 18 | config = Config.new(opts) 19 | paginate(queryable, config) 20 | end 21 | 22 | # This clause is responsible for transforming legacy list cursors into map cursors 23 | defp filter_values(query, fields, values, cursor_direction) when is_list(values) do 24 | new_values = 25 | fields 26 | |> Enum.map(&elem(&1, 0)) 27 | |> Enum.zip(values) 28 | |> Map.new() 29 | 30 | filter_values(query, fields, new_values, cursor_direction) 31 | end 32 | 33 | defp filter_values(query, fields, values, cursor_direction) when is_map(values) do 34 | filters = build_where_expression(query, fields, values, cursor_direction) 35 | 36 | where(query, [{q, 0}], ^filters) 37 | end 38 | 39 | defp build_where_expression(query, [{field, order} = column], values, cursor_direction) do 40 | value = column_value(column, values) 41 | {q_position, q_binding} = column_position(query, field) 42 | 43 | DynamicFilterBuilder.build!(%{ 44 | sort_order: order, 45 | direction: cursor_direction, 46 | value: value, 47 | entity_position: q_position, 48 | column: q_binding, 49 | next_filters: true 50 | }) 51 | end 52 | 53 | defp build_where_expression(query, [{field, order} = column | fields], values, cursor_direction) do 54 | value = column_value(column, values) 55 | {q_position, q_binding} = column_position(query, field) 56 | 57 | filters = build_where_expression(query, fields, values, cursor_direction) 58 | 59 | DynamicFilterBuilder.build!(%{ 60 | sort_order: order, 61 | direction: cursor_direction, 62 | value: value, 63 | entity_position: q_position, 64 | column: q_binding, 65 | next_filters: filters 66 | }) 67 | end 68 | 69 | defp column_value({{field, func}, _order}, values) when is_function(func) and is_atom(field) do 70 | Map.get(values, field) 71 | end 72 | 73 | defp column_value({column, _order}, values) do 74 | Map.get(values, column) 75 | end 76 | 77 | defp maybe_where(query, %Config{ 78 | after: nil, 79 | before: nil 80 | }) do 81 | query 82 | end 83 | 84 | defp maybe_where(query, %Config{ 85 | after_values: after_values, 86 | before: nil, 87 | cursor_fields: cursor_fields 88 | }) do 89 | query 90 | |> filter_values(cursor_fields, after_values, :after) 91 | end 92 | 93 | defp maybe_where(query, %Config{ 94 | after: nil, 95 | before_values: before_values, 96 | cursor_fields: cursor_fields 97 | }) do 98 | query 99 | |> filter_values(cursor_fields, before_values, :before) 100 | |> reverse_order_bys() 101 | end 102 | 103 | defp maybe_where(query, %Config{ 104 | after_values: after_values, 105 | before_values: before_values, 106 | cursor_fields: cursor_fields 107 | }) do 108 | query 109 | |> filter_values(cursor_fields, after_values, :after) 110 | |> filter_values(cursor_fields, before_values, :before) 111 | end 112 | 113 | # With custom column handler 114 | defp column_position(_query, {_, handler} = column) when is_function(handler), 115 | do: {0, column} 116 | 117 | # Lookup position of binding in query aliases 118 | defp column_position(query, {binding_name, column}) do 119 | case Map.fetch(query.aliases, binding_name) do 120 | {:ok, position} -> 121 | {position, column} 122 | 123 | _ -> 124 | raise( 125 | ArgumentError, 126 | "Could not find binding `#{binding_name}` in query aliases: #{inspect(query.aliases)}" 127 | ) 128 | end 129 | end 130 | 131 | # Without named binding we assume position of binding is 0 132 | defp column_position(_query, column), do: {0, column} 133 | 134 | #  In order to return the correct pagination cursors, we need to fetch one more 135 | # # record than we actually want to return. 136 | defp query_limit(%Config{limit: limit}) do 137 | limit + 1 138 | end 139 | 140 | # This code was taken from https://github.com/elixir-ecto/ecto/blob/v2.1.4/lib/ecto/query.ex#L1212-L1226 141 | defp reverse_order_bys(query) do 142 | update_in(query.order_bys, fn 143 | [] -> 144 | [] 145 | 146 | order_bys -> 147 | for %{expr: expr} = order_by <- order_bys do 148 | %{ 149 | order_by 150 | | expr: 151 | Enum.map(expr, fn 152 | {:desc, ast} -> {:asc, ast} 153 | {:desc_nulls_first, ast} -> {:asc_nulls_last, ast} 154 | {:desc_nulls_last, ast} -> {:asc_nulls_first, ast} 155 | {:asc, ast} -> {:desc, ast} 156 | {:asc_nulls_last, ast} -> {:desc_nulls_first, ast} 157 | {:asc_nulls_first, ast} -> {:desc_nulls_last, ast} 158 | end) 159 | } 160 | end 161 | end) 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/asc_nulls_first.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.AscNullsFirst do 2 | @behaviour Paginator.Ecto.Query.DynamicFilterBuilder 3 | 4 | import Ecto.Query 5 | import Paginator.Ecto.Query.FieldOrExpression 6 | 7 | @impl Paginator.Ecto.Query.DynamicFilterBuilder 8 | def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do 9 | raise("unstable sort order: nullable columns can't be used as the last term") 10 | end 11 | 12 | def build_dynamic_filter(args = %{direction: :after, value: nil}) do 13 | dynamic( 14 | [{query, args.entity_position}], 15 | (^field_or_expr_is_nil(args) and ^args.next_filters) or 16 | not (^field_or_expr_is_nil(args)) 17 | ) 18 | end 19 | 20 | def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do 21 | dynamic( 22 | [{query, args.entity_position}], 23 | ^field_or_expr_greater(args) 24 | ) 25 | end 26 | 27 | def build_dynamic_filter(args = %{direction: :after}) do 28 | dynamic( 29 | [{query, args.entity_position}], 30 | (^field_or_expr_equal(args) and ^args.next_filters) or 31 | ^field_or_expr_greater(args) 32 | ) 33 | end 34 | 35 | def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do 36 | raise("unstable sort order: nullable columns can't be used as the last term") 37 | end 38 | 39 | def build_dynamic_filter(args = %{direction: :before, value: nil}) do 40 | dynamic( 41 | [{query, args.entity_position}], 42 | ^field_or_expr_is_nil(args) and ^args.next_filters 43 | ) 44 | end 45 | 46 | def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do 47 | dynamic( 48 | [{query, args.entity_position}], 49 | ^field_or_expr_less(args) or ^field_or_expr_is_nil(args) 50 | ) 51 | end 52 | 53 | def build_dynamic_filter(args = %{direction: :before}) do 54 | dynamic( 55 | [{query, args.entity_position}], 56 | (^field_or_expr_equal(args) and ^args.next_filters) or 57 | ^field_or_expr_less(args) or 58 | ^field_or_expr_is_nil(args) 59 | ) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/asc_nulls_last.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.AscNullsLast do 2 | @behaviour Paginator.Ecto.Query.DynamicFilterBuilder 3 | 4 | import Ecto.Query 5 | import Paginator.Ecto.Query.FieldOrExpression 6 | 7 | @impl Paginator.Ecto.Query.DynamicFilterBuilder 8 | def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do 9 | raise("unstable sort order: nullable columns can't be used as the last term") 10 | end 11 | 12 | def build_dynamic_filter(args = %{direction: :after, value: nil}) do 13 | dynamic( 14 | [{query, args.entity_position}], 15 | ^field_or_expr_is_nil(args) and ^args.next_filters 16 | ) 17 | end 18 | 19 | def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do 20 | dynamic( 21 | [{query, args.entity_position}], 22 | ^field_or_expr_greater(args) or ^field_or_expr_is_nil(args) 23 | ) 24 | end 25 | 26 | def build_dynamic_filter(args = %{direction: :after}) do 27 | dynamic( 28 | [{query, args.entity_position}], 29 | (^field_or_expr_equal(args) and ^args.next_filters) or 30 | ^field_or_expr_greater(args) or 31 | ^field_or_expr_is_nil(args) 32 | ) 33 | end 34 | 35 | def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do 36 | raise("unstable sort order: nullable columns can't be used as the last term") 37 | end 38 | 39 | def build_dynamic_filter(args = %{direction: :before, value: nil}) do 40 | dynamic( 41 | [{query, args.entity_position}], 42 | (^field_or_expr_is_nil(args) and ^args.next_filters) or 43 | not (^field_or_expr_is_nil(args)) 44 | ) 45 | end 46 | 47 | def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do 48 | dynamic([{query, args.entity_position}], ^field_or_expr_less(args)) 49 | end 50 | 51 | def build_dynamic_filter(args = %{direction: :before}) do 52 | dynamic( 53 | [{query, args.entity_position}], 54 | (^field_or_expr_equal(args) and ^args.next_filters) or 55 | ^field_or_expr_less(args) 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/desc_nulls_first.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.DescNullsFirst do 2 | @behaviour Paginator.Ecto.Query.DynamicFilterBuilder 3 | 4 | import Ecto.Query 5 | import Paginator.Ecto.Query.FieldOrExpression 6 | 7 | @impl Paginator.Ecto.Query.DynamicFilterBuilder 8 | def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do 9 | raise("unstable sort order: nullable columns can't be used as the last term") 10 | end 11 | 12 | def build_dynamic_filter(args = %{direction: :before, value: nil}) do 13 | dynamic( 14 | [{query, args.entity_position}], 15 | ^field_or_expr_is_nil(args) and ^args.next_filters 16 | ) 17 | end 18 | 19 | def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do 20 | dynamic( 21 | [{query, args.entity_position}], 22 | ^field_or_expr_greater(args) or ^field_or_expr_is_nil(args) 23 | ) 24 | end 25 | 26 | def build_dynamic_filter(args = %{direction: :before}) do 27 | dynamic( 28 | [{query, args.entity_position}], 29 | (^field_or_expr_equal(args) and ^args.next_filters) or 30 | ^field_or_expr_greater(args) or 31 | ^field_or_expr_is_nil(args) 32 | ) 33 | end 34 | 35 | def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do 36 | raise("unstable sort order: nullable columns can't be used as the last term") 37 | end 38 | 39 | def build_dynamic_filter(args = %{direction: :after, value: nil}) do 40 | dynamic( 41 | [{query, args.entity_position}], 42 | (^field_or_expr_is_nil(args) and ^args.next_filters) or 43 | not (^field_or_expr_is_nil(args)) 44 | ) 45 | end 46 | 47 | def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do 48 | dynamic([{query, args.entity_position}], ^field_or_expr_less(args)) 49 | end 50 | 51 | def build_dynamic_filter(args = %{direction: :after}) do 52 | dynamic( 53 | [{query, args.entity_position}], 54 | (^field_or_expr_equal(args) and ^args.next_filters) or 55 | ^field_or_expr_less(args) 56 | ) 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/desc_nulls_last.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.DescNullsLast do 2 | @behaviour Paginator.Ecto.Query.DynamicFilterBuilder 3 | 4 | import Ecto.Query 5 | import Paginator.Ecto.Query.FieldOrExpression 6 | 7 | @impl Paginator.Ecto.Query.DynamicFilterBuilder 8 | def build_dynamic_filter(%{direction: :before, value: nil, next_filters: true}) do 9 | raise("unstable sort order: nullable columns can't be used as the last term") 10 | end 11 | 12 | def build_dynamic_filter(args = %{direction: :before, value: nil}) do 13 | dynamic( 14 | [{query, args.entity_position}], 15 | (^field_or_expr_is_nil(args) and ^args.next_filters) or 16 | not (^field_or_expr_is_nil(args)) 17 | ) 18 | end 19 | 20 | def build_dynamic_filter(args = %{direction: :before, next_filters: true}) do 21 | dynamic( 22 | [{query, args.entity_position}], 23 | ^field_or_expr_greater(args) 24 | ) 25 | end 26 | 27 | def build_dynamic_filter(args = %{direction: :before}) do 28 | dynamic( 29 | [{query, args.entity_position}], 30 | (^field_or_expr_equal(args) and ^args.next_filters) or 31 | ^field_or_expr_greater(args) 32 | ) 33 | end 34 | 35 | def build_dynamic_filter(%{direction: :after, value: nil, next_filters: true}) do 36 | raise("unstable sort order: nullable columns can't be used as the last term") 37 | end 38 | 39 | def build_dynamic_filter(args = %{direction: :after, value: nil}) do 40 | dynamic( 41 | [{query, args.entity_position}], 42 | ^field_or_expr_is_nil(args) and ^args.next_filters 43 | ) 44 | end 45 | 46 | def build_dynamic_filter(args = %{direction: :after, next_filters: true}) do 47 | dynamic( 48 | [{query, args.entity_position}], 49 | ^field_or_expr_less(args) or ^field_or_expr_is_nil(args) 50 | ) 51 | end 52 | 53 | def build_dynamic_filter(args = %{direction: :after}) do 54 | dynamic( 55 | [{query, args.entity_position}], 56 | (^field_or_expr_equal(args) and ^args.next_filters) or 57 | ^field_or_expr_less(args) or 58 | ^field_or_expr_is_nil(args) 59 | ) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/dynamic_filter_builder.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.DynamicFilterBuilder do 2 | @dispatch_table %{ 3 | desc: Paginator.Ecto.Query.DescNullsFirst, 4 | desc_nulls_first: Paginator.Ecto.Query.DescNullsFirst, 5 | desc_nulls_last: Paginator.Ecto.Query.DescNullsLast, 6 | asc: Paginator.Ecto.Query.AscNullsLast, 7 | asc_nulls_last: Paginator.Ecto.Query.AscNullsLast, 8 | asc_nulls_first: Paginator.Ecto.Query.AscNullsFirst 9 | } 10 | 11 | @callback build_dynamic_filter(%{ 12 | direction: :after | :before, 13 | entity_position: integer(), 14 | column: term(), 15 | value: term(), 16 | next_filters: Ecto.Query.dynamic() | boolean() 17 | }) :: term() 18 | 19 | @type sort_order :: 20 | :asc 21 | | :asc_nulls_first 22 | | :asc_nulls_desc 23 | | :desc 24 | | :desc_nulls_first 25 | | :desc_nulls_last 26 | 27 | @type direction :: :after | :before 28 | 29 | @spec build!(%{ 30 | sort_order: sort_order(), 31 | direction: direction(), 32 | entity_position: integer(), 33 | column: term(), 34 | value: term(), 35 | next_filters: Ecto.Query.dynamic() | boolean() 36 | }) :: term() 37 | def build!(input) do 38 | case Map.fetch(@dispatch_table, input.sort_order) do 39 | {:ok, module} -> 40 | apply(module, :build_dynamic_filter, [input]) 41 | 42 | :error -> 43 | direction = input.direction 44 | available_sort_orders = Map.keys(@dispatch_table) |> Enum.join(", ") 45 | 46 | raise( 47 | "Invalid sorting value :#{direction}, please please use either #{available_sort_orders}" 48 | ) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /lib/paginator/ecto/query/field_or_expression.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Ecto.Query.FieldOrExpression do 2 | import Ecto.Query 3 | 4 | def field_or_expr_is_nil(%{column: {_, handler}}) do 5 | dynamic([{query, args.entity_position}], is_nil(^handler.())) 6 | end 7 | 8 | def field_or_expr_is_nil(args) do 9 | dynamic([{query, args.entity_position}], is_nil(field(query, ^args.column))) 10 | end 11 | 12 | def field_or_expr_equal(%{column: {_, handler}, value: value}) do 13 | dynamic([{query, args.entity_position}], ^handler.() == ^value) 14 | end 15 | 16 | def field_or_expr_equal(args) do 17 | dynamic([{query, args.entity_position}], field(query, ^args.column) == ^args.value) 18 | end 19 | 20 | def field_or_expr_less(%{column: {_, handler}, value: value}) do 21 | dynamic([{query, args.entity_position}], ^handler.() < ^value) 22 | end 23 | 24 | def field_or_expr_less(args) do 25 | dynamic([{query, args.entity_position}], field(query, ^args.column) < ^args.value) 26 | end 27 | 28 | def field_or_expr_greater(%{column: {_, handler}, value: value}) do 29 | dynamic([{query, args.entity_position}], ^handler.() > ^value) 30 | end 31 | 32 | def field_or_expr_greater(args) do 33 | dynamic([{query, args.entity_position}], field(query, ^args.column) > ^args.value) 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/paginator/page.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Page do 2 | @moduledoc """ 3 | Defines a page. 4 | 5 | ## Fields 6 | 7 | * `entries` - a list entries contained in this page. 8 | * `metadata` - metadata attached to this page. 9 | """ 10 | 11 | @type t :: %__MODULE__{ 12 | entries: [any()] | [], 13 | metadata: Paginator.Page.Metadata.t() 14 | } 15 | 16 | defstruct [:metadata, :entries] 17 | end 18 | -------------------------------------------------------------------------------- /lib/paginator/page/metadata.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Page.Metadata do 2 | @moduledoc """ 3 | Defines page metadata. 4 | 5 | ## Fields 6 | 7 | * `after` - an opaque cursor representing the last row of the current page. 8 | * `before` - an opaque cursor representing the first row of the current page. 9 | * `limit` - the maximum number of entries that can be contained in this page. 10 | * `total_count` - the total number of entries matching the query. 11 | * `total_count_cap_exceeded` - a boolean indicating whether the `:total_count_limit` 12 | was exceeded. 13 | """ 14 | 15 | @type opaque_cursor :: String.t() 16 | 17 | @type t :: %__MODULE__{ 18 | after: opaque_cursor() | nil, 19 | before: opaque_cursor() | nil, 20 | limit: integer(), 21 | total_count: integer() | nil, 22 | total_count_cap_exceeded: boolean() | nil 23 | } 24 | 25 | defstruct [:after, :before, :limit, :total_count, :total_count_cap_exceeded] 26 | end 27 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Mixfile do 2 | use Mix.Project 3 | 4 | @source_url "https://github.com/duffelhq/paginator" 5 | @version "1.2.0" 6 | 7 | def project do 8 | [ 9 | app: :paginator, 10 | name: "Paginator", 11 | version: @version, 12 | elixir: "~> 1.5", 13 | elixirc_options: [warnings_as_errors: System.get_env("CI") == "true"], 14 | elixirc_paths: elixirc_paths(Mix.env()), 15 | build_embedded: Mix.env() == :prod, 16 | start_permanent: Mix.env() == :prod, 17 | deps: deps(), 18 | dialyzer: [ 19 | plt_file: {:no_warn, "priv/plts/dialyzer.plt"} 20 | ], 21 | package: package(), 22 | docs: docs() 23 | ] 24 | end 25 | 26 | def application do 27 | [extra_applications: [:logger]] 28 | end 29 | 30 | defp elixirc_paths(:test), do: ["lib", "test/support"] 31 | defp elixirc_paths(_), do: ["lib"] 32 | 33 | defp deps do 34 | [ 35 | {:calendar, "~> 1.0.0", only: :test}, 36 | {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, 37 | {:ecto, "~> 3.0"}, 38 | {:ecto_sql, "~> 3.0"}, 39 | {:ex_doc, "~> 0.18", only: :dev, runtime: false}, 40 | {:ex_machina, "~> 2.1", only: :test}, 41 | {:inch_ex, "~> 2.0", only: [:dev, :test]}, 42 | {:postgrex, "~> 0.13", optional: true}, 43 | {:plug_crypto, "~> 1.2.0"} 44 | ] 45 | end 46 | 47 | defp package do 48 | [ 49 | description: "Cursor based pagination for Elixir Ecto.", 50 | maintainers: ["Steve Domin"], 51 | licenses: ["MIT"], 52 | links: %{ 53 | "Changelog" => "https://hexdocs.pm/paginator/changelog.html", 54 | "GitHub" => @source_url 55 | } 56 | ] 57 | end 58 | 59 | defp docs do 60 | [ 61 | extras: [ 62 | "CHANGELOG.md", 63 | "LICENSE.md": [title: "License"], 64 | "README.md": [title: "Overview"] 65 | ], 66 | main: "readme", 67 | canonical: "http://hexdocs.pm/paginator", 68 | source_url: @source_url, 69 | source_ref: "v#{@version}", 70 | formatters: ["html"] 71 | ] 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "calendar": {:hex, :calendar, "1.0.0", "f52073a708528482ec33d0a171954ca610fe2bd28f1e871f247dc7f1565fa807", [:mix], [{:tzdata, "~> 0.5.20 or ~> 0.1.201603 or ~> 1.0", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "990e9581920c82912a5ee50e62ff5ef96da6b15949a2ee4734f935fdef0f0a6f"}, 4 | "certifi": {:hex, :certifi, "2.6.1", "dbab8e5e155a0763eea978c913ca280a6b544bfa115633fa20249c3d396d9493", [:rebar3], [], "hexpm", "524c97b4991b3849dd5c17a631223896272c6b0af446778ba4675a1dff53bb7e"}, 5 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, 6 | "db_connection": {:hex, :db_connection, "2.4.1", "6411f6e23f1a8b68a82fa3a36366d4881f21f47fc79a9efb8c615e62050219da", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ea36d226ec5999781a9a8ad64e5d8c4454ecedc7a4d643e4832bf08efca01f00"}, 7 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, 8 | "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, 9 | "earmark_parser": {:hex, :earmark_parser, "1.4.20", "89970db71b11b6b89759ce16807e857df154f8df3e807b2920a8c39834a9e5cf", [:mix], [], "hexpm", "1eb0d2dabeeeff200e0d17dc3048a6045aab271f73ebb82e416464832eb57bdd"}, 10 | "ecto": {:hex, :ecto, "3.6.2", "efdf52acfc4ce29249bab5417415bd50abd62db7b0603b8bab0d7b996548c2bc", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "efad6dfb04e6f986b8a3047822b0f826d9affe8e4ebdd2aeedbfcb14fd48884e"}, 11 | "ecto_sql": {:hex, :ecto_sql, "3.6.2", "9526b5f691701a5181427634c30655ac33d11e17e4069eff3ae1176c764e0ba3", [:mix], [{:db_connection, "~> 2.2", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.6.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.4.0 or ~> 0.5.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5ec9d7e6f742ea39b63aceaea9ac1d1773d574ea40df5a53ef8afbd9242fdb6b"}, 12 | "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, 13 | "ex_doc": {:hex, :ex_doc, "0.28.2", "e031c7d1a9fc40959da7bf89e2dc269ddc5de631f9bd0e326cbddf7d8085a9da", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "51ee866993ffbd0e41c084a7677c570d0fc50cb85c6b5e76f8d936d9587fa719"}, 14 | "ex_machina": {:hex, :ex_machina, "2.7.0", "b792cc3127fd0680fecdb6299235b4727a4944a09ff0fa904cc639272cd92dc7", [:mix], [{:ecto, "~> 2.2 or ~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_sql, "~> 3.0", [hex: :ecto_sql, repo: "hexpm", optional: true]}], "hexpm", "419aa7a39bde11894c87a615c4ecaa52d8f107bbdd81d810465186f783245bf8"}, 15 | "hackney": {:hex, :hackney, "1.17.4", "99da4674592504d3fb0cfef0db84c3ba02b4508bae2dff8c0108baa0d6e0977c", [:rebar3], [{:certifi, "~>2.6.1", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "de16ff4996556c8548d512f4dbe22dd58a587bf3332e7fd362430a7ef3986b16"}, 16 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 17 | "inch_ex": {:hex, :inch_ex, "2.0.0", "24268a9284a1751f2ceda569cd978e1fa394c977c45c331bb52a405de544f4de", [:mix], [{:bunt, "~> 0.2", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "96d0ec5ecac8cf63142d02f16b7ab7152cf0f0f1a185a80161b758383c9399a8"}, 18 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 19 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 20 | "makeup_elixir": {:hex, :makeup_elixir, "0.15.2", "dc72dfe17eb240552857465cc00cce390960d9a0c055c4ccd38b70629227e97c", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.1", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "fd23ae48d09b32eff49d4ced2b43c9f086d402ee4fd4fcb2d7fad97fa8823e75"}, 21 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, 22 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 23 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 24 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.2", "b99ca56bbce410e9d5ee4f9155a212e942e224e259c7ebbf8f2c86ac21d4fa3c", [:mix], [], "hexpm", "98d51bd64d5f6a2a9c6bb7586ee8129e27dfaab1140b5a4753f24dac0ba27d2f"}, 25 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 26 | "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, 27 | "postgrex": {:hex, :postgrex, "0.15.13", "7794e697481799aee8982688c261901de493eb64451feee6ea58207d7266d54a", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "3ffb76e1a97cfefe5c6a95632a27ffb67f28871c9741fb585f9d1c3cd2af70f1"}, 28 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 29 | "telemetry": {:hex, :telemetry, "0.4.3", "a06428a514bdbc63293cd9a6263aad00ddeb66f608163bdec7c8995784080818", [:rebar3], [], "hexpm", "eb72b8365ffda5bed68a620d1da88525e326cb82a75ee61354fc24b844768041"}, 30 | "tzdata": {:hex, :tzdata, "1.1.0", "72f5babaa9390d0f131465c8702fa76da0919e37ba32baa90d93c583301a8359", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "18f453739b48d3dc5bcf0e8906d2dc112bb40baafe2c707596d89f3c8dd14034"}, 31 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 32 | } 33 | -------------------------------------------------------------------------------- /test/paginator/config_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Paginator.ConfigTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Paginator.{Config, Cursor} 5 | 6 | describe "Config.new/2" do 7 | test "creates a new config" do 8 | config = Config.new(cursor_fields: [:id], limit: 10) 9 | 10 | assert config.after_values == nil 11 | assert config.before_values == nil 12 | assert config.limit == 10 13 | assert config.cursor_fields == [id: :asc] 14 | end 15 | end 16 | 17 | describe "Config.new/2 applies sort_fields" do 18 | test "applies column fields with default direction" do 19 | config = Config.new(cursor_fields: [:id]) 20 | 21 | assert config.cursor_fields == [id: :asc] 22 | end 23 | 24 | test "applies column fields with alternate direction" do 25 | config = Config.new(cursor_fields: [:id], sort_direction: :desc) 26 | 27 | assert config.cursor_fields == [id: :desc] 28 | end 29 | 30 | test "applies column with direction tuples" do 31 | config = Config.new(cursor_fields: [id: :desc], sort_direction: :asc) 32 | 33 | assert config.cursor_fields == [id: :desc] 34 | end 35 | 36 | test "applies column with direction tuples mixed with column fields" do 37 | config = Config.new(cursor_fields: [{:id, :desc}, :name], sort_direction: :asc) 38 | 39 | assert config.cursor_fields == [id: :desc, name: :asc] 40 | end 41 | 42 | test "applies {binding, column} tuples with direction" do 43 | config = Config.new(cursor_fields: [{{:payments, :id}, :desc}], sort_direction: :asc) 44 | 45 | assert config.cursor_fields == [{{:payments, :id}, :desc}] 46 | end 47 | 48 | test "applies {binding, column} tuples without direction" do 49 | config = Config.new(cursor_fields: [{:payments, :id}], sort_direction: :asc) 50 | 51 | assert config.cursor_fields == [{{:payments, :id}, :asc}] 52 | end 53 | end 54 | 55 | describe "Config.new/2 applies min/max limit" do 56 | test "applies default limit" do 57 | config = Config.new() 58 | assert config.limit == 50 59 | assert config.total_count_primary_key_field == :id 60 | end 61 | 62 | test "applies minimum limit" do 63 | config = Config.new(limit: 0) 64 | assert config.limit == 1 65 | end 66 | 67 | test "applies maximum limit" do 68 | config = Config.new(limit: 1000) 69 | assert config.limit == 500 70 | end 71 | 72 | test "respects configured maximum limit" do 73 | config = Config.new(limit: 1000, maximum_limit: 2000) 74 | assert config.limit == 1000 75 | 76 | config = Config.new(limit: 3000, maximum_limit: 2000) 77 | assert config.limit == 2000 78 | end 79 | end 80 | 81 | describe "Config.new/2 decodes cusors" do 82 | test "simple before" do 83 | config = Config.new(limit: 10, cursor_fields: [:id], before: simple_before()) 84 | 85 | assert config.after_values == nil 86 | assert config.before_values == %{id: "pay_789"} 87 | assert config.limit == 10 88 | assert config.cursor_fields == [id: :asc] 89 | end 90 | 91 | test "simple after" do 92 | config = Config.new(limit: 10, cursor_fields: [:id], after: simple_after()) 93 | 94 | assert config.after_values == %{id: "pay_123"} 95 | assert config.before_values == nil 96 | assert config.limit == 10 97 | assert config.cursor_fields == [id: :asc] 98 | end 99 | 100 | test "complex before" do 101 | config = Config.new(limit: 10, cursor_fields: [:created_at, :id], before: complex_before()) 102 | 103 | assert config.after_values == nil 104 | assert config.before_values == %{date: "2036-02-09T20:00:00.000Z", id: "pay_789"} 105 | assert config.limit == 10 106 | assert config.cursor_fields == [created_at: :asc, id: :asc] 107 | end 108 | 109 | test "complex after" do 110 | config = Config.new(limit: 10, cursor_fields: [:created_at, :id], after: complex_after()) 111 | 112 | assert config.after_values == %{date: "2036-02-09T20:00:00.000Z", id: "pay_123"} 113 | assert config.before_values == nil 114 | assert config.limit == 10 115 | assert config.cursor_fields == [created_at: :asc, id: :asc] 116 | end 117 | end 118 | 119 | describe "Config.validate!/1" do 120 | test "raises ArgumentError when cursor_fields are not set" do 121 | config = Config.new([]) 122 | 123 | assert_raise Config.ArgumentError, "expected `:cursor_fields` to be set", fn -> 124 | Config.validate!(config) 125 | end 126 | end 127 | 128 | test "raises ArgumentError when after cursor does not match the cursor_fields" do 129 | config = Config.new(cursor_fields: [:date], after: complex_after()) 130 | 131 | assert_raise Config.ArgumentError, 132 | "expected `:after` cursor to match `:cursor_fields`", 133 | fn -> 134 | Config.validate!(config) 135 | end 136 | end 137 | 138 | test "raises ArgumentError when after cursor does not match cursor_fields with schema" do 139 | config = 140 | Config.new( 141 | cursor_fields: [{:person, :first_name}, {{:person, :last_name}, :asc}], 142 | after: 143 | Cursor.encode(%{ 144 | {:person, :first_name} => "Test 121", 145 | {:person, :birth_date} => "1990-10-10" 146 | }) 147 | ) 148 | 149 | assert_raise Config.ArgumentError, 150 | "expected `:after` cursor to match `:cursor_fields`", 151 | fn -> 152 | Config.validate!(config) 153 | end 154 | end 155 | 156 | test "ok when after cursor matches cursor_fields with schema" do 157 | config = 158 | Config.new( 159 | cursor_fields: [{:person, :first_name}, {{:person, :last_name}, :asc}], 160 | after: 161 | Cursor.encode(%{ 162 | {:person, :first_name} => "Test 121", 163 | {:person, :last_name} => "Test" 164 | }) 165 | ) 166 | 167 | Config.validate!(config) 168 | end 169 | 170 | test "raises ArgumentError when before cursor does not match the cursor_fields" do 171 | config = Config.new(cursor_fields: [:id], before: complex_before()) 172 | 173 | assert_raise Config.ArgumentError, 174 | "expected `:before` cursor to match `:cursor_fields`", 175 | fn -> 176 | Config.validate!(config) 177 | end 178 | end 179 | end 180 | 181 | def simple_after, do: Cursor.encode(%{id: "pay_123"}) 182 | def simple_before, do: Cursor.encode(%{id: "pay_789"}) 183 | def complex_after, do: Cursor.encode(%{date: "2036-02-09T20:00:00.000Z", id: "pay_123"}) 184 | def complex_before, do: Cursor.encode(%{date: "2036-02-09T20:00:00.000Z", id: "pay_789"}) 185 | end 186 | -------------------------------------------------------------------------------- /test/paginator/cursor_test.exs: -------------------------------------------------------------------------------- 1 | defmodule Paginator.CursorTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Paginator.Cursor 5 | 6 | describe "encoding and decoding terms" do 7 | test "it encodes and decodes map cursors" do 8 | cursor = Cursor.encode(%{a: 1, b: 2}) 9 | 10 | assert Cursor.decode(cursor) == %{a: 1, b: 2} 11 | end 12 | end 13 | 14 | describe "Cursor.decode/1" do 15 | test "it safely decodes user input" do 16 | assert_raise ArgumentError, fn -> 17 | # this binary represents the atom :fubar_0a1b2c3d4e 18 | <<131, 100, 0, 16, "fubar_0a1b2c3d4e">> 19 | |> Base.url_encode64() 20 | |> Cursor.decode() 21 | end 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/paginator_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaginatorTest do 2 | use Paginator.DataCase 3 | doctest Paginator 4 | 5 | alias Calendar.DateTime, as: DT 6 | 7 | alias Paginator.Cursor 8 | 9 | setup :create_customers_and_payments 10 | 11 | test "paginates forward", %{ 12 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 13 | } do 14 | opts = [cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 4] 15 | 16 | page = payments_by_charged_at() |> Repo.paginate(opts) 17 | assert to_ids(page.entries) == to_ids([p5, p4, p1, p6]) 18 | assert page.metadata.after == encode_cursor(%{charged_at: p6.charged_at, id: p6.id}) 19 | 20 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: page.metadata.after]) 21 | assert to_ids(page.entries) == to_ids([p7, p3, p10, p2]) 22 | assert page.metadata.after == encode_cursor(%{charged_at: p2.charged_at, id: p2.id}) 23 | 24 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: page.metadata.after]) 25 | assert to_ids(page.entries) == to_ids([p12, p8, p9, p11]) 26 | assert page.metadata.after == nil 27 | end 28 | 29 | test "paginates forward with legacy cursor", %{ 30 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 31 | } do 32 | opts = [cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 4] 33 | 34 | page = payments_by_charged_at() |> Repo.paginate(opts) 35 | assert to_ids(page.entries) == to_ids([p5, p4, p1, p6]) 36 | assert %{charged_at: charged_at, id: id} = Cursor.decode(page.metadata.after) 37 | assert charged_at == p6.charged_at 38 | assert id == p6.id 39 | 40 | legacy_cursor = encode_legacy_cursor([charged_at, id]) 41 | 42 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: legacy_cursor]) 43 | assert to_ids(page.entries) == to_ids([p7, p3, p10, p2]) 44 | assert %{charged_at: charged_at, id: id} = Cursor.decode(page.metadata.after) 45 | assert charged_at == p2.charged_at 46 | assert id == p2.id 47 | 48 | legacy_cursor = encode_legacy_cursor([charged_at, id]) 49 | 50 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [after: legacy_cursor]) 51 | assert to_ids(page.entries) == to_ids([p12, p8, p9, p11]) 52 | assert page.metadata.after == nil 53 | end 54 | 55 | test "paginates backward", %{ 56 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 57 | } do 58 | opts = [cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 4] 59 | 60 | page = 61 | payments_by_charged_at() 62 | |> Repo.paginate(opts ++ [before: encode_cursor(%{charged_at: p11.charged_at, id: p11.id})]) 63 | 64 | assert to_ids(page.entries) == to_ids([p2, p12, p8, p9]) 65 | assert page.metadata.before == encode_cursor(%{charged_at: p2.charged_at, id: p2.id}) 66 | 67 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [before: page.metadata.before]) 68 | assert to_ids(page.entries) == to_ids([p6, p7, p3, p10]) 69 | assert page.metadata.before == encode_cursor(%{charged_at: p6.charged_at, id: p6.id}) 70 | 71 | page = payments_by_charged_at() |> Repo.paginate(opts ++ [before: page.metadata.before]) 72 | assert to_ids(page.entries) == to_ids([p5, p4, p1]) 73 | assert page.metadata.after == encode_cursor(%{charged_at: p1.charged_at, id: p1.id}) 74 | assert page.metadata.before == nil 75 | end 76 | 77 | test "returns an empty page when there are no results" do 78 | page = 79 | payments_by_status("failed") 80 | |> Repo.paginate(cursor_fields: [:charged_at, :id], limit: 10) 81 | 82 | assert page.entries == [] 83 | assert page.metadata.after == nil 84 | assert page.metadata.before == nil 85 | end 86 | 87 | describe "paginate a collection of payments, sorting by charged_at" do 88 | test "sorts ascending without cursors", %{ 89 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 90 | } do 91 | %Page{entries: entries, metadata: metadata} = 92 | payments_by_charged_at() 93 | |> Repo.paginate(cursor_fields: [:charged_at, :id], sort_direction: :asc, limit: 50) 94 | 95 | assert to_ids(entries) == to_ids([p5, p4, p1, p6, p7, p3, p10, p2, p12, p8, p9, p11]) 96 | assert metadata == %Metadata{after: nil, before: nil, limit: 50} 97 | end 98 | 99 | test "sorts ascending with before cursor", %{ 100 | payments: {p1, p2, p3, _p4, _p5, p6, p7, p8, p9, p10, _p11, p12} 101 | } do 102 | %Page{entries: entries, metadata: metadata} = 103 | payments_by_charged_at() 104 | |> Repo.paginate( 105 | cursor_fields: [:charged_at, :id], 106 | sort_direction: :asc, 107 | before: encode_cursor(%{charged_at: p9.charged_at, id: p9.id}), 108 | limit: 8 109 | ) 110 | 111 | assert to_ids(entries) == to_ids([p1, p6, p7, p3, p10, p2, p12, p8]) 112 | 113 | assert metadata == %Metadata{ 114 | after: encode_cursor(%{charged_at: p8.charged_at, id: p8.id}), 115 | before: encode_cursor(%{charged_at: p1.charged_at, id: p1.id}), 116 | limit: 8 117 | } 118 | end 119 | 120 | test "sorts ascending with after cursor", %{ 121 | payments: {_p1, p2, p3, _p4, _p5, _p6, _p7, p8, p9, p10, p11, p12} 122 | } do 123 | %Page{entries: entries, metadata: metadata} = 124 | payments_by_charged_at() 125 | |> Repo.paginate( 126 | cursor_fields: [:charged_at, :id], 127 | sort_direction: :asc, 128 | after: encode_cursor(%{charged_at: p3.charged_at, id: p3.id}), 129 | limit: 8 130 | ) 131 | 132 | assert to_ids(entries) == to_ids([p10, p2, p12, p8, p9, p11]) 133 | 134 | assert metadata == %Metadata{ 135 | after: nil, 136 | before: encode_cursor(%{charged_at: p10.charged_at, id: p10.id}), 137 | limit: 8 138 | } 139 | end 140 | 141 | test "sorts ascending with before and after cursor", %{ 142 | payments: {_p1, p2, p3, _p4, _p5, _p6, _p7, p8, _p9, p10, _p11, p12} 143 | } do 144 | %Page{entries: entries, metadata: metadata} = 145 | payments_by_charged_at() 146 | |> Repo.paginate( 147 | cursor_fields: [:charged_at, :id], 148 | sort_direction: :asc, 149 | after: encode_cursor(%{charged_at: p3.charged_at, id: p3.id}), 150 | before: encode_cursor(%{charged_at: p8.charged_at, id: p8.id}), 151 | limit: 8 152 | ) 153 | 154 | assert to_ids(entries) == to_ids([p10, p2, p12]) 155 | 156 | assert metadata == %Metadata{ 157 | after: encode_cursor(%{charged_at: p12.charged_at, id: p12.id}), 158 | before: encode_cursor(%{charged_at: p10.charged_at, id: p10.id}), 159 | limit: 8 160 | } 161 | end 162 | 163 | test "sorts descending without cursors", %{ 164 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 165 | } do 166 | %Page{entries: entries, metadata: metadata} = 167 | payments_by_charged_at(:desc) 168 | |> Repo.paginate(cursor_fields: [:charged_at, :id], sort_direction: :desc, limit: 50) 169 | 170 | assert to_ids(entries) == to_ids([p11, p9, p8, p12, p2, p10, p3, p7, p6, p1, p4, p5]) 171 | assert metadata == %Metadata{after: nil, before: nil, limit: 50} 172 | end 173 | 174 | test "sorts descending with before cursor", %{ 175 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, p9, _p10, p11, _p12} 176 | } do 177 | %Page{entries: entries, metadata: metadata} = 178 | payments_by_charged_at(:desc) 179 | |> Repo.paginate( 180 | cursor_fields: [:charged_at, :id], 181 | sort_direction: :desc, 182 | before: encode_cursor(%{charged_at: p9.charged_at, id: p9.id}), 183 | limit: 8 184 | ) 185 | 186 | assert to_ids(entries) == to_ids([p11]) 187 | 188 | assert metadata == %Metadata{ 189 | after: encode_cursor(%{charged_at: p11.charged_at, id: p11.id}), 190 | before: nil, 191 | limit: 8 192 | } 193 | end 194 | 195 | test "sorts descending with after cursor", %{ 196 | payments: {p1, p2, p3, _p4, _p5, p6, p7, p8, p9, p10, _p11, p12} 197 | } do 198 | %Page{entries: entries, metadata: metadata} = 199 | payments_by_charged_at(:desc) 200 | |> Repo.paginate( 201 | cursor_fields: [:charged_at, :id], 202 | sort_direction: :desc, 203 | after: encode_cursor(%{charged_at: p9.charged_at, id: p9.id}), 204 | limit: 8 205 | ) 206 | 207 | assert to_ids(entries) == to_ids([p8, p12, p2, p10, p3, p7, p6, p1]) 208 | 209 | assert metadata == %Metadata{ 210 | after: encode_cursor(%{charged_at: p1.charged_at, id: p1.id}), 211 | before: encode_cursor(%{charged_at: p8.charged_at, id: p8.id}), 212 | limit: 8 213 | } 214 | end 215 | 216 | test "sorts descending with before and after cursor", %{ 217 | payments: {_p1, p2, p3, _p4, _p5, _p6, _p7, p8, p9, p10, _p11, p12} 218 | } do 219 | %Page{entries: entries, metadata: metadata} = 220 | payments_by_charged_at(:desc) 221 | |> Repo.paginate( 222 | cursor_fields: [:charged_at, :id], 223 | sort_direction: :desc, 224 | after: encode_cursor(%{charged_at: p9.charged_at, id: p9.id}), 225 | before: encode_cursor(%{charged_at: p3.charged_at, id: p3.id}), 226 | limit: 8 227 | ) 228 | 229 | assert to_ids(entries) == to_ids([p8, p12, p2, p10]) 230 | 231 | assert metadata == %Metadata{ 232 | after: encode_cursor(%{charged_at: p10.charged_at, id: p10.id}), 233 | before: encode_cursor(%{charged_at: p8.charged_at, id: p8.id}), 234 | limit: 8 235 | } 236 | end 237 | 238 | test "sorts ascending with before cursor at beginning of collection", %{ 239 | payments: {_p1, _p2, _p3, _p4, p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 240 | } do 241 | %Page{entries: entries, metadata: metadata} = 242 | payments_by_charged_at() 243 | |> Repo.paginate( 244 | cursor_fields: [:charged_at, :id], 245 | sort_direction: :asc, 246 | before: encode_cursor(%{charged_at: p5.charged_at, id: p5.id}), 247 | limit: 8 248 | ) 249 | 250 | assert to_ids(entries) == to_ids([]) 251 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 252 | end 253 | 254 | test "sorts ascending with after cursor at end of collection", %{ 255 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, p11, _p12} 256 | } do 257 | %Page{entries: entries, metadata: metadata} = 258 | payments_by_charged_at() 259 | |> Repo.paginate( 260 | cursor_fields: [:charged_at, :id], 261 | sort_direction: :asc, 262 | after: encode_cursor(%{charged_at: p11.charged_at, id: p11.id}), 263 | limit: 8 264 | ) 265 | 266 | assert to_ids(entries) == to_ids([]) 267 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 268 | end 269 | 270 | test "sorts descending with before cursor at beginning of collection", %{ 271 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, p11, _p12} 272 | } do 273 | %Page{entries: entries, metadata: metadata} = 274 | payments_by_charged_at(:desc) 275 | |> Repo.paginate( 276 | cursor_fields: [:charged_at, :id], 277 | sort_direction: :desc, 278 | before: encode_cursor(%{charged_at: p11.charged_at, id: p11.id}), 279 | limit: 8 280 | ) 281 | 282 | assert to_ids(entries) == to_ids([]) 283 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 284 | end 285 | 286 | test "sorts descending with after cursor at end of collection", %{ 287 | payments: {_p1, _p2, _p3, _p4, p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 288 | } do 289 | %Page{entries: entries, metadata: metadata} = 290 | payments_by_charged_at(:desc) 291 | |> Repo.paginate( 292 | cursor_fields: [:charged_at, :id], 293 | sort_direction: :desc, 294 | after: encode_cursor(%{charged_at: p5.charged_at, id: p5.id}), 295 | limit: 8 296 | ) 297 | 298 | assert to_ids(entries) == to_ids([]) 299 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 300 | end 301 | end 302 | 303 | describe "paginate a collection of payments with customer filter, sorting by amount, charged_at" do 304 | test "multiple cursor_fields with pre-existing where filter in query", %{ 305 | customers: {c1, _c2, _c3}, 306 | payments: {_p1, _p2, _p3, _p4, p5, p6, p7, p8, _p9, _p10, _p11, _p12} 307 | } do 308 | %Page{entries: entries, metadata: metadata} = 309 | customer_payments_by_charged_at_and_amount(c1) 310 | |> Repo.paginate(cursor_fields: [:charged_at, :amount, :id], limit: 2) 311 | 312 | assert to_ids(entries) == to_ids([p5, p6]) 313 | 314 | %Page{entries: entries, metadata: _metadata} = 315 | customer_payments_by_charged_at_and_amount(c1) 316 | |> Repo.paginate( 317 | cursor_fields: [:charged_at, :amount, :id], 318 | limit: 2, 319 | after: metadata.after 320 | ) 321 | 322 | assert to_ids(entries) == to_ids([p7, p8]) 323 | end 324 | 325 | test "before cursor with multiple cursor_fields and pre-existing where filter in query", %{ 326 | customers: {c1, _c2, _c3}, 327 | payments: {_p1, _p2, _p3, _p4, _p5, p6, _p7, _p8, _p9, _p10, _p11, _p12} 328 | } do 329 | assert %Page{entries: [], metadata: _metadata} = 330 | customer_payments_by_charged_at_and_amount(c1) 331 | |> Repo.paginate( 332 | cursor_fields: [:amount, :charged_at, :id], 333 | before: 334 | encode_cursor(%{amount: p6.amount, charged_at: p6.charged_at, id: p6.id}), 335 | limit: 1 336 | ) 337 | end 338 | end 339 | 340 | describe "paginate a collection of payments, sorting by customer name" do 341 | test "raises error when binding not found", %{ 342 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, p11, _p12} 343 | } do 344 | assert_raise ArgumentError, 345 | "Could not find binding `bogus_binding` in query aliases: %{customer: 1, payments: 0}", 346 | fn -> 347 | %Page{} = 348 | payments_by_customer_name() 349 | |> Repo.paginate( 350 | cursor_fields: [ 351 | {{:bogus_binding, :id}, :asc}, 352 | {{:bogus_binding, :name}, :asc} 353 | ], 354 | limit: 50, 355 | before: 356 | encode_cursor(%{ 357 | {:bogus_binding, :id} => p11.id, 358 | {:bogus_binding, :name} => p11.customer.name 359 | }) 360 | ) 361 | end 362 | end 363 | 364 | test "sorts with mixed bindingless, bound columns", %{ 365 | payments: {_p1, _p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, _p12} 366 | } do 367 | %Page{entries: entries, metadata: metadata} = 368 | payments_by_customer_name() 369 | |> Repo.paginate( 370 | cursor_fields: [{:id, :asc}, {{:customer, :name}, :asc}], 371 | before: encode_cursor(%{:id => p11.id, {:customer, :name} => p11.customer.name}), 372 | limit: 8 373 | ) 374 | 375 | assert to_ids(entries) == to_ids([p3, p4, p5, p6, p7, p8, p9, p10]) 376 | 377 | assert metadata == %Metadata{ 378 | after: encode_cursor(%{:id => p10.id, {:customer, :name} => p10.customer.name}), 379 | before: encode_cursor(%{:id => p3.id, {:customer, :name} => p3.customer.name}), 380 | limit: 8 381 | } 382 | end 383 | 384 | test "sorts with mixed columns without direction and bound columns", %{ 385 | payments: {_p1, _p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, _p12} 386 | } do 387 | %Page{entries: entries, metadata: metadata} = 388 | payments_by_customer_name() 389 | |> Repo.paginate( 390 | cursor_fields: [:id, {{:customer, :name}, :asc}], 391 | before: encode_cursor(%{:id => p11.id, {:customer, :name} => p11.customer.name}), 392 | limit: 8 393 | ) 394 | 395 | assert to_ids(entries) == to_ids([p3, p4, p5, p6, p7, p8, p9, p10]) 396 | 397 | assert metadata == %Metadata{ 398 | after: encode_cursor(%{:id => p10.id, {:customer, :name} => p10.customer.name}), 399 | before: encode_cursor(%{:id => p3.id, {:customer, :name} => p3.customer.name}), 400 | limit: 8 401 | } 402 | end 403 | 404 | test "sorts ascending without cursors", %{ 405 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 406 | } do 407 | %Page{entries: entries, metadata: metadata} = 408 | payments_by_customer_name() 409 | |> Repo.paginate( 410 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 411 | limit: 50 412 | ) 413 | 414 | assert to_ids(entries) == to_ids([p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12]) 415 | assert metadata == %Metadata{after: nil, before: nil, limit: 50} 416 | end 417 | 418 | test "sorts ascending with before cursor", %{ 419 | payments: {_p1, _p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, _p12} 420 | } do 421 | %Page{entries: entries, metadata: metadata} = 422 | payments_by_customer_name() 423 | |> Repo.paginate( 424 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 425 | before: 426 | encode_cursor(%{{:payments, :id} => p11.id, {:customer, :name} => p11.customer.name}), 427 | limit: 8 428 | ) 429 | 430 | assert to_ids(entries) == to_ids([p3, p4, p5, p6, p7, p8, p9, p10]) 431 | 432 | assert metadata == %Metadata{ 433 | after: 434 | encode_cursor(%{ 435 | {:payments, :id} => p10.id, 436 | {:customer, :name} => p10.customer.name 437 | }), 438 | before: 439 | encode_cursor(%{ 440 | {:payments, :id} => p3.id, 441 | {:customer, :name} => p3.customer.name 442 | }), 443 | limit: 8 444 | } 445 | end 446 | 447 | test "sorts ascending with after cursor", %{ 448 | payments: {_p1, _p2, _p3, _p4, _p5, p6, p7, p8, p9, p10, p11, p12} 449 | } do 450 | %Page{entries: entries, metadata: metadata} = 451 | payments_by_customer_name() 452 | |> Repo.paginate( 453 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 454 | after: 455 | encode_cursor(%{{:payments, :id} => p6.id, {:customer, :name} => p6.customer.name}), 456 | limit: 8 457 | ) 458 | 459 | assert to_ids(entries) == to_ids([p7, p8, p9, p10, p11, p12]) 460 | 461 | assert metadata == %Metadata{ 462 | after: nil, 463 | before: 464 | encode_cursor(%{ 465 | {:payments, :id} => p7.id, 466 | {:customer, :name} => p7.customer.name 467 | }), 468 | limit: 8 469 | } 470 | end 471 | 472 | test "sorts ascending with before and after cursor", %{ 473 | payments: {_p1, _p2, _p3, _p4, _p5, p6, p7, p8, p9, p10, _p11, _p12} 474 | } do 475 | %Page{entries: entries, metadata: metadata} = 476 | payments_by_customer_name() 477 | |> Repo.paginate( 478 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 479 | after: 480 | encode_cursor(%{{:payments, :id} => p6.id, {:customer, :name} => p6.customer.name}), 481 | before: 482 | encode_cursor(%{{:payments, :id} => p10.id, {:customer, :name} => p10.customer.name}), 483 | limit: 8 484 | ) 485 | 486 | assert to_ids(entries) == to_ids([p7, p8, p9]) 487 | 488 | assert metadata == %Metadata{ 489 | after: 490 | encode_cursor(%{ 491 | {:payments, :id} => p9.id, 492 | {:customer, :name} => p9.customer.name 493 | }), 494 | before: 495 | encode_cursor(%{ 496 | {:payments, :id} => p7.id, 497 | {:customer, :name} => p7.customer.name 498 | }), 499 | limit: 8 500 | } 501 | end 502 | 503 | test "sorts descending without cursors", %{ 504 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 505 | } do 506 | %Page{entries: entries, metadata: metadata} = 507 | payments_by_customer_name(:desc, :desc) 508 | |> Repo.paginate( 509 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 510 | limit: 50 511 | ) 512 | 513 | assert to_ids(entries) == to_ids([p12, p11, p10, p9, p8, p7, p6, p5, p4, p3, p2, p1]) 514 | assert metadata == %Metadata{after: nil, before: nil, limit: 50} 515 | end 516 | 517 | test "sorts descending with before cursor", %{ 518 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, p11, p12} 519 | } do 520 | %Page{entries: entries, metadata: metadata} = 521 | payments_by_customer_name(:desc) 522 | |> Repo.paginate( 523 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 524 | before: 525 | encode_cursor(%{{:payments, :id} => p11.id, {:customer, :name} => p11.customer.name}), 526 | limit: 8 527 | ) 528 | 529 | assert to_ids(entries) == to_ids([p12]) 530 | 531 | assert metadata == %Metadata{ 532 | after: 533 | encode_cursor(%{ 534 | {:payments, :id} => p12.id, 535 | {:customer, :name} => p12.customer.name 536 | }), 537 | before: nil, 538 | limit: 8 539 | } 540 | end 541 | 542 | test "sorts descending with after cursor", %{ 543 | payments: {_p1, _p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, _p12} 544 | } do 545 | %Page{entries: entries, metadata: metadata} = 546 | payments_by_customer_name(:desc, :desc) 547 | |> Repo.paginate( 548 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 549 | sort_direction: :desc, 550 | after: 551 | encode_cursor(%{{:payments, :id} => p11.id, {:customer, :name} => p11.customer.name}), 552 | limit: 8 553 | ) 554 | 555 | assert to_ids(entries) == to_ids([p10, p9, p8, p7, p6, p5, p4, p3]) 556 | 557 | assert metadata == %Metadata{ 558 | after: 559 | encode_cursor(%{ 560 | {:payments, :id} => p3.id, 561 | {:customer, :name} => p3.customer.name 562 | }), 563 | before: 564 | encode_cursor(%{ 565 | {:payments, :id} => p10.id, 566 | {:customer, :name} => p10.customer.name 567 | }), 568 | limit: 8 569 | } 570 | end 571 | 572 | test "sorts descending with before and after cursor", %{ 573 | payments: {_p1, _p2, _p3, _p4, _p5, p6, p7, p8, p9, p10, p11, _p12} 574 | } do 575 | %Page{entries: entries, metadata: metadata} = 576 | payments_by_customer_name(:desc, :desc) 577 | |> Repo.paginate( 578 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 579 | after: 580 | encode_cursor(%{{:payments, :id} => p11.id, {:customer, :name} => p11.customer.name}), 581 | before: 582 | encode_cursor(%{{:payments, :id} => p6.id, {:customer, :name} => p6.customer.name}), 583 | limit: 8 584 | ) 585 | 586 | assert to_ids(entries) == to_ids([p10, p9, p8, p7]) 587 | 588 | assert metadata == %Metadata{ 589 | after: 590 | encode_cursor(%{ 591 | {:payments, :id} => p7.id, 592 | {:customer, :name} => p7.customer.name 593 | }), 594 | before: 595 | encode_cursor(%{ 596 | {:payments, :id} => p10.id, 597 | {:customer, :name} => p10.customer.name 598 | }), 599 | limit: 8 600 | } 601 | end 602 | 603 | test "sorts ascending with before cursor at beginning of collection", %{ 604 | payments: {p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 605 | } do 606 | %Page{entries: entries, metadata: metadata} = 607 | payments_by_customer_name() 608 | |> Repo.paginate( 609 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 610 | before: 611 | encode_cursor(%{{:payments, :id} => p1.id, {:customer, :name} => p1.customer.name}), 612 | limit: 8 613 | ) 614 | 615 | assert to_ids(entries) == to_ids([]) 616 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 617 | end 618 | 619 | test "sorts ascending with after cursor at end of collection", %{ 620 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, p12} 621 | } do 622 | %Page{entries: entries, metadata: metadata} = 623 | payments_by_customer_name() 624 | |> Repo.paginate( 625 | cursor_fields: [{{:payments, :id}, :asc}, {{:customer, :name}, :asc}], 626 | after: 627 | encode_cursor(%{{:payments, :id} => p12.id, {:customer, :name} => p12.customer.name}), 628 | limit: 8 629 | ) 630 | 631 | assert to_ids(entries) == to_ids([]) 632 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 633 | end 634 | 635 | test "sorts descending with before cursor at beginning of collection", %{ 636 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, p12} 637 | } do 638 | %Page{entries: entries, metadata: metadata} = 639 | payments_by_customer_name(:desc, :desc) 640 | |> Repo.paginate( 641 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 642 | before: 643 | encode_cursor(%{{:payments, :id} => p12.id, {:customer, :name} => p12.customer.name}), 644 | limit: 8 645 | ) 646 | 647 | assert to_ids(entries) == to_ids([]) 648 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 649 | end 650 | 651 | test "sorts descending with after cursor at end of collection", %{ 652 | payments: {p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 653 | } do 654 | %Page{entries: entries, metadata: metadata} = 655 | payments_by_customer_name(:desc, :desc) 656 | |> Repo.paginate( 657 | cursor_fields: [{{:payments, :id}, :desc}, {{:customer, :name}, :desc}], 658 | after: 659 | encode_cursor(%{{:payments, :id} => p1.id, {:customer, :name} => p1.customer.name}), 660 | limit: 8 661 | ) 662 | 663 | assert to_ids(entries) == to_ids([]) 664 | assert metadata == %Metadata{after: nil, before: nil, limit: 8} 665 | end 666 | 667 | test "sorts on 2nd level join column with a custom cursor value function", %{ 668 | payments: {_p1, _p2, _p3, _p4, p5, p6, p7, _p8, _p9, _p10, _p11, _p12} 669 | } do 670 | %Page{entries: entries, metadata: metadata} = 671 | payments_by_address_city() 672 | |> Repo.paginate( 673 | cursor_fields: [{{:address, :city}, :asc}, id: :asc], 674 | before: nil, 675 | limit: 3, 676 | fetch_cursor_value_fun: fn 677 | schema, {:address, :city} -> 678 | schema.customer.address.city 679 | 680 | schema, field -> 681 | Paginator.default_fetch_cursor_value(schema, field) 682 | end 683 | ) 684 | 685 | assert to_ids(entries) == to_ids([p5, p6, p7]) 686 | 687 | p7 = Repo.preload(p7, customer: :address) 688 | 689 | assert metadata == %Metadata{ 690 | after: 691 | encode_cursor(%{{:address, :city} => p7.customer.address.city, :id => p7.id}), 692 | before: nil, 693 | limit: 3 694 | } 695 | end 696 | 697 | test "sorts with respect to nil values", %{ 698 | payments: {_p1, _p2, _p3, _p4, _p5, _p6, p7, _p8, _p9, _p10, p11, _p12} 699 | } do 700 | %Page{entries: entries, metadata: metadata} = 701 | payments_by_charged_at(:desc) 702 | |> Repo.paginate( 703 | cursor_fields: [:charged_at, :id], 704 | sort_direction: :desc, 705 | after: encode_cursor(%{charged_at: nil, id: -1}), 706 | limit: 8 707 | ) 708 | 709 | assert Enum.count(entries) == 8 710 | 711 | assert metadata == %Metadata{ 712 | before: encode_cursor(%{charged_at: p11.charged_at, id: p11.id}), 713 | limit: 8, 714 | after: encode_cursor(%{charged_at: p7.charged_at, id: p7.id}) 715 | } 716 | end 717 | end 718 | 719 | test "applies a default limit if none is provided", %{ 720 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12} 721 | } do 722 | %Page{entries: entries, metadata: metadata} = 723 | payments_by_customer_name() 724 | |> Repo.paginate(cursor_fields: [:id], sort_direction: :asc) 725 | 726 | assert to_ids(entries) == to_ids([p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12]) 727 | assert metadata == %Metadata{after: nil, before: nil, limit: 50} 728 | end 729 | 730 | test "enforces the minimum limit", %{ 731 | payments: {p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 732 | } do 733 | %Page{entries: entries, metadata: metadata} = 734 | payments_by_customer_name() 735 | |> Repo.paginate(cursor_fields: [:id], sort_direction: :asc, limit: 0) 736 | 737 | assert to_ids(entries) == to_ids([p1]) 738 | assert metadata == %Metadata{after: encode_cursor(%{id: p1.id}), before: nil, limit: 1} 739 | end 740 | 741 | describe "with include_total_count" do 742 | test "when set to :infinity", %{ 743 | payments: {_p1, _p2, _p3, _p4, p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 744 | } do 745 | %Page{metadata: metadata} = 746 | payments_by_customer_name() 747 | |> Repo.paginate( 748 | cursor_fields: [:id], 749 | sort_direction: :asc, 750 | limit: 5, 751 | total_count_limit: :infinity, 752 | include_total_count: true 753 | ) 754 | 755 | assert metadata == %Metadata{ 756 | after: encode_cursor(%{id: p5.id}), 757 | before: nil, 758 | limit: 5, 759 | total_count: 12, 760 | total_count_cap_exceeded: false 761 | } 762 | end 763 | 764 | test "when cap not exceeded", %{ 765 | payments: {_p1, _p2, _p3, _p4, p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 766 | } do 767 | %Page{metadata: metadata} = 768 | payments_by_customer_name() 769 | |> Repo.paginate( 770 | cursor_fields: [:id], 771 | sort_direction: :asc, 772 | limit: 5, 773 | include_total_count: true 774 | ) 775 | 776 | assert metadata == %Metadata{ 777 | after: encode_cursor(%{id: p5.id}), 778 | before: nil, 779 | limit: 5, 780 | total_count: 12, 781 | total_count_cap_exceeded: false 782 | } 783 | end 784 | 785 | test "when cap exceeded", %{ 786 | payments: {_p1, _p2, _p3, _p4, p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 787 | } do 788 | %Page{metadata: metadata} = 789 | payments_by_customer_name() 790 | |> Repo.paginate( 791 | cursor_fields: [:id], 792 | sort_direction: :asc, 793 | limit: 5, 794 | include_total_count: true, 795 | total_count_limit: 10 796 | ) 797 | 798 | assert metadata == %Metadata{ 799 | after: encode_cursor(%{id: p5.id}), 800 | before: nil, 801 | limit: 5, 802 | total_count: 10, 803 | total_count_cap_exceeded: true 804 | } 805 | end 806 | 807 | test "when custom total_count_primary_key_field", %{ 808 | addresses: {_a1, a2, _a3} 809 | } do 810 | %Page{metadata: metadata} = 811 | from(a in Address, select: a) 812 | |> Repo.paginate( 813 | cursor_fields: [:city], 814 | sort_direction: :asc, 815 | limit: 2, 816 | include_total_count: true, 817 | total_count_primary_key_field: :city 818 | ) 819 | 820 | assert metadata == %Metadata{ 821 | after: encode_cursor(%{city: a2.city}), 822 | before: nil, 823 | limit: 2, 824 | total_count: 3, 825 | total_count_cap_exceeded: false 826 | } 827 | end 828 | end 829 | 830 | test "when before parameter is erlang term, we do not execute the code", %{} do 831 | # before and after, are user inputs, we need to make sure that they are 832 | # handled safely. 833 | 834 | test_pid = self() 835 | 836 | exploit = fn _, _ -> 837 | send(test_pid, :rce) 838 | {:cont, []} 839 | end 840 | 841 | payload = 842 | exploit 843 | |> :erlang.term_to_binary() 844 | |> Base.url_encode64() 845 | 846 | assert_raise(ArgumentError, ~r/^cannot deserialize.+/, fn -> 847 | payments_by_amount_and_charged_at(:asc, :desc) 848 | |> Repo.paginate( 849 | cursor_fields: [amount: :asc, charged_at: :desc, id: :asc], 850 | before: payload, 851 | limit: 3 852 | ) 853 | end) 854 | 855 | refute_receive :rce, 1000, "Remote Code Execution Detected" 856 | end 857 | 858 | test "per-record cursor generation", %{ 859 | payments: {p1, _p2, _p3, _p4, _p5, _p6, p7, _p8, _p9, _p10, _p11, _p12} 860 | } do 861 | assert Paginator.cursor_for_record(p1, charged_at: :asc, id: :asc) == 862 | encode_cursor(%{charged_at: p1.charged_at, id: p1.id}) 863 | 864 | assert Paginator.cursor_for_record(p7, amount: :asc) == encode_cursor(%{amount: p7.amount}) 865 | end 866 | 867 | test "per-record cursor generation with custom cursor value function", %{ 868 | payments: {p1, _p2, _p3, _p4, _p5, _p6, _p7, _p8, _p9, _p10, _p11, _p12} 869 | } do 870 | assert Paginator.cursor_for_record(p1, [charged_at: :asc, id: :asc], fn schema, field -> 871 | case field do 872 | :id -> Map.get(schema, :id) 873 | _ -> "10" 874 | end 875 | end) == encode_cursor(%{charged_at: "10", id: p1.id}) 876 | end 877 | 878 | test "sorts on two different directions with before cursor", %{ 879 | payments: {_p1, _p2, _p3, p4, p5, p6, p7, _p8, _p9, _p10, _p11, _p12} 880 | } do 881 | %Page{entries: entries, metadata: metadata} = 882 | payments_by_amount_and_charged_at(:asc, :desc) 883 | |> Repo.paginate( 884 | cursor_fields: [amount: :asc, charged_at: :desc, id: :asc], 885 | before: encode_cursor(%{amount: p7.amount, charged_at: p7.charged_at, id: p7.id}), 886 | limit: 3 887 | ) 888 | 889 | assert to_ids(entries) == to_ids([p6, p4, p5]) 890 | 891 | assert metadata == %Metadata{ 892 | after: encode_cursor(%{amount: p5.amount, charged_at: p5.charged_at, id: p5.id}), 893 | before: nil, 894 | limit: 3 895 | } 896 | end 897 | 898 | test "sorts on two different directions with after cursor", %{ 899 | payments: {_p1, _p2, _p3, p4, p5, _p6, p7, p8, _p9, _p10, _p11, _p12} 900 | } do 901 | %Page{entries: entries, metadata: metadata} = 902 | payments_by_amount_and_charged_at(:asc, :desc) 903 | |> Repo.paginate( 904 | cursor_fields: [amount: :asc, charged_at: :desc, id: :asc], 905 | after: encode_cursor(%{amount: p4.amount, charged_at: p4.charged_at, id: p4.id}), 906 | limit: 3 907 | ) 908 | 909 | assert to_ids(entries) == to_ids([p5, p7, p8]) 910 | 911 | assert metadata == %Metadata{ 912 | after: encode_cursor(%{amount: p8.amount, charged_at: p8.charged_at, id: p8.id}), 913 | before: encode_cursor(%{amount: p5.amount, charged_at: p5.charged_at, id: p5.id}), 914 | limit: 3 915 | } 916 | end 917 | 918 | test "sorts on two different directions with before and after cursor", %{ 919 | payments: {_p1, _p2, _p3, p4, p5, p6, p7, p8, _p9, _p10, _p11, _p12} 920 | } do 921 | %Page{entries: entries, metadata: metadata} = 922 | payments_by_amount_and_charged_at(:desc, :asc) 923 | |> Repo.paginate( 924 | cursor_fields: [amount: :desc, charged_at: :asc, id: :asc], 925 | after: encode_cursor(%{amount: p8.amount, charged_at: p8.charged_at, id: p8.id}), 926 | before: encode_cursor(%{amount: p6.amount, charged_at: p6.charged_at, id: p6.id}), 927 | limit: 8 928 | ) 929 | 930 | assert to_ids(entries) == to_ids([p7, p5, p4]) 931 | 932 | assert metadata == %Metadata{ 933 | after: encode_cursor(%{amount: p4.amount, charged_at: p4.charged_at, id: p4.id}), 934 | before: encode_cursor(%{amount: p7.amount, charged_at: p7.charged_at, id: p7.id}), 935 | limit: 8 936 | } 937 | end 938 | 939 | @available_sorting_order [ 940 | :asc, 941 | :asc_nulls_last, 942 | :asc_nulls_first, 943 | :desc, 944 | :desc_nulls_first, 945 | :desc_nulls_last 946 | ] 947 | 948 | for order <- @available_sorting_order do 949 | test "throw an error if nulls are used in the last term - order_by charged_at #{order}" do 950 | customer = insert(:customer) 951 | insert(:payment, customer: customer, charged_at: NaiveDateTime.utc_now()) 952 | insert(:payment, customer: customer, charged_at: nil) 953 | insert(:payment, customer: customer, charged_at: nil) 954 | 955 | opts = [ 956 | cursor_fields: [charged_at: unquote(order)], 957 | limit: 1 958 | ] 959 | 960 | query = 961 | from( 962 | p in Payment, 963 | where: p.customer_id == ^customer.id, 964 | order_by: [{^unquote(order), p.charged_at}], 965 | select: p 966 | ) 967 | 968 | assert_raise RuntimeError, fn -> paginate_as_list(query, opts) end 969 | end 970 | end 971 | 972 | for field0_order <- @available_sorting_order, field1_order <- @available_sorting_order do 973 | test "paginates correctly when pages contains nulls - order by charged_at #{field0_order}, id #{field1_order}" do 974 | customer = insert(:customer) 975 | 976 | now = NaiveDateTime.utc_now() 977 | 978 | for k <- 1..50 do 979 | if Enum.random([true, false]) do 980 | if Enum.random([true, false]) do 981 | insert(:payment, customer: customer, charged_at: NaiveDateTime.add(now, k)) 982 | else 983 | insert(:payment, customer: customer, charged_at: NaiveDateTime.add(now, k - 1)) 984 | end 985 | else 986 | insert(:payment, customer: customer, charged_at: nil) 987 | end 988 | end 989 | 990 | opts = [ 991 | cursor_fields: [charged_at: unquote(field0_order), id: unquote(field1_order)], 992 | limit: 1 993 | ] 994 | 995 | query = 996 | from( 997 | p in Payment, 998 | where: p.customer_id == ^customer.id, 999 | order_by: [{^unquote(field0_order), p.charged_at}, {^unquote(field1_order), p.id}], 1000 | select: p 1001 | ) 1002 | 1003 | expected = 1004 | query 1005 | |> Repo.all(opts) 1006 | |> to_ids() 1007 | 1008 | after_pagination = paginate_as_list(query, opts) 1009 | assert after_pagination == expected 1010 | 1011 | before_pagination = paginate_before_as_list(query, opts) 1012 | assert before_pagination == init([nil | expected]) 1013 | end 1014 | end 1015 | 1016 | test "expression based field is passed to cursor_fields" do 1017 | base_customer_name = "Bob" 1018 | 1019 | list = create_customers_with_similar_names(base_customer_name) 1020 | 1021 | {:ok, customer_3} = Enum.fetch(list, 3) 1022 | 1023 | %Page{entries: entries, metadata: metadata} = 1024 | base_customer_name 1025 | |> customers_with_tsvector_rank() 1026 | |> Repo.paginate( 1027 | after: encode_cursor(%{rank_value: customer_3.rank_value, id: customer_3.id}), 1028 | limit: 3, 1029 | cursor_fields: [ 1030 | {:rank_value, 1031 | fn -> 1032 | dynamic( 1033 | [x], 1034 | fragment( 1035 | "ts_rank(setweight(to_tsvector('simple', name), 'A'), plainto_tsquery('simple', ?))", 1036 | ^base_customer_name 1037 | ) 1038 | ) 1039 | end}, 1040 | :id 1041 | ] 1042 | ) 1043 | 1044 | last_entry = List.last(entries) 1045 | first_entry = List.first(entries) 1046 | 1047 | assert metadata == %Metadata{ 1048 | after: encode_cursor(%{rank_value: last_entry.rank_value, id: last_entry.id}), 1049 | before: encode_cursor(%{rank_value: first_entry.rank_value, id: first_entry.id}), 1050 | limit: 3 1051 | } 1052 | end 1053 | 1054 | test "expression based field when combined with UUID field" do 1055 | base_customer_name = "Bob" 1056 | 1057 | create_customers_with_similar_names(base_customer_name) 1058 | 1059 | list = base_customer_name |> customers_with_tsvector_rank() |> Repo.all() 1060 | {:ok, customer_3} = Enum.fetch(list, 3) 1061 | 1062 | %Page{entries: entries, metadata: metadata} = 1063 | base_customer_name 1064 | |> customers_with_tsvector_rank() 1065 | |> Repo.paginate( 1066 | after: 1067 | encode_cursor(%{ 1068 | rank_value: customer_3.rank_value, 1069 | internal_uuid: customer_3.internal_uuid 1070 | }), 1071 | limit: 3, 1072 | cursor_fields: [ 1073 | {:rank_value, 1074 | fn -> 1075 | dynamic( 1076 | [x], 1077 | fragment( 1078 | "ts_rank(setweight(to_tsvector('simple', name), 'A'), plainto_tsquery('simple', ?))", 1079 | ^base_customer_name 1080 | ) 1081 | ) 1082 | end}, 1083 | :internal_uuid 1084 | ] 1085 | ) 1086 | 1087 | last_entry = List.last(entries) 1088 | first_entry = List.first(entries) 1089 | 1090 | assert metadata == %Metadata{ 1091 | after: 1092 | encode_cursor(%{ 1093 | rank_value: last_entry.rank_value, 1094 | internal_uuid: last_entry.internal_uuid 1095 | }), 1096 | before: 1097 | encode_cursor(%{ 1098 | rank_value: first_entry.rank_value, 1099 | internal_uuid: first_entry.internal_uuid 1100 | }), 1101 | limit: 3 1102 | } 1103 | end 1104 | 1105 | defp to_ids(entries), do: Enum.map(entries, & &1.id) 1106 | 1107 | defp create_customers_and_payments(_context) do 1108 | c1 = insert(:customer, %{name: "Bob", internal_uuid: Ecto.UUID.generate()}) 1109 | c2 = insert(:customer, %{name: "Alice", internal_uuid: Ecto.UUID.generate()}) 1110 | c3 = insert(:customer, %{name: "Charlie", internal_uuid: Ecto.UUID.generate()}) 1111 | 1112 | a1 = insert(:address, city: "London", customer: c1) 1113 | a2 = insert(:address, city: "New York", customer: c2) 1114 | a3 = insert(:address, city: "Tokyo", customer: c3) 1115 | 1116 | p1 = insert(:payment, customer: c2, charged_at: days_ago(11)) 1117 | p2 = insert(:payment, customer: c2, charged_at: days_ago(6)) 1118 | p3 = insert(:payment, customer: c2, charged_at: days_ago(8)) 1119 | p4 = insert(:payment, customer: c2, amount: 2, charged_at: days_ago(12)) 1120 | 1121 | p5 = insert(:payment, customer: c1, amount: 3, charged_at: days_ago(13)) 1122 | p6 = insert(:payment, customer: c1, amount: 2, charged_at: days_ago(10)) 1123 | p7 = insert(:payment, customer: c1, amount: 4, charged_at: days_ago(9)) 1124 | p8 = insert(:payment, customer: c1, amount: 5, charged_at: days_ago(4)) 1125 | 1126 | p9 = insert(:payment, customer: c3, charged_at: days_ago(3)) 1127 | p10 = insert(:payment, customer: c3, charged_at: days_ago(7)) 1128 | p11 = insert(:payment, customer: c3, charged_at: days_ago(2)) 1129 | p12 = insert(:payment, customer: c3, charged_at: days_ago(5)) 1130 | 1131 | {:ok, 1132 | customers: {c1, c2, c3}, 1133 | addresses: {a1, a2, a3}, 1134 | payments: {p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12}} 1135 | end 1136 | 1137 | defp create_customers_with_similar_names(base_customer_name) do 1138 | 1..10 1139 | |> Enum.map(fn i -> 1140 | {:ok, %{rows: [[rank_value]]}} = 1141 | Repo.query( 1142 | "SELECT ts_rank(setweight(to_tsvector('simple', $1), 'A'), plainto_tsquery('simple', $2))", 1143 | [ 1144 | "#{base_customer_name} #{i}", 1145 | base_customer_name 1146 | ] 1147 | ) 1148 | 1149 | insert(:customer, %{ 1150 | name: "#{base_customer_name} #{i}", 1151 | internal_uuid: Ecto.UUID.generate(), 1152 | rank_value: rank_value 1153 | }) 1154 | end) 1155 | end 1156 | 1157 | defp payments_by_status(status, direction \\ :asc) do 1158 | from( 1159 | p in Payment, 1160 | where: p.status == ^status, 1161 | order_by: [{^direction, p.charged_at}, {^direction, p.id}], 1162 | select: p 1163 | ) 1164 | end 1165 | 1166 | defp payments_by_amount_and_charged_at(amount_direction, charged_at_direction) do 1167 | from( 1168 | p in Payment, 1169 | order_by: [ 1170 | {^amount_direction, p.amount}, 1171 | {^charged_at_direction, p.charged_at}, 1172 | {:asc, p.id} 1173 | ], 1174 | select: p 1175 | ) 1176 | end 1177 | 1178 | defp payments_by_charged_at(direction \\ :asc) do 1179 | from( 1180 | p in Payment, 1181 | order_by: [{^direction, p.charged_at}, {^direction, p.id}], 1182 | select: p 1183 | ) 1184 | end 1185 | 1186 | defp payments_by_customer_name(payment_id_direction \\ :asc, customer_name_direction \\ :asc) do 1187 | from( 1188 | p in Payment, 1189 | as: :payments, 1190 | join: c in assoc(p, :customer), 1191 | as: :customer, 1192 | preload: [customer: c], 1193 | select: p, 1194 | order_by: [ 1195 | {^customer_name_direction, c.name}, 1196 | {^payment_id_direction, p.id} 1197 | ] 1198 | ) 1199 | end 1200 | 1201 | defp payments_by_address_city(payment_id_direction \\ :asc, address_city_direction \\ :asc) do 1202 | from( 1203 | p in Payment, 1204 | as: :payments, 1205 | join: c in assoc(p, :customer), 1206 | as: :customer, 1207 | join: a in assoc(c, :address), 1208 | as: :address, 1209 | preload: [customer: {c, address: a}], 1210 | select: p, 1211 | order_by: [ 1212 | {^address_city_direction, a.city}, 1213 | {^payment_id_direction, p.id} 1214 | ] 1215 | ) 1216 | end 1217 | 1218 | defp customer_payments_by_charged_at_and_amount(customer, direction \\ :asc) do 1219 | from( 1220 | p in Payment, 1221 | where: p.customer_id == ^customer.id, 1222 | order_by: [{^direction, p.charged_at}, {^direction, p.amount}, {^direction, p.id}] 1223 | ) 1224 | end 1225 | 1226 | defp customers_with_tsvector_rank(q) do 1227 | from(f in Customer, 1228 | select_merge: %{ 1229 | rank_value: 1230 | fragment( 1231 | "ts_rank(setweight(to_tsvector('simple', name), 'A'), plainto_tsquery('simple', ?)) AS rank_value", 1232 | ^q 1233 | ) 1234 | }, 1235 | where: 1236 | fragment( 1237 | "setweight(to_tsvector('simple', name), 'A') @@ plainto_tsquery('simple', ?)", 1238 | ^q 1239 | ), 1240 | order_by: [ 1241 | asc: fragment("rank_value"), 1242 | asc: f.internal_uuid 1243 | ] 1244 | ) 1245 | end 1246 | 1247 | defp encode_cursor(value) do 1248 | Cursor.encode(value) 1249 | end 1250 | 1251 | defp encode_legacy_cursor(value) when is_list(value) do 1252 | value 1253 | |> :erlang.term_to_binary() 1254 | |> Base.url_encode64() 1255 | end 1256 | 1257 | defp days_ago(days) do 1258 | DT.add!(DateTime.utc_now(), -(days * 86400)) 1259 | end 1260 | 1261 | defp paginate_as_list(query, opts, mapf \\ &to_ids(&1.entries)) do 1262 | opts 1263 | |> Stream.unfold(fn 1264 | nil -> 1265 | nil 1266 | 1267 | opts -> 1268 | page = Repo.paginate(query, opts) 1269 | 1270 | if after_value = page.metadata.after do 1271 | {mapf.(page), Keyword.put(opts, :after, after_value)} 1272 | else 1273 | {mapf.(page), nil} 1274 | end 1275 | end) 1276 | |> Stream.flat_map(& &1) 1277 | |> Enum.to_list() 1278 | end 1279 | 1280 | defp paginate_before_as_list(query, opts) do 1281 | query 1282 | |> paginate_as_list(opts, &[&1.metadata.before]) 1283 | |> Enum.flat_map(fn 1284 | nil -> 1285 | [nil] 1286 | 1287 | before_cursor -> 1288 | Repo.paginate(query, Keyword.put(opts, :before, before_cursor)) 1289 | |> Map.fetch!(:entries) 1290 | |> to_ids 1291 | end) 1292 | end 1293 | 1294 | defp init([]) do 1295 | [] 1296 | end 1297 | 1298 | defp init([_]) do 1299 | [] 1300 | end 1301 | 1302 | defp init([x | xs]) do 1303 | [x | init(xs)] 1304 | end 1305 | end 1306 | -------------------------------------------------------------------------------- /test/support/address.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Address do 2 | use Ecto.Schema 3 | 4 | @primary_key {:city, :string, autogenerate: false} 5 | 6 | schema "addresses" do 7 | belongs_to(:customer, Paginator.Customer) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /test/support/customer.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Customer do 2 | use Ecto.Schema 3 | 4 | import Ecto.Query 5 | 6 | schema "customers" do 7 | field(:name, :string) 8 | field(:active, :boolean) 9 | field(:internal_uuid, :binary_id) 10 | field(:rank_value, :float, virtual: true) 11 | 12 | has_many(:payments, Paginator.Payment) 13 | has_one(:address, Paginator.Address) 14 | 15 | timestamps() 16 | end 17 | 18 | def active(query) do 19 | query |> where([c], c.active == true) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /test/support/data_case.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.DataCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using _opts do 5 | quote do 6 | alias Paginator.Repo 7 | 8 | import Ecto 9 | import Ecto.Query 10 | import Paginator.Factory 11 | 12 | alias Paginator.{Page, Page.Metadata} 13 | alias Paginator.{Customer, Address, Payment} 14 | end 15 | end 16 | 17 | setup tags do 18 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Paginator.Repo) 19 | 20 | unless tags[:async] do 21 | Ecto.Adapters.SQL.Sandbox.mode(Paginator.Repo, {:shared, self()}) 22 | end 23 | 24 | :ok 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /test/support/factory.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Factory do 2 | use ExMachina.Ecto, repo: Paginator.Repo 3 | 4 | alias Paginator.{Customer, Address, Payment} 5 | 6 | def customer_factory do 7 | %Customer{ 8 | name: "Bob", 9 | internal_uuid: Ecto.UUID.generate(), 10 | active: true 11 | } 12 | end 13 | 14 | def address_factory do 15 | %Address{ 16 | city: "City name", 17 | customer: build(:customer) 18 | } 19 | end 20 | 21 | def payment_factory do 22 | %Payment{ 23 | description: "Skittles", 24 | charged_at: DateTime.utc_now(), 25 | # +10 so it doesn't mess with low amounts we want to order on. 26 | amount: :rand.uniform(100) + 10, 27 | status: "success", 28 | customer: build(:customer) 29 | } 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /test/support/payment.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Payment do 2 | use Ecto.Schema 3 | 4 | import Ecto.Query 5 | 6 | schema "payments" do 7 | field(:amount, :integer) 8 | field(:charged_at, :utc_datetime) 9 | field(:description, :string) 10 | field(:status, :string) 11 | 12 | belongs_to(:customer, Paginator.Customer) 13 | 14 | timestamps() 15 | end 16 | 17 | def successful(query) do 18 | query |> where([p], p.status == "success") 19 | end 20 | 21 | def failed(query) do 22 | query |> where([p], p.status == "failed") 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /test/support/repo.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.Repo do 2 | use Ecto.Repo, 3 | otp_app: :paginator, 4 | adapter: Ecto.Adapters.Postgres 5 | 6 | use Paginator 7 | end 8 | -------------------------------------------------------------------------------- /test/support/test_migration.ex: -------------------------------------------------------------------------------- 1 | defmodule Paginator.TestMigration do 2 | use Ecto.Migration 3 | 4 | def change do 5 | create table(:customers) do 6 | add(:name, :string) 7 | add(:active, :boolean) 8 | add(:internal_uuid, :uuid, null: false) 9 | 10 | timestamps() 11 | end 12 | 13 | create table(:payments) do 14 | add(:description, :text) 15 | add(:charged_at, :utc_datetime) 16 | add(:amount, :integer) 17 | add(:status, :string) 18 | 19 | add(:customer_id, references(:customers)) 20 | 21 | timestamps() 22 | end 23 | 24 | create table(:addresses, primary_key: false) do 25 | add(:city, :string, primary_key: true) 26 | 27 | add(:customer_id, references(:customers)) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Application.ensure_all_started(:postgrex) 2 | Application.ensure_all_started(:ecto) 3 | 4 | # Load up the repository, start it, and run migrations 5 | _ = Ecto.Adapters.Postgres.storage_down(Paginator.Repo.config()) 6 | :ok = Ecto.Adapters.Postgres.storage_up(Paginator.Repo.config()) 7 | {:ok, _} = Paginator.Repo.start_link() 8 | :ok = Ecto.Migrator.up(Paginator.Repo, 0, Paginator.TestMigration, log: false) 9 | 10 | Ecto.Adapters.SQL.Sandbox.mode(Paginator.Repo, :manual) 11 | 12 | ExUnit.start() 13 | --------------------------------------------------------------------------------