├── .formatter.exs ├── .github └── workflows │ └── ci.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── config ├── config.exs ├── dev.exs └── test.exs ├── lib ├── pagination_ex.ex └── pagination_ex │ ├── config.ex │ ├── core.ex │ └── html.ex ├── mix.exs ├── mix.lock └── test ├── pagination_ex ├── core_test.exs └── html_test.exs └── test_helper.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | pull_request: 5 | workflow_call: 6 | 7 | jobs: 8 | lint: 9 | runs-on: ubuntu-24.04 10 | 11 | env: 12 | MIX_ENV: test 13 | 14 | strategy: 15 | matrix: 16 | elixir: [1.18.x] 17 | otp: [27.x] 18 | 19 | steps: 20 | - name: Checkout code 21 | uses: actions/checkout@v4 22 | 23 | - name: Set up Elixir 24 | uses: erlef/setup-beam@v1.18.2 25 | with: 26 | elixir-version: ${{ matrix.elixir }} 27 | otp-version: ${{ matrix.otp }} 28 | 29 | - name: Cache Elixir deps 30 | uses: actions/cache@v4 31 | id: deps-cache 32 | with: 33 | path: deps 34 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 35 | 36 | - name: Cache Elixir _build 37 | uses: actions/cache@v4 38 | id: build-cache 39 | with: 40 | path: _build 41 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 42 | 43 | - name: Install deps 44 | if: steps.deps-cache.outputs.cache-hit != 'true' 45 | run: | 46 | mix local.rebar --force 47 | mix local.hex --force 48 | mix deps.get --only ${{ env.MIX_ENV }} 49 | 50 | - name: Compile deps 51 | if: steps.build-cache.outputs.cache-hit != 'true' 52 | run: mix deps.compile --warnings-as-errors 53 | 54 | - name: Clean build 55 | run: mix clean 56 | 57 | - name: Check code formatting 58 | run: mix format --check-formatted 59 | 60 | - name: Run Credo 61 | run: mix credo --strict 62 | 63 | static-analysis: 64 | runs-on: ubuntu-24.04 65 | 66 | env: 67 | MIX_ENV: test 68 | 69 | strategy: 70 | matrix: 71 | elixir: [1.18.x] 72 | otp: [27.x] 73 | 74 | steps: 75 | - name: Checkout code 76 | uses: actions/checkout@v4 77 | 78 | - name: Set up Elixir 79 | uses: erlef/setup-beam@v1.18.2 80 | with: 81 | elixir-version: ${{ matrix.elixir }} 82 | otp-version: ${{ matrix.otp }} 83 | 84 | - name: Cache Elixir deps 85 | uses: actions/cache@v4 86 | id: deps-cache 87 | with: 88 | path: deps 89 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 90 | 91 | - name: Cache Elixir _build 92 | uses: actions/cache@v4 93 | id: build-cache 94 | with: 95 | path: _build 96 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 97 | 98 | - name: Install deps 99 | if: steps.deps-cache.outputs.cache-hit != 'true' 100 | run: | 101 | mix local.rebar --force 102 | mix local.hex --force 103 | mix deps.get --only ${{ env.MIX_ENV }} 104 | 105 | - name: Compile deps 106 | if: steps.build-cache.outputs.cache-hit != 'true' 107 | run: mix deps.compile --warnings-as-errors 108 | 109 | # Don't cache PLTs based on mix.lock hash, as Dialyzer can incrementally update even old ones 110 | # Cache key based on Elixir & Erlang version (also useful when running in matrix) 111 | - name: Restore PLT cache 112 | uses: actions/cache/restore@v4 113 | id: plt_cache 114 | with: 115 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plt 116 | restore-keys: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plt 117 | path: priv/plts 118 | 119 | # Create PLTs if no cache was found 120 | - name: Create PLTs 121 | if: steps.plt_cache.outputs.cache-hit != 'true' 122 | run: mix dialyzer --plt 123 | 124 | - name: Save PLT cache 125 | uses: actions/cache/save@v4 126 | if: steps.plt_cache.outputs.cache-hit != 'true' 127 | id: plt_cache_save 128 | with: 129 | key: ${{ runner.os }}-${{ matrix.otp }}-${{ matrix.elixir }}-plt 130 | path: priv/plts 131 | 132 | - name: Run dialyzer 133 | run: mix dialyzer --format github 134 | 135 | test: 136 | runs-on: ubuntu-24.04 137 | needs: [lint, static-analysis] 138 | 139 | env: 140 | MIX_ENV: test 141 | # PostgreSQL configuration in environment variables 142 | PGHOST: localhost 143 | PGUSER: postgres 144 | PGPASSWORD: postgres 145 | PGDATABASE: pagination_ex_test 146 | PGPORT: 5432 147 | 148 | # PostgreSQL service that will run alongside the tests 149 | services: 150 | postgres: 151 | image: postgres:14 152 | env: 153 | POSTGRES_USER: postgres 154 | POSTGRES_PASSWORD: postgres 155 | POSTGRES_DB: pagination_ex_test 156 | ports: 157 | - 5432:5432 158 | # Options to ensure PostgreSQL is ready 159 | options: >- 160 | --health-cmd pg_isready 161 | --health-interval 10s 162 | --health-timeout 5s 163 | --health-retries 5 164 | 165 | strategy: 166 | matrix: 167 | elixir: [1.18.x] 168 | otp: [27.x] 169 | 170 | steps: 171 | - name: Checkout code 172 | uses: actions/checkout@v4 173 | 174 | - name: Set up Elixir 175 | uses: erlef/setup-beam@v1.18.2 176 | with: 177 | elixir-version: ${{ matrix.elixir }} 178 | otp-version: ${{ matrix.otp }} 179 | 180 | - name: Cache Elixir deps 181 | uses: actions/cache@v4 182 | id: deps-cache 183 | with: 184 | path: deps 185 | key: ${{ runner.os }}-mix-${{ env.MIX_ENV }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 186 | 187 | - name: Cache Elixir _build 188 | uses: actions/cache@v4 189 | id: build-cache 190 | with: 191 | path: _build 192 | key: ${{ runner.os }}-build-${{ env.MIX_ENV }}-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 193 | 194 | - name: Install deps 195 | if: steps.deps-cache.outputs.cache-hit != 'true' 196 | run: | 197 | mix local.rebar --force 198 | mix local.hex --force 199 | mix deps.get --only ${{ env.MIX_ENV }} 200 | 201 | - name: Compile deps 202 | if: steps.build-cache.outputs.cache-hit != 'true' 203 | run: mix deps.compile --warnings-as-errors 204 | 205 | - name: Clean build 206 | run: mix clean 207 | 208 | # Create tables for testing if necessary 209 | - name: Setup database 210 | run: | 211 | mix ecto.create 212 | mix ecto.migrate 213 | 214 | - name: Run tests 215 | run: mix test 216 | -------------------------------------------------------------------------------- /.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 | # If the VM crashes, it generates a dump, let's ignore it too. 14 | erl_crash.dump 15 | 16 | # Also ignore archive artifacts (built via "mix archive.build"). 17 | *.ez 18 | 19 | # Ignore package tarball (built via "mix hex.build"). 20 | pagination_ex-*.tar 21 | 22 | # Temporary files, for example, from tests. 23 | /tmp/ 24 | 25 | /priv/plts/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.1.0] - 2025-03-05 6 | 7 | ### Added 8 | 9 | - Initial version with core pagination functionality 10 | - `PaginationEx.Core` module for paginating Ecto queries with: 11 | - Customizable page size 12 | - Total entries and page calculations 13 | - Support for complex queries (grouped, distinct, etc.) 14 | - `PaginationEx.HTML` module with Phoenix integration: 15 | - Tailwind CSS styled pagination controls 16 | - Support for path helpers and URL strings 17 | - Customizable through template modules 18 | - i18n support via Gettext 19 | - `PaginationEx.Config` module for centralized configuration: 20 | - Configurable repo, router helpers, and Gettext 21 | - Default pagination settings 22 | - Runtime validation of required configuration 23 | 24 | [0.1.0]: https://github.com/idopterlabs/pagination_ex/releases/tag/v0.1.0 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PaginationEx 2 | 3 | A flexible and robust pagination library for Elixir/Phoenix applications that integrates seamlessly with Ecto. 4 | 5 | ## Features 6 | 7 | - **Simple Integration**: Easily paginate Ecto queries with minimal configuration 8 | - **Flexible Rendering**: HTML helpers with Tailwind CSS styling by default 9 | - **Customizable**: Support for custom templates and styling 10 | - **Internationalization**: Built-in i18n support via Gettext 11 | - **Performance Optimized**: Smart counting strategies for different query types 12 | 13 | ## Installation 14 | 15 | The package is not yet available on Hex.pm. To install directly from GitHub, add to your dependencies: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:pagination_ex, "~> 0.1.0"} 21 | ] 22 | end 23 | ``` 24 | 25 | ## Configuration 26 | 27 | Configure PaginationEx in your application's config file: 28 | 29 | ```elixir 30 | # In config/config.exs or environment-specific config files 31 | config :pagination_ex, 32 | repo: MyApp.Repo, # Required 33 | per_page: 25, # Optional, defaults to 30 34 | per_group: 500, # Optional, defaults to 1000 35 | gettext_module: MyApp.Gettext, # Optional for internationalization 36 | router_helpers: MyAppWeb.Router.Helpers # Optional for route generation 37 | ``` 38 | 39 | ## Usage 40 | 41 | ### Basic Pagination 42 | 43 | ```elixir 44 | # In your controller 45 | def index(conn, params) do 46 | pagination = 47 | MyApp.Posts 48 | |> PaginationEx.new(params) 49 | 50 | render(conn, "index.html", pagination: pagination) 51 | end 52 | ``` 53 | 54 | ### In Templates 55 | 56 | ```elixir 57 | # In your template 58 | <%= for post <- @pagination.entries do %> 59 |
60 |

<%= post.title %>

61 |

<%= post.content %>

62 |
63 | <% end %> 64 | 65 | 68 | ``` 69 | 70 | ### Batch Processing with Groups 71 | 72 | For processing large datasets in batches: 73 | 74 | ```elixir 75 | # This fetches all items in batches of the configured size 76 | all_items = PaginationEx.in_groups(MyApp.Items) 77 | 78 | # Process all items 79 | Enum.each(all_items, fn item -> 80 | # Process each item 81 | end) 82 | ``` 83 | 84 | ### Custom Templates 85 | 86 | Create a custom template module: 87 | 88 | ```elixir 89 | defmodule MyApp.CustomPaginationTemplate do 90 | use PhoenixHTMLHelpers 91 | import PaginationEx.HTML, only: [translate: 1, build_url: 3] 92 | 93 | def render_pagination(conn, path, pagination) do 94 | # Your custom rendering logic here 95 | end 96 | end 97 | ``` 98 | 99 | Then use it in your templates: 100 | 101 | ```elixir 102 | <%= PaginationEx.paginate(@conn, :post_path, template_module: MyApp.CustomPaginationTemplate) %> 103 | ``` 104 | 105 | ## API Documentation 106 | 107 | ### PaginationEx.Core 108 | 109 | The `Core` module handles the pagination logic and query execution. 110 | 111 | Key functions: 112 | - `new/3`: Creates a new pagination struct from an Ecto query 113 | - `in_groups/2`: Retrieves all records in batches of specified size 114 | 115 | ### PaginationEx.HTML 116 | 117 | The `HTML` module renders pagination controls in templates. 118 | 119 | Key functions: 120 | - `paginate/3`: Renders complete pagination controls 121 | - `page_links/3`: Generates numbered page links 122 | - `previous_path/3` and `next_path/4`: Creates previous/next navigation links 123 | 124 | ### PaginationEx.Config 125 | 126 | The `Config` module handles configuration retrieval. 127 | 128 | ## Pagination Structure 129 | 130 | The pagination result is a struct with the following fields: 131 | 132 | ```elixir 133 | %PaginationEx.Core{ 134 | entries: [%Post{}, %Post{}, ...], # The current page's items 135 | total_entries: 59, # Total number of items 136 | page_number: 2, # Current page number 137 | per_page: 10, # Items per page 138 | pages: 6, # Total number of pages 139 | query: #Ecto.Query<...> # The original query 140 | } 141 | ``` 142 | 143 | ## Contributing 144 | 145 | Contributions are welcome! Please feel free to submit a Pull Request. 146 | 147 | 1. Fork the repository 148 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 149 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 150 | 4. Push to the branch (`git push origin feature/amazing-feature`) 151 | 5. Open a Pull Request 152 | 6. Wait for the review 153 | 154 | And voilà! Your ice cream is ready ✨ 155 | 156 | ## Authors 157 | 158 | - **Rômulo Silva (Tomate)** - _Alchemist & Developer_ [Github](https://github.com/rohlacanna) 159 | - **Paulo Igor (Pigor)** - _Alchemist & Developer_ [Github](https://github.com/pigor) 160 | - **Mateus Linhares (Mateus)** - _Alchemist & Developer_ [Github](https://github.com/mateuslinhares) 161 | 162 | ## License 163 | 164 | This project is licensed under the Apache License 2.0 - see the LICENSE file for details. -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :tailwind, :version, "3.4.6" 4 | 5 | if Mix.env() != :test do 6 | import_config "#{Mix.env()}.exs" 7 | end 8 | -------------------------------------------------------------------------------- /config/dev.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | -------------------------------------------------------------------------------- /config/test.exs: -------------------------------------------------------------------------------- 1 | import Config 2 | 3 | config :pagination_ex, 4 | ecto_repos: [PaginationEx.CoreTest.TestRepo], 5 | repo: PaginationEx.CoreTest.TestRepo 6 | 7 | config :logger, level: :warning 8 | -------------------------------------------------------------------------------- /lib/pagination_ex.ex: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx do 2 | @moduledoc """ 3 | PaginationEx provides pagination functionality for Elixir applications. 4 | """ 5 | 6 | alias PaginationEx.Core 7 | alias PaginationEx.HTML 8 | 9 | @doc """ 10 | Creates a new pagination struct from a query and params. 11 | """ 12 | defdelegate new(query, params, opts \\ []), to: Core 13 | 14 | @doc """ 15 | Paginates results in groups. 16 | """ 17 | defdelegate in_groups(query, params \\ %{}), to: Core 18 | 19 | @doc """ 20 | Renders pagination links in HTML. 21 | """ 22 | defdelegate paginate(conn, path, opts \\ []), to: HTML 23 | 24 | @doc """ 25 | Generates the "Next" link or disabled button based on the current page and total pages. 26 | Useful for custom pagination templates. 27 | """ 28 | defdelegate next_path(conn, path, current_page, total_pages), to: HTML 29 | 30 | @doc """ 31 | Generates the "Previous" link or disabled button based on the current page. 32 | Useful for custom pagination templates. 33 | """ 34 | defdelegate previous_path(conn, path, current_page), to: HTML 35 | 36 | @doc """ 37 | Generates numeric links for each page. 38 | Useful for custom pagination templates. 39 | """ 40 | defdelegate page_links(conn, path, pagination), to: HTML 41 | 42 | @doc """ 43 | Builds the URL for a specific page. 44 | Useful for custom pagination templates. 45 | """ 46 | defdelegate build_url(conn, path, page), to: HTML 47 | 48 | @doc """ 49 | Translates text using configured Gettext module if available. 50 | Useful for custom pagination templates. 51 | """ 52 | defdelegate translate(text), to: HTML 53 | end 54 | -------------------------------------------------------------------------------- /lib/pagination_ex/config.ex: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.Config do 2 | @moduledoc """ 3 | Configuration module for PaginationEx. 4 | 5 | This module provides configuration management for the PaginationEx library. 6 | It handles retrieval of configuration values from the application environment 7 | with fallback to default values, and provides access to essential configuration 8 | options like pagination limits and required dependencies. 9 | """ 10 | 11 | @default_per_page 30 12 | @default_per_group 1000 13 | 14 | @doc """ 15 | Get configuration value with fallback to default. 16 | 17 | ## Parameters 18 | * `key` - The configuration key to look up under the `:pagination_ex` application environment 19 | * `default` - The default value to return if the key is not found 20 | 21 | ## Returns 22 | * The configured value for the given key, or the provided default if not configured 23 | 24 | ## Examples 25 | # Assuming configuration: 26 | # config :pagination_ex, :custom_setting, "custom_value" 27 | 28 | iex> PaginationEx.Config.get(:custom_setting) 29 | "custom_value" 30 | iex> PaginationEx.Config.get(:missing_setting) 31 | nil 32 | iex> PaginationEx.Config.get(:missing_setting, "default") 33 | "default" 34 | """ 35 | @spec get(atom(), term()) :: term() 36 | def get(key, default \\ nil) do 37 | Application.get_env(:pagination_ex, key, default) 38 | end 39 | 40 | @doc """ 41 | Get per page configuration. 42 | 43 | ## Returns 44 | * The configured `:per_page` value, or `#{@default_per_page}` if not configured 45 | 46 | ## Examples 47 | # With default configuration 48 | iex> PaginationEx.Config.per_page() 49 | 30 50 | 51 | # With custom configuration: 52 | # config :pagination_ex, :per_page, 50 53 | iex> PaginationEx.Config.per_page() 54 | 50 55 | """ 56 | @spec per_page() :: non_neg_integer() 57 | def per_page do 58 | get(:per_page, @default_per_page) 59 | end 60 | 61 | @doc """ 62 | Get per group configuration. 63 | 64 | ## Returns 65 | * The configured `:per_group` value, or `#{@default_per_group}` if not configured 66 | 67 | ## Examples 68 | # With default configuration 69 | iex> PaginationEx.Config.per_group() 70 | 1000 71 | 72 | # With custom configuration: 73 | # config :pagination_ex, :per_group, 500 74 | iex> PaginationEx.Config.per_group() 75 | 500 76 | """ 77 | @spec per_group() :: non_neg_integer() 78 | def per_group do 79 | get(:per_group, @default_per_group) 80 | end 81 | 82 | @doc """ 83 | Get repo configuration. 84 | 85 | ## Returns 86 | * The configured `:repo` module 87 | 88 | ## Raises 89 | * Runtime error if `:repo` is not configured 90 | 91 | ## Examples 92 | # With configuration: 93 | # config :pagination_ex, :repo, MyApp.Repo 94 | iex> PaginationEx.Config.repo() 95 | MyApp.Repo 96 | 97 | # Without configuration: 98 | iex> PaginationEx.Config.repo() 99 | ** (RuntimeError) You must configure a repo for PaginationEx... 100 | """ 101 | @spec repo() :: module() 102 | def repo do 103 | get(:repo) || 104 | raise """ 105 | You must configure a repo for PaginationEx. For example: 106 | 107 | config :pagination_ex, :repo, MyApp.Repo 108 | """ 109 | end 110 | 111 | @doc """ 112 | Get gettext module configuration. 113 | 114 | ## Returns 115 | * The configured `:gettext_module`, or `nil` if not configured 116 | 117 | ## Examples 118 | # With configuration: 119 | # config :pagination_ex, :gettext_module, MyApp.Gettext 120 | iex> PaginationEx.Config.gettext_module() 121 | MyApp.Gettext 122 | 123 | # Without configuration: 124 | iex> PaginationEx.Config.gettext_module() 125 | nil 126 | """ 127 | @spec gettext_module() :: module() | nil 128 | def gettext_module do 129 | get(:gettext_module) 130 | end 131 | 132 | @doc """ 133 | Get router helpers module configuration. 134 | 135 | ## Returns 136 | * The configured `:router_helpers`, or `nil` if not configured 137 | 138 | ## Examples 139 | # With configuration: 140 | # config :pagination_ex, :router_helpers, MyAppWeb.Router.Helpers 141 | iex> PaginationEx.Config.router_helpers() 142 | MyAppWeb.Router.Helpers 143 | 144 | # Without configuration: 145 | iex> PaginationEx.Config.router_helpers() 146 | nil 147 | """ 148 | @spec router_helpers() :: module() | nil 149 | def router_helpers do 150 | get(:router_helpers) 151 | end 152 | end 153 | -------------------------------------------------------------------------------- /lib/pagination_ex/core.ex: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.Core do 2 | @moduledoc """ 3 | Core pagination functionality for Ecto queries. 4 | 5 | This module provides the main pagination functionality for Ecto queries, including: 6 | 7 | - Paginating query results with customizable page size 8 | - Calculating total entries and pages 9 | - Grouping results for bulk retrieval 10 | - Handling various query types (simple, grouped, distinct) 11 | 12 | The pagination result is a struct containing entries, metadata about the pagination, 13 | and the original query. 14 | """ 15 | 16 | import Ecto.Query 17 | 18 | alias Ecto.Adapters.SQL 19 | 20 | @derive {Jason.Encoder, only: [:entries, :total_entries, :page_number, :per_page, :pages]} 21 | defstruct [:entries, :total_entries, :page_number, :per_page, :pages, :query] 22 | 23 | @type t() :: %__MODULE__{ 24 | entries: list(any()), 25 | total_entries: integer(), 26 | page_number: pos_integer(), 27 | per_page: pos_integer(), 28 | pages: non_neg_integer(), 29 | query: Ecto.Query.t() 30 | } 31 | 32 | @default_per_page 30 33 | @default_per_group 1000 34 | 35 | @doc """ 36 | Retrieves all entries in groups of specified size. 37 | 38 | This function fetches all records matching the query in batches, combining them into 39 | a single list. Useful for retrieving large datasets efficiently without loading 40 | everything at once. 41 | 42 | ## Parameters 43 | * `query` - The Ecto query to paginate 44 | * `params` - Map of pagination parameters (optional) 45 | * `"per_group"` - Number of items per group (defaults to config or #{@default_per_group}) 46 | * `"total"` - Optional pre-calculated total count 47 | 48 | ## Returns 49 | * List of all entries matching the query 50 | 51 | ## Examples 52 | iex> PaginationEx.Core.in_groups(Post, %{"per_group" => "100"}) 53 | [%Post{}, %Post{}, ...] 54 | """ 55 | @spec in_groups(Ecto.Queryable.t(), map()) :: list(any()) 56 | def in_groups(query, params \\ %{}) do 57 | query 58 | |> new(set_group_params(params)) 59 | |> get_group([]) 60 | end 61 | 62 | @doc """ 63 | Creates a new paginated result for the given query. 64 | 65 | This function returns a pagination struct containing the entries for the requested page, 66 | along with metadata about the pagination such as total entries, page count, etc. 67 | 68 | ## Parameters 69 | * `query` - The Ecto query to paginate 70 | * `params` - Map of pagination parameters 71 | * `"page"` - Page number to fetch (defaults to 1) 72 | * `"per_page"` - Number of items per page (defaults to #{@default_per_page}) 73 | * `"total"` - Optional pre-calculated total count 74 | * `opts` - Options passed to `Repo.all/2` (optional) 75 | 76 | ## Returns 77 | * A `PaginationEx.Core` struct containing: 78 | * `entries` - The paginated query results 79 | * `total_entries` - Total count of matching records 80 | * `page_number` - Current page number 81 | * `per_page` - Number of items per page 82 | * `pages` - Total number of pages 83 | * `query` - The original query 84 | 85 | ## Examples 86 | iex> PaginationEx.Core.new(Post, %{"page" => "2", "per_page" => "10"}) 87 | %PaginationEx.Core{ 88 | entries: [%Post{}, %Post{}, ...], 89 | total_entries: 59, 90 | page_number: 2, 91 | per_page: 10, 92 | pages: 6, 93 | query: #Ecto.Query<...> 94 | } 95 | """ 96 | @spec new(Ecto.Queryable.t(), map(), keyword()) :: t() 97 | def new(query, params, opts \\ []) do 98 | page_number = params |> Map.get("page", 1) |> to_int(:page) 99 | per_page = params |> Map.get("per_page", @default_per_page) |> to_int(:per_page) 100 | total = total_entries(query, params) 101 | 102 | %__MODULE__{ 103 | entries: entries(query, page_number, per_page, opts), 104 | total_entries: total, 105 | page_number: page_number, 106 | per_page: per_page, 107 | pages: total_pages(total, per_page), 108 | query: query 109 | } 110 | end 111 | 112 | defp entries(query, page_number, per_page, opts) 113 | when is_integer(page_number) and is_integer(per_page) and per_page > 0 do 114 | page = max(1, page_number) 115 | offset = max(0, per_page * (page - 1)) 116 | 117 | from(query, offset: ^offset, limit: ^per_page) 118 | |> repo().all(opts) 119 | end 120 | 121 | defp entries(_query, _page_number, _per_page, _opts), do: [] 122 | 123 | defp total_entries(query, %{"total" => nil}), do: total_entries(query, %{}) 124 | 125 | defp total_entries(_query, %{"total" => total}) when is_binary(total) do 126 | case Integer.parse(total) do 127 | {num, _} when num >= 0 -> num 128 | _ -> 0 129 | end 130 | end 131 | 132 | defp total_entries(_query, %{"total" => total}) when is_integer(total) and total >= 0, do: total 133 | 134 | defp total_entries(query, _params) do 135 | cond do 136 | simple_count_query?(query) -> 137 | query 138 | |> exclude(:order_by) 139 | |> exclude(:preload) 140 | |> exclude(:select) 141 | |> select([x], count(x.id)) 142 | |> repo().one() || 0 143 | 144 | has_group_by?(query) || has_distinct?(query) -> 145 | count_query = """ 146 | WITH count_query AS ( 147 | #{query |> exclude(:order_by) |> exclude(:preload) |> exclude(:limit) |> exclude(:offset) |> to_sql()} 148 | ) 149 | SELECT count(*) FROM count_query 150 | """ 151 | 152 | %{rows: [[count]]} = repo().query!(count_query) 153 | count || 0 154 | 155 | true -> 156 | query 157 | |> exclude(:order_by) 158 | |> exclude(:preload) 159 | |> exclude(:select) 160 | |> exclude(:limit) 161 | |> exclude(:offset) 162 | |> subquery() 163 | |> select([s], count(s)) 164 | |> repo().one() || 0 165 | end 166 | end 167 | 168 | defp simple_count_query?(query) do 169 | !has_group_by?(query) && !has_distinct?(query) && !has_joins?(query, [:left, :right, :full]) 170 | end 171 | 172 | defp has_group_by?(%{group_bys: group_bys}) when is_list(group_bys) and length(group_bys) > 0 do 173 | true 174 | end 175 | 176 | defp has_group_by?(_), do: false 177 | 178 | defp has_distinct?(%{distinct: %{expr: expr}}) when not is_nil(expr), do: true 179 | defp has_distinct?(_), do: false 180 | 181 | defp has_joins?(query, types) do 182 | Enum.any?(query.joins, fn 183 | %{qual: qual} -> qual in types 184 | _ -> false 185 | end) 186 | end 187 | 188 | defp to_sql(query) do 189 | {sql, _} = SQL.to_sql(:all, repo(), query) 190 | sql 191 | end 192 | 193 | defp total_pages(total, per_page) when is_integer(per_page) and per_page > 0 do 194 | Float.ceil(total / per_page) |> Kernel.trunc() 195 | end 196 | 197 | defp total_pages(_total, _per_page), do: 0 198 | 199 | defp to_int(nil, :page), do: 1 200 | defp to_int(i, :page) when is_integer(i), do: max(1, i) 201 | 202 | defp to_int(s, :page) when is_binary(s) do 203 | case Integer.parse(s) do 204 | {i, _} -> max(1, i) 205 | _ -> 1 206 | end 207 | end 208 | 209 | defp to_int(_, :page), do: 1 210 | 211 | defp to_int(nil, :per_page), do: @default_per_page 212 | defp to_int(i, :per_page) when is_integer(i) and i > 0, do: i 213 | 214 | defp to_int(s, :per_page) when is_binary(s) do 215 | case Integer.parse(s) do 216 | {i, _} when i > 0 -> i 217 | _ -> @default_per_page 218 | end 219 | end 220 | 221 | defp to_int(_, :per_page), do: @default_per_page 222 | 223 | defp set_group_params(params) do 224 | per_group = Map.get(params, "per_group", config(:per_group, @default_per_group)) 225 | total = Map.get(params, "total") 226 | 227 | %{ 228 | "per_page" => per_group, 229 | "total" => total 230 | } 231 | end 232 | 233 | defp get_group( 234 | %__MODULE__{page_number: page_number, pages: pages} = pagination, 235 | collection 236 | ) 237 | when page_number == pages or pages == 0 do 238 | collection ++ pagination.entries 239 | end 240 | 241 | defp get_group( 242 | %__MODULE__{ 243 | page_number: page_number, 244 | per_page: per_page, 245 | total_entries: total_entries, 246 | entries: entries, 247 | query: query 248 | }, 249 | collection 250 | ) do 251 | query 252 | |> new( 253 | %{} 254 | |> Map.put("total", total_entries) 255 | |> Map.put("per_page", per_page) 256 | |> Map.put("page", page_number + 1) 257 | ) 258 | |> get_group(collection ++ entries) 259 | end 260 | 261 | defp repo do 262 | Application.get_env(:pagination_ex, :repo) || 263 | raise """ 264 | You must configure a repo for PaginationEx. For example: 265 | 266 | config :pagination_ex, :repo, MyApp.Repo 267 | """ 268 | end 269 | 270 | defp config(key, default) do 271 | Application.get_env(:pagination_ex, key, default) 272 | end 273 | end 274 | -------------------------------------------------------------------------------- /lib/pagination_ex/html.ex: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.HTML do 2 | @moduledoc """ 3 | HTML helpers for pagination rendering with support for custom templates. 4 | Default implementation uses Tailwind CSS. 5 | 6 | This module provides functions to generate HTML pagination components for 7 | Phoenix applications. It supports: 8 | 9 | - Standard pagination with next/previous links 10 | - Numbered page links 11 | - Customization through template modules 12 | - Internationalization through Gettext 13 | - Tailwind CSS styling by default 14 | 15 | The pagination component is designed to work with data already paginated using 16 | `PaginationEx.Core` and stored in `conn.assigns.pagination`. 17 | """ 18 | 19 | use PhoenixHTMLHelpers 20 | 21 | @doc """ 22 | Renders pagination controls for the current page. 23 | 24 | This function generates HTML pagination controls for the current page. It uses 25 | the pagination data stored in `conn.assigns.pagination`. 26 | 27 | ## Parameters 28 | * `conn` - The connection struct containing pagination data in `conn.assigns.pagination` 29 | * `path` - Either a router helper function name (atom) or a URL string 30 | * `opts` - Options for customization (optional) 31 | * `:template_module` - A module that implements `render_pagination/3` for custom rendering 32 | 33 | ## Returns 34 | * HTML markup for pagination controls or `nil` if total entries is less than per_page 35 | 36 | ## Examples 37 | # Using a path helper 38 | <%= PaginationEx.HTML.paginate(@conn, :post_path) %> 39 | 40 | # Using a URL string 41 | <%= PaginationEx.HTML.paginate(@conn, "/posts") %> 42 | 43 | # Using a custom template 44 | <%= PaginationEx.HTML.paginate(@conn, :post_path, template_module: MyApp.PaginationTemplate) %> 45 | """ 46 | @spec paginate(Plug.Conn.t(), atom() | String.t(), keyword()) :: Phoenix.HTML.safe() | nil 47 | def paginate(conn, path, opts \\ []) do 48 | template_module = Keyword.get(opts, :template_module) 49 | 50 | if template_module do 51 | template_module.render_pagination(conn, path, conn.assigns.pagination) 52 | else 53 | do_paginate(conn, path, conn.assigns.pagination) 54 | end 55 | end 56 | 57 | defp do_paginate(_conn, _path, %{total_entries: total_entries, per_page: per_page}) 58 | when total_entries < per_page, 59 | do: nil 60 | 61 | defp do_paginate(conn, path, %{ 62 | page_number: current_page, 63 | pages: pages 64 | }) do 65 | previous = previous_path(conn, path, current_page) 66 | next = next_path(conn, path, current_page, pages) 67 | 68 | pagination = 69 | content_tag(:p, "#{translate("Pages")}: #{current_page} #{translate("of")} #{pages}", 70 | class: "text-sm text-gray-700" 71 | ) 72 | 73 | content_tag( 74 | :nav, 75 | [pagination, previous, next, content_tag(:ul, [], class: "inline-flex -space-x-px")], 76 | class: "flex items-center justify-between border-t border-gray-200 px-4 py-3 sm:px-6", 77 | role: "navigation", 78 | "aria-label": "pagination" 79 | ) 80 | end 81 | 82 | @doc """ 83 | Generates HTML for the "Next" pagination link. 84 | 85 | Creates either an active link to the next page or a disabled button if 86 | the current page is the last page. 87 | 88 | ## Parameters 89 | * `conn` - The connection struct 90 | * `path` - Either a router helper function name (atom) or a URL string 91 | * `current_page` - The current page number 92 | * `total_pages` - The total number of pages 93 | 94 | ## Returns 95 | * HTML markup for the next page link 96 | 97 | ## Examples 98 | <%= PaginationEx.HTML.next_path(@conn, :post_path, 1, 5) %> 99 | """ 100 | @spec next_path(Plug.Conn.t(), atom() | String.t(), integer(), integer()) :: Phoenix.HTML.safe() 101 | def next_path(conn, path, current_page, total_pages) do 102 | if total_pages > current_page do 103 | next_path_internal(conn, path, current_page) 104 | else 105 | content_tag(:a, translate("Next"), 106 | class: 107 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-r-lg cursor-not-allowed", 108 | disabled: true 109 | ) 110 | end 111 | end 112 | 113 | defp next_path_internal(conn, path, ""), do: next_path_internal(conn, path, 1) 114 | defp next_path_internal(conn, path, nil), do: next_path_internal(conn, path, 1) 115 | 116 | defp next_path_internal(conn, path, page) do 117 | next_page = page + 1 118 | url = build_url(conn, path, next_page) 119 | 120 | link(translate("Next"), 121 | to: url, 122 | class: 123 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-r-lg hover:bg-gray-50" 124 | ) 125 | end 126 | 127 | @doc """ 128 | Generates HTML for the "Previous" pagination link. 129 | 130 | Creates either an active link to the previous page or a disabled button if 131 | the current page is the first page. 132 | 133 | ## Parameters 134 | * `conn` - The connection struct 135 | * `path` - Either a router helper function name (atom) or a URL string 136 | * `current_page` - The current page number 137 | 138 | ## Returns 139 | * HTML markup for the previous page link 140 | 141 | ## Examples 142 | <%= PaginationEx.HTML.previous_path(@conn, :post_path, 2) %> 143 | """ 144 | @spec previous_path(Plug.Conn.t(), atom() | String.t(), integer()) :: Phoenix.HTML.safe() 145 | def previous_path(conn, path, current_page) do 146 | if current_page > 1 do 147 | previous_path_internal(conn, path, current_page) 148 | else 149 | content_tag(:a, translate("Previous"), 150 | class: 151 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-500 bg-white border border-gray-300 rounded-l-lg cursor-not-allowed", 152 | disabled: true 153 | ) 154 | end 155 | end 156 | 157 | defp previous_path_internal(conn, path, ""), do: previous_path_internal(conn, path, 1) 158 | defp previous_path_internal(conn, path, nil), do: previous_path_internal(conn, path, 1) 159 | 160 | defp previous_path_internal(conn, path, page) do 161 | previous_page = page - 1 162 | url = build_url(conn, path, previous_page) 163 | 164 | link(translate("Previous"), 165 | to: url, 166 | class: 167 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-l-lg hover:bg-gray-50" 168 | ) 169 | end 170 | 171 | @doc """ 172 | Generates HTML for numbered page links. 173 | 174 | Creates a list of links for all pages, with the current page highlighted. 175 | 176 | ## Parameters 177 | * `conn` - The connection struct 178 | * `path` - Either a router helper function name (atom) or a URL string 179 | * `pagination` - A map containing `:page_number` (current page) and `:pages` (total pages) 180 | 181 | ## Returns 182 | * List of HTML markup for page links with interspersed spaces 183 | 184 | ## Examples 185 | <%= for link <- PaginationEx.HTML.page_links(@conn, :post_path, %{page_number: 2, pages: 5}) do %> 186 | <%= link %> 187 | <% end %> 188 | """ 189 | @spec page_links(Plug.Conn.t(), atom() | String.t(), map()) :: list(Phoenix.HTML.safe()) 190 | def page_links(conn, path, %{page_number: current_page, pages: total_pages}) do 191 | 1..total_pages 192 | |> Enum.map(fn page -> 193 | page_link(conn, path, page, page == current_page) 194 | end) 195 | |> Enum.intersperse(" ") 196 | end 197 | 198 | defp page_link(conn, path, page, is_current) do 199 | url = build_url(conn, path, page) 200 | 201 | if is_current do 202 | content_tag(:span, page, 203 | class: 204 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-white bg-blue-600 border border-blue-600" 205 | ) 206 | else 207 | link(page, 208 | to: url, 209 | class: 210 | "relative inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 hover:bg-gray-50" 211 | ) 212 | end 213 | end 214 | 215 | @doc """ 216 | Builds a URL for pagination links. 217 | 218 | This function handles both router helper functions (atoms) and URL strings, creating 219 | a properly formatted link with the page parameter. 220 | 221 | ## Parameters 222 | * `conn` - The connection struct 223 | * `path` - Either a router helper function name (atom) or a URL string 224 | * `page` - The page number to link to 225 | 226 | ## Returns 227 | * URL string with page parameter 228 | 229 | ## Examples 230 | iex> PaginationEx.HTML.build_url(conn, :post_path, 2) 231 | "/posts?page=2" 232 | 233 | iex> PaginationEx.HTML.build_url(conn, "/posts", 2) 234 | "/posts?page=2" 235 | 236 | iex> PaginationEx.HTML.build_url(conn, "/posts?sort=asc", 2) 237 | "/posts?sort=asc&page=2" 238 | """ 239 | @spec build_url(Plug.Conn.t(), atom() | String.t(), integer()) :: String.t() 240 | def build_url(conn, path, page) when is_atom(path) do 241 | router_module = config(:router_helpers) || raise "Router helpers module not configured" 242 | apply(router_module, path, [conn, :index, Map.put(conn.params, "page", page)]) 243 | end 244 | 245 | def build_url(conn, path, page) do 246 | params = URI.encode_query(Map.put(conn.params, "page", page)) 247 | 248 | if Regex.match?(~r/\?/, path) do 249 | path <> "&" <> params 250 | else 251 | path <> "?" <> params 252 | end 253 | end 254 | 255 | @doc """ 256 | Translates text using the configured Gettext module. 257 | 258 | If a Gettext module is configured, the text is passed through gettext for translation. 259 | Otherwise, the original text is returned unchanged. 260 | 261 | ## Parameters 262 | * `text` - The text to translate 263 | 264 | ## Returns 265 | * Translated text if a Gettext module is configured, otherwise the original text 266 | 267 | ## Examples 268 | iex> PaginationEx.HTML.translate("Next") 269 | "Next" 270 | 271 | # With configured Gettext module: 272 | iex> PaginationEx.HTML.translate("Next") 273 | "Próximo" # If the locale is set to Portuguese 274 | """ 275 | @spec translate(String.t()) :: String.t() 276 | def translate(text) do 277 | case config(:gettext_module) do 278 | nil -> text 279 | module -> module.gettext(text) 280 | end 281 | end 282 | 283 | defp config(key) do 284 | Application.get_env(:pagination_ex, key) 285 | end 286 | end 287 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.MixProject do 2 | use Mix.Project 3 | 4 | @version "0.1.0" 5 | @source_url "https://github.com/idopterlabs/pagination_ex" 6 | 7 | def project do 8 | [ 9 | app: :pagination_ex, 10 | version: @version, 11 | elixir: "~> 1.18", 12 | start_permanent: Mix.env() == :prod, 13 | deps: deps(), 14 | description: description(), 15 | package: package(), 16 | name: "PaginationEx", 17 | docs: docs(), 18 | dialyzer: [plt_local_path: "priv/plts", ignore_warnings: ".dialyzerignore"] 19 | ] 20 | end 21 | 22 | # Run "mix help compile.app" to learn about applications. 23 | def application do 24 | [ 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | # Run "mix help deps" to learn about dependencies. 30 | defp deps do 31 | [ 32 | {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, 33 | {:dialyxir, "~> 1.3", only: [:dev, :test], runtime: false}, 34 | {:ecto_sql, "~> 3.12"}, 35 | {:ecto, "~> 3.12"}, 36 | {:ex_doc, "~> 0.37.1", only: :dev, runtime: false}, 37 | {:gettext, "~> 0.26.2"}, 38 | {:jason, "~> 1.4"}, 39 | {:phoenix_ecto, "~> 4.6"}, 40 | {:phoenix_html_helpers, "~> 1.0"}, 41 | {:postgrex, "~> 0.20.0"}, 42 | {:tailwind, "~> 0.2.4"} 43 | ] 44 | end 45 | 46 | defp description do 47 | """ 48 | A flexible pagination library for Elixir and Phoenix applications. 49 | """ 50 | end 51 | 52 | defp package do 53 | [ 54 | licenses: ["Apache-2.0"], 55 | links: %{"GitHub" => @source_url}, 56 | files: ~w(lib priv LICENSE mix.exs README.md) 57 | ] 58 | end 59 | 60 | defp docs do 61 | [ 62 | main: "readme", 63 | extras: ["README.md", "CHANGELOG.md"], 64 | source_ref: @version, 65 | skip_undefined_reference_warnings_on: ["CHANGELOG.md"] 66 | ] 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, 3 | "castore": {:hex, :castore, "1.0.11", "4bbd584741601eb658007339ea730b082cc61f3554cf2e8f39bf693a11b49073", [:mix], [], "hexpm", "e03990b4db988df56262852f20de0f659871c35154691427a5047f4967a16a62"}, 4 | "credo": {:hex, :credo, "1.7.11", "d3e805f7ddf6c9c854fd36f089649d7cf6ba74c42bc3795d587814e3c9847102", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "56826b4306843253a66e47ae45e98e7d284ee1f95d53d1612bb483f88a8cf219"}, 5 | "db_connection": {:hex, :db_connection, "2.7.0", "b99faa9291bb09892c7da373bb82cba59aefa9b36300f6145c5f201c7adf48ec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "dcf08f31b2701f857dfc787fbad78223d61a32204f217f15e881dd93e4bdd3ff"}, 6 | "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, 7 | "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, 9 | "ecto": {:hex, :ecto, "3.12.5", "4a312960ce612e17337e7cefcf9be45b95a3be6b36b6f94dfb3d8c361d631866", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6eb18e80bef8bb57e17f5a7f068a1719fbda384d40fc37acb8eb8aeca493b6ea"}, 10 | "ecto_sql": {:hex, :ecto_sql, "3.12.1", "c0d0d60e85d9ff4631f12bafa454bc392ce8b9ec83531a412c12a0d415a3a4d0", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.12", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aff5b958a899762c5f09028c847569f7dfb9cc9d63bdb8133bff8a5546de6bf5"}, 11 | "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, 12 | "ex_doc": {:hex, :ex_doc, "0.37.1", "65ca30d242082b95aa852b3b73c9d9914279fff56db5dc7b3859be5504417980", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "6774f75477733ea88ce861476db031f9399c110640752ca2b400dbbb50491224"}, 13 | "expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"}, 14 | "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, 15 | "gettext": {:hex, :gettext, "0.26.2", "5978aa7b21fada6deabf1f6341ddba50bc69c999e812211903b169799208f2a8", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "aa978504bcf76511efdc22d580ba08e2279caab1066b76bb9aa81c4a1e0a32a5"}, 16 | "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, 17 | "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, 18 | "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, 19 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, 20 | "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, 22 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.6.3", "f686701b0499a07f2e3b122d84d52ff8a31f5def386e03706c916f6feddf69ef", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "909502956916a657a197f94cc1206d9a65247538de8a5e186f7537c895d95764"}, 23 | "phoenix_html": {:hex, :phoenix_html, "4.2.0", "83a4d351b66f472ebcce242e4ae48af1b781866f00ef0eb34c15030d4e2069ac", [:mix], [], "hexpm", "9713b3f238d07043583a94296cc4bbdceacd3b3a6c74667f4df13971e7866ec8"}, 24 | "phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"}, 25 | "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, 26 | "plug_crypto": {:hex, :plug_crypto, "2.1.0", "f44309c2b06d249c27c8d3f65cfe08158ade08418cf540fd4f72d4d6863abb7b", [:mix], [], "hexpm", "131216a4b030b8f8ce0f26038bc4421ae60e4bb95c5cf5395e1421437824c4fa"}, 27 | "postgrex": {:hex, :postgrex, "0.20.0", "363ed03ab4757f6bc47942eff7720640795eb557e1935951c1626f0d303a3aed", [:mix], [{: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]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "d36ef8b36f323d29505314f704e21a1a038e2dc387c6409ee0cd24144e187c0f"}, 28 | "tailwind": {:hex, :tailwind, "0.2.4", "5706ec47182d4e7045901302bf3a333e80f3d1af65c442ba9a9eed152fb26c2e", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}], "hexpm", "c6e4a82b8727bab593700c998a4d98cf3d8025678bfde059aed71d0000c3e463"}, 29 | "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/pagination_ex/core_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.CoreTest do 2 | use ExUnit.Case 3 | use Ecto.Schema 4 | 5 | import Ecto.Query 6 | require Logger 7 | 8 | @default_per_page 30 9 | 10 | defmodule TestRepo do 11 | use Ecto.Repo, 12 | otp_app: :pagination_ex, 13 | adapter: Ecto.Adapters.Postgres 14 | end 15 | 16 | defmodule TestSchema do 17 | use Ecto.Schema 18 | 19 | schema "test_items" do 20 | field(:name, :string) 21 | field(:category, :string) 22 | timestamps() 23 | end 24 | end 25 | 26 | setup_all do 27 | original_log_level = Logger.level() 28 | original_backends = :logger.get_handler_config() 29 | 30 | Logger.configure(level: :error) 31 | :logger.remove_handler(:default) 32 | 33 | Application.put_env(:pagination_ex, TestRepo, 34 | username: "postgres", 35 | password: "postgres", 36 | hostname: "localhost", 37 | port: 5432, 38 | database: "pagination_ex_test", 39 | pool: Ecto.Adapters.SQL.Sandbox, 40 | log: false 41 | ) 42 | 43 | Application.put_env(:ecto, :logger, false) 44 | Application.put_env(:postgrex, :debug_logger, false) 45 | 46 | TestRepo.start_link() 47 | 48 | on_exit(fn -> 49 | Logger.configure(level: original_log_level) 50 | 51 | :logger.add_handler(:default, :logger_std_h, %{ 52 | config: %{type: :standard_io}, 53 | formatter: original_backends[:default][:formatter] 54 | }) 55 | end) 56 | 57 | :ok 58 | end 59 | 60 | setup do 61 | TestRepo.query!("DROP TABLE IF EXISTS test_item_details CASCADE") 62 | TestRepo.query!("DROP TABLE IF EXISTS test_items CASCADE") 63 | 64 | TestRepo.query!(""" 65 | CREATE TABLE IF NOT EXISTS test_items ( 66 | id serial primary key, 67 | name text, 68 | category text, 69 | inserted_at timestamp without time zone DEFAULT current_timestamp, 70 | updated_at timestamp without time zone DEFAULT current_timestamp 71 | ) 72 | """) 73 | 74 | TestRepo.query!(""" 75 | CREATE TABLE IF NOT EXISTS test_item_details ( 76 | id serial primary key, 77 | item_id integer references test_items(id), 78 | details text, 79 | tag text 80 | ) 81 | """) 82 | 83 | for i <- 1..30 do 84 | TestRepo.query!( 85 | "INSERT INTO test_items (name, category) VALUES ($1, $2)", 86 | ["Item #{i}", "Category #{div(i, 10)}"] 87 | ) 88 | end 89 | 90 | Application.put_env(:pagination_ex, :repo, TestRepo) 91 | :ok 92 | end 93 | 94 | describe "pagination" do 95 | test "successfully paginates with default params" do 96 | query = from(i in TestSchema) 97 | result = PaginationEx.new(query, %{}) 98 | 99 | assert result.page_number == 1 100 | assert result.per_page == 30 101 | assert result.total_entries == 30 102 | assert result.pages == 1 103 | assert length(result.entries) == 30 104 | end 105 | 106 | test "successfully paginates with custom page and per_page" do 107 | query = from(i in TestSchema) 108 | result = PaginationEx.new(query, %{"page" => "2", "per_page" => "10"}) 109 | 110 | assert result.page_number == 2 111 | assert result.per_page == 10 112 | assert result.total_entries == 30 113 | assert result.pages == 3 114 | assert length(result.entries) == 10 115 | end 116 | 117 | test "handles empty result set" do 118 | query = from(i in TestSchema, where: i.name == "NonExistent") 119 | result = PaginationEx.new(query, %{}) 120 | 121 | assert result.page_number == 1 122 | assert result.total_entries == 0 123 | assert result.pages == 0 124 | assert Enum.empty?(result.entries) 125 | end 126 | 127 | test "in_groups returns all items" do 128 | query = from(i in TestSchema) 129 | result = PaginationEx.in_groups(query, %{"per_group" => "10"}) 130 | 131 | assert length(result) == 30 132 | end 133 | 134 | test "handles invalid page number" do 135 | query = from(i in TestSchema) 136 | result = PaginationEx.new(query, %{"page" => "invalid"}) 137 | 138 | assert result.page_number == 1 139 | end 140 | 141 | test "handles zero per_page" do 142 | query = from(i in TestSchema) 143 | result = PaginationEx.new(query, %{"per_page" => "0"}) 144 | 145 | assert result.per_page == @default_per_page 146 | end 147 | 148 | test "handles negative per_page" do 149 | query = from(i in TestSchema) 150 | result = PaginationEx.new(query, %{"per_page" => "-10"}) 151 | 152 | assert result.per_page == @default_per_page 153 | end 154 | 155 | test "handles page number greater than total pages" do 156 | query = from(i in TestSchema) 157 | result = PaginationEx.new(query, %{"page" => "5"}) 158 | 159 | assert result.page_number == 5 160 | assert result.total_entries == 30 161 | assert Enum.empty?(result.entries) 162 | end 163 | 164 | test "accepts total override in params" do 165 | query = from(i in TestSchema) 166 | result = PaginationEx.new(query, %{"total" => "50"}) 167 | 168 | assert result.total_entries == 50 169 | end 170 | 171 | test "handles nil values in params" do 172 | query = from(i in TestSchema) 173 | result = PaginationEx.new(query, %{"page" => nil, "per_page" => nil}) 174 | 175 | assert result.page_number == 1 176 | assert result.per_page == 30 177 | end 178 | 179 | test "handles missing repo configuration" do 180 | Application.delete_env(:pagination_ex, :repo) 181 | 182 | assert_raise RuntimeError, ~r/You must configure a repo/, fn -> 183 | query = from(i in TestSchema) 184 | PaginationEx.new(query, %{}) 185 | end 186 | end 187 | 188 | test "handles in_groups with empty result set" do 189 | query = from(i in TestSchema, where: i.name == "NonExistent") 190 | result = PaginationEx.in_groups(query, %{}) 191 | 192 | assert Enum.empty?(result) 193 | end 194 | 195 | test "accepts integer params" do 196 | query = from(i in TestSchema) 197 | result = PaginationEx.new(query, %{"page" => 2, "per_page" => 10}) 198 | 199 | assert result.page_number == 2 200 | assert result.per_page == 10 201 | assert result.total_entries == 30 202 | end 203 | 204 | test "calculates total pages correctly with remainder" do 205 | query = from(i in TestSchema) 206 | result = PaginationEx.new(query, %{"per_page" => "7"}) 207 | 208 | assert result.pages == 5 209 | end 210 | 211 | test "keeps original query in struct" do 212 | query = from(i in TestSchema) 213 | result = PaginationEx.new(query, %{}) 214 | 215 | assert result.query == query 216 | end 217 | 218 | test "handles very large page numbers" do 219 | query = from(i in TestSchema) 220 | result = PaginationEx.new(query, %{"page" => "999999"}) 221 | 222 | assert result.page_number == 999_999 223 | assert Enum.empty?(result.entries) 224 | end 225 | 226 | test "handles very large per_page" do 227 | query = from(i in TestSchema) 228 | result = PaginationEx.new(query, %{"per_page" => "999999"}) 229 | 230 | assert result.per_page == 999_999 231 | assert length(result.entries) == 30 232 | end 233 | 234 | test "successfully paginates with GROUP BY query" do 235 | query = 236 | from(i in TestSchema, 237 | group_by: i.category, 238 | select: {i.category, count(i.id)} 239 | ) 240 | 241 | result = PaginationEx.new(query, %{}) 242 | 243 | assert result.page_number == 1 244 | assert result.total_entries == 4 245 | assert result.pages == 1 246 | assert length(result.entries) == 4 247 | end 248 | 249 | test "paginates GROUP BY query with custom page and per_page" do 250 | query = 251 | from(i in TestSchema, 252 | group_by: i.category, 253 | select: {i.category, count(i.id)} 254 | ) 255 | 256 | result = PaginationEx.new(query, %{"page" => "1", "per_page" => "2"}) 257 | 258 | assert result.page_number == 1 259 | assert result.per_page == 2 260 | assert result.total_entries == 4 261 | assert result.pages == 2 262 | assert length(result.entries) == 2 263 | end 264 | 265 | test "handles empty result set with GROUP BY" do 266 | query = 267 | from(i in TestSchema, 268 | where: i.name == "NonExistent", 269 | group_by: i.category, 270 | select: {i.category, count(i.id)} 271 | ) 272 | 273 | result = PaginationEx.new(query, %{}) 274 | 275 | assert result.page_number == 1 276 | assert result.total_entries == 0 277 | assert result.pages == 0 278 | assert Enum.empty?(result.entries) 279 | end 280 | 281 | test "successfully paginates with GROUP BY and ORDER BY" do 282 | query = 283 | from(i in TestSchema, 284 | group_by: i.category, 285 | order_by: [desc: count(i.id)], 286 | select: {i.category, count(i.id)} 287 | ) 288 | 289 | result = PaginationEx.new(query, %{}) 290 | 291 | assert result.page_number == 1 292 | assert result.total_entries == 4 293 | assert result.pages == 1 294 | assert length(result.entries) == 4 295 | end 296 | 297 | test "handles complex GROUP BY with multiple grouping expressions" do 298 | for i <- 1..10 do 299 | TestRepo.query!( 300 | "INSERT INTO test_items (name, category) VALUES ($1, $2)", 301 | ["Extra Item #{i}", "Extra Category"] 302 | ) 303 | end 304 | 305 | query = 306 | from(i in TestSchema, 307 | group_by: [i.category, fragment("date_trunc('day', ?)", i.inserted_at)], 308 | select: {i.category, fragment("date_trunc('day', ?)", i.inserted_at), count(i.id)} 309 | ) 310 | 311 | result = PaginationEx.new(query, %{}) 312 | 313 | assert result.total_entries == 5 314 | assert length(result.entries) == 5 315 | end 316 | 317 | test "handles DISTINCT queries" do 318 | for _ <- 1..5 do 319 | TestRepo.query!( 320 | "INSERT INTO test_items (name, category) VALUES ($1, $2)", 321 | ["Duplicate Item", "Duplicate Category"] 322 | ) 323 | end 324 | 325 | query = 326 | from(i in TestSchema, 327 | distinct: i.category, 328 | select: i.category 329 | ) 330 | 331 | result = PaginationEx.new(query, %{}) 332 | 333 | assert result.total_entries == 5 334 | assert length(result.entries) == 5 335 | end 336 | 337 | test "handles queries with JOIN" do 338 | for i <- 1..10 do 339 | TestRepo.query!( 340 | "INSERT INTO test_item_details (item_id, details) VALUES ($1, $2)", 341 | [i, "Details for item #{i}"] 342 | ) 343 | end 344 | 345 | query = 346 | from(i in TestSchema, 347 | join: d in "test_item_details", 348 | on: i.id == d.item_id, 349 | select: {i.id, i.name, d.details} 350 | ) 351 | 352 | result = PaginationEx.new(query, %{}) 353 | 354 | assert result.total_entries == 10 355 | assert length(result.entries) == 10 356 | end 357 | 358 | test "handles queries with JOIN and GROUP BY" do 359 | for i <- 1..10 do 360 | tag = (rem(i, 3) == 0 && "tag_a") || "tag_b" 361 | 362 | TestRepo.query!( 363 | "INSERT INTO test_item_details (item_id, details, tag) VALUES ($1, $2, $3)", 364 | [i, "Details for item #{i}", tag] 365 | ) 366 | end 367 | 368 | query = 369 | from(i in TestSchema, 370 | join: d in "test_item_details", 371 | on: i.id == d.item_id, 372 | group_by: d.tag, 373 | select: {d.tag, count(i.id)} 374 | ) 375 | 376 | result = PaginationEx.new(query, %{}) 377 | 378 | assert result.total_entries == 2 379 | assert length(result.entries) == 2 380 | end 381 | 382 | test "performance of count with large dataset" do 383 | TestRepo.query!("TRUNCATE test_items RESTART IDENTITY CASCADE") 384 | 385 | batch_size = 100 386 | total_records = 1000 387 | 388 | for batch <- 0..div(total_records - 1, batch_size) do 389 | params = 390 | for i <- 1..batch_size do 391 | index = batch * batch_size + i 392 | category = "Category #{div(index, 100)}" 393 | ["Item #{index}", category] 394 | end 395 | 396 | placeholders = 397 | params 398 | |> Enum.with_index() 399 | |> Enum.map_join(", ", fn {_, idx} -> "($#{idx * 2 + 1}, $#{idx * 2 + 2})" end) 400 | 401 | values = params |> List.flatten() 402 | 403 | TestRepo.query!( 404 | "INSERT INTO test_items (name, category) VALUES " <> placeholders, 405 | values 406 | ) 407 | end 408 | 409 | {time_normal, result_normal} = 410 | :timer.tc(fn -> 411 | query = from(i in TestSchema) 412 | PaginationEx.new(query, %{}) 413 | end) 414 | 415 | {time_group, result_group} = 416 | :timer.tc(fn -> 417 | query = 418 | from(i in TestSchema, 419 | group_by: i.category, 420 | select: {i.category, count(i.id)} 421 | ) 422 | 423 | PaginationEx.new(query, %{}) 424 | end) 425 | 426 | assert result_normal.total_entries == total_records 427 | assert result_group.total_entries >= 10 && result_group.total_entries <= 11 428 | 429 | assert time_group < time_normal * 10, "GROUP BY query time should not be excessive" 430 | end 431 | end 432 | end 433 | -------------------------------------------------------------------------------- /test/pagination_ex/html_test.exs: -------------------------------------------------------------------------------- 1 | defmodule PaginationEx.HTMLTest do 2 | use ExUnit.Case 3 | 4 | alias Phoenix.HTML.Safe 5 | 6 | setup do 7 | conn = %{ 8 | params: %{}, 9 | assigns: %{ 10 | pagination: %PaginationEx.Core{ 11 | entries: [1, 2, 3], 12 | total_entries: 10, 13 | page_number: 2, 14 | per_page: 3, 15 | pages: 4 16 | } 17 | } 18 | } 19 | 20 | {:ok, conn: conn} 21 | end 22 | 23 | describe "paginate/3 with default template" do 24 | test "renders basic pagination", %{conn: conn} do 25 | result = 26 | conn 27 | |> PaginationEx.paginate("/test") 28 | |> Safe.to_iodata() 29 | |> IO.iodata_to_binary() 30 | 31 | assert result =~ "Pages: 2 of 4" 32 | assert result =~ "Previous" 33 | assert result =~ "Next" 34 | end 35 | 36 | test "handles paths with existing query params", %{conn: conn} do 37 | conn = %{conn | params: %{"filter" => "active"}} 38 | 39 | result = 40 | conn 41 | |> PaginationEx.paginate("/test?sort=desc") 42 | |> Safe.to_iodata() 43 | |> IO.iodata_to_binary() 44 | 45 | assert result =~ "sort=desc" 46 | assert result =~ "page=" 47 | end 48 | 49 | test "disables previous on first page", %{conn: conn} do 50 | conn = put_in(conn.assigns.pagination.page_number, 1) 51 | 52 | result = 53 | conn 54 | |> PaginationEx.paginate("/test") 55 | |> Safe.to_iodata() 56 | |> IO.iodata_to_binary() 57 | 58 | assert result =~ "disabled" 59 | assert result =~ "cursor-not-allowed" 60 | assert result =~ "Previous" 61 | refute result =~ "href=\"/test?page=0\"" 62 | end 63 | 64 | test "disables next on last page", %{conn: conn} do 65 | conn = put_in(conn.assigns.pagination.page_number, 4) 66 | 67 | result = 68 | conn 69 | |> PaginationEx.paginate("/test") 70 | |> Safe.to_iodata() 71 | |> IO.iodata_to_binary() 72 | 73 | assert result =~ "disabled" 74 | assert result =~ "cursor-not-allowed" 75 | assert result =~ "Next" 76 | refute result =~ "href=\"/test?page=5\"" 77 | end 78 | 79 | test "includes proper ARIA attributes", %{conn: conn} do 80 | result = 81 | conn 82 | |> PaginationEx.paginate("/test") 83 | |> Safe.to_iodata() 84 | |> IO.iodata_to_binary() 85 | 86 | assert result =~ ~s(role="navigation") 87 | assert result =~ ~s(aria-label="pagination") 88 | end 89 | 90 | test "handles no results", %{conn: conn} do 91 | conn = put_in(conn.assigns.pagination.total_entries, 0) 92 | conn = put_in(conn.assigns.pagination.per_page, 10) 93 | result = PaginationEx.paginate(conn, "/test") 94 | assert result == nil 95 | end 96 | end 97 | 98 | describe "paginate/3 with custom template" do 99 | defmodule TestTemplateModule do 100 | use PhoenixHTMLHelpers 101 | 102 | def render_pagination(_conn, _path, %{total_entries: total_entries, per_page: per_page}) 103 | when total_entries < per_page, 104 | do: nil 105 | 106 | def render_pagination(_conn, _path, %{page_number: current_page, pages: total_pages}) do 107 | content_tag( 108 | :div, 109 | [ 110 | content_tag(:p, "Custom Pages: #{current_page}/#{total_pages}"), 111 | content_tag(:div, "Custom Pagination") 112 | ], 113 | class: "custom-pagination" 114 | ) 115 | end 116 | end 117 | 118 | test "uses custom template when provided", %{conn: conn} do 119 | result = 120 | conn 121 | |> PaginationEx.paginate("/test", template_module: TestTemplateModule) 122 | |> Safe.to_iodata() 123 | |> IO.iodata_to_binary() 124 | 125 | assert result =~ "Custom Pages: 2/4" 126 | assert result =~ "Custom Pagination" 127 | assert result =~ "custom-pagination" 128 | end 129 | 130 | test "custom template handles no results", %{conn: conn} do 131 | conn = put_in(conn.assigns.pagination.total_entries, 0) 132 | conn = put_in(conn.assigns.pagination.per_page, 10) 133 | result = PaginationEx.paginate(conn, "/test", template_module: TestTemplateModule) 134 | assert result == nil 135 | end 136 | end 137 | end 138 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------