├── .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 |
--------------------------------------------------------------------------------