diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98f8244 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/_build +/deps +/doc +erl_crash.dump +*.ez diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..322f3eb --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: elixir +otp_release: + - 18.2 +elixir: + - 1.2.3 +before_script: + - export SCRIVENER_ECTO_DB_USER=postgres + - MIX_ENV=test mix scrivener.ecto.db.reset diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8fc0566 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.0 + +* Initial release diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..75b0ab4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Andrew Olson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..79c72c0 --- /dev/null +++ b/README.md @@ -0,0 +1,82 @@ +# Scrivener.Ecto + +[![Build Status](https://travis-ci.org/drewolson/scrivener_ecto.svg)](https://travis-ci.org/drewolson/scrivener_ecto) [![Hex Version](http://img.shields.io/hexpm/v/scrivener_ecto.svg?style=flat)](https://hex.pm/packages/scrivener_ecto) [![Hex docs](http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat)](https://hexdocs.pm/scrivener_ecto) + +Scrivener.Ecto allows you to paginate your Ecto queries with Scrivener. It gives you useful information such as the total number of pages, the current page, and the current page's entries. It works nicely with Phoenix as well. + +First, you'll want to `use` Scrivener in your application's Ecto Repo. This will add a `paginate` function to your Repo. This `paginate` function expects to be called with, at a minimum, an Ecto query. It will then paginate the query and execute it, returning a `Scrivener.Page`. Defaults for `page_size` can be configued when you `use` Scrivener. If no `page_size` is provided, Scrivener will use `10` by default. + +You may also want to call `paginate` with a params map along with your query. If provided with a params map, Scrivener will use the values in the keys `"page"` and `"page_size"` before using any configured defaults. + +## Example + +```elixir +defmodule MyApp.Repo do + use Ecto.Repo, otp_app: :my_app + use Scrivener, page_size: 10 +end +``` + +```elixir +defmodule MyApp.Person do + use Ecto.Schema + + schema "people" do + field :name, :string + field :age, :integer + + has_many :friends, MyApp.Person + end +end +``` + +```elixir +def index(conn, params) do + page = MyApp.Person + |> where([p], p.age > 30) + |> order_by([p], desc: p.age) + |> preload(:friends) + |> MyApp.Repo.paginate(params) + + render conn, :index, + people: page.entries, + page_number: page.page_number, + page_size: page.page_size, + total_pages: page.total_pages, + total_entries: page.total_entries +end +``` + +```elixir +page = MyApp.Person +|> where([p], p.age > 30) +|> order_by([p], desc: p.age) +|> preload(:friends) +|> MyApp.Repo.paginate(page: 2, page_size: 5) +``` + +## Installation + +Add `scrivener_ecto` to your `mix.exs` dependencies. + +```elixir +defp deps do + [{:scrivener_ecto, "~> 1.0"}] +end +``` + +## Contributing + +First, you'll need to build the test database. + +```elixir +MIX_ENV=test mix scrivener.ecto.db.reset +``` + +This task assumes you have postgres installed and that your current user can create / drop databases. If you'd prefer to use a different user, you can specify it with the environment variable `SCRIVENER_ECTO_DB_USER`. + +With the database built, you can now run the tests. + +```elixir +mix test +``` diff --git a/config/config.exs b/config/config.exs new file mode 100644 index 0000000..c9c59bb --- /dev/null +++ b/config/config.exs @@ -0,0 +1,3 @@ +use Mix.Config + +import_config "#{Mix.env}.exs" diff --git a/config/dev.exs b/config/dev.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/dev.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/prod.exs b/config/prod.exs new file mode 100644 index 0000000..d2d855e --- /dev/null +++ b/config/prod.exs @@ -0,0 +1 @@ +use Mix.Config diff --git a/config/test.exs b/config/test.exs new file mode 100644 index 0000000..e28c422 --- /dev/null +++ b/config/test.exs @@ -0,0 +1,10 @@ +use Mix.Config + +config :scrivener_ecto, ScrivenerEcto.Repo, + adapter: Ecto.Adapters.Postgres, + pool: Ecto.Adapters.SQL.Sandbox, + database: "scrivener_test", + username: System.get_env("SCRIVENER_ECTO_DB_USER") || System.get_env("USER") + +config :logger, :console, + level: :error diff --git a/lib/mix/tasks/scrivener/ecto/db/reset.ex b/lib/mix/tasks/scrivener/ecto/db/reset.ex new file mode 100644 index 0000000..e488259 --- /dev/null +++ b/lib/mix/tasks/scrivener/ecto/db/reset.ex @@ -0,0 +1,13 @@ +defmodule Mix.Tasks.Scrivener.Ecto.Db.Reset do + use Mix.Task + + @moduledoc false + + def run(_args) do + Logger.configure(level: :error) + + Mix.Task.run("ecto.drop", []) + Mix.Task.run("ecto.create", []) + Mix.Task.run("ecto.migrate", []) + end +end diff --git a/lib/scrivener/paginater/ecto/query.ex b/lib/scrivener/paginater/ecto/query.ex new file mode 100644 index 0000000..0e6d704 --- /dev/null +++ b/lib/scrivener/paginater/ecto/query.ex @@ -0,0 +1,56 @@ +defimpl Scrivener.Paginater, for: Ecto.Query do + import Ecto.Query + + alias Scrivener.Config + alias Scrivener.Page + + @spec paginate(Ecto.Query.t, Scrivener.Config.t) :: Scrivener.Page.t + def paginate(query, %Config{page_size: page_size, page_number: page_number, module: repo}) do + total_entries = total_entries(query, repo) + + %Page{ + page_size: page_size, + page_number: page_number, + entries: entries(query, repo, page_number, page_size), + total_entries: total_entries, + total_pages: total_pages(total_entries, page_size) + } + end + + defp ceiling(float) do + t = trunc(float) + + case float - t do + neg when neg < 0 -> + t + pos when pos > 0 -> + t + 1 + _ -> t + end + end + + defp entries(query, repo, page_number, page_size) do + offset = page_size * (page_number - 1) + + query + |> limit([_], ^page_size) + |> offset([_], ^offset) + |> repo.all + end + + defp total_entries(query, repo) do + stripped_query = query + |> exclude(:order_by) + |> exclude(:preload) + |> exclude(:select) + + {query_sql, parameters} = Ecto.Adapters.SQL.to_sql(:all, repo, stripped_query) + {:ok, %{num_rows: 1, rows: [[count]]}} = Ecto.Adapters.SQL.query(repo, "SELECT count(*) FROM (#{query_sql}) AS temp", parameters) + + count + end + + defp total_pages(total_entries, page_size) do + ceiling(total_entries / page_size) + end +end diff --git a/mix.exs b/mix.exs new file mode 100644 index 0000000..3ef5b73 --- /dev/null +++ b/mix.exs @@ -0,0 +1,56 @@ +defmodule Scrivener.Ecto.Mixfile do + use Mix.Project + + def project do + [ + app: :scrivener_ecto, + version: "1.0.0-dev", + elixir: "~> 1.0", + elixirc_paths: elixirc_paths(Mix.env), + package: package, + description: "Paginate your Ecto queries with Scrivener", + deps: deps, + docs: [ + main: "README.md", + readme: "README.md" + ] + ] + end + + def application do + [ + applications: applications(Mix.env) + ] + end + + defp applications(:test), do: [:postgrex, :ecto, :logger] + defp applications(_), do: [:logger] + + defp deps do + [ + {:scrivener, git: "https://github.com/drewolson/scrivener", branch: "v2"}, + {:ecto, "~> 2.0.0-beta"}, + {:dialyze, "~> 0.2.0", only: :dev}, + {:earmark, ">= 0.0.0", only: :dev}, + {:ex_doc, "~> 0.11.0", only: :dev}, + {:ex_spec, "~> 1.0", only: :test}, + {:postgrex, ">= 0.0.0", optional: true} + ] + end + + defp elixirc_paths(:test), do: ["lib", "test/support"] + defp elixirc_paths(_), do: ["lib"] + + defp package do + [ + maintainers: ["Drew Olson"], + licenses: ["MIT"], + links: %{"github" => "https://github.com/drewolson/scrivener_ecto"}, + files: [ + "lib/scrivener", + "mix.exs", + "README.md" + ] + ] + end +end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..cb6b8aa --- /dev/null +++ b/mix.lock @@ -0,0 +1,11 @@ +%{"connection": {:hex, :connection, "1.0.2"}, + "db_connection": {:hex, :db_connection, "0.2.5"}, + "decimal": {:hex, :decimal, "1.1.1"}, + "dialyze": {:hex, :dialyze, "0.2.1"}, + "earmark": {:hex, :earmark, "0.2.1"}, + "ecto": {:hex, :ecto, "2.0.0-beta.2"}, + "ex_doc": {:hex, :ex_doc, "0.11.4"}, + "ex_spec": {:hex, :ex_spec, "1.0.0"}, + "poolboy": {:hex, :poolboy, "1.5.1"}, + "postgrex": {:hex, :postgrex, "0.11.1"}, + "scrivener": {:git, "https://github.com/drewolson/scrivener", "18fddf641b77d88ec7462e963198ed086601c2f0", [branch: "v2"]}} diff --git a/priv/repo/migrations/1_create_posts.exs b/priv/repo/migrations/1_create_posts.exs new file mode 100644 index 0000000..9224e85 --- /dev/null +++ b/priv/repo/migrations/1_create_posts.exs @@ -0,0 +1,13 @@ +defmodule TestRepo.Migrations.CreatePosts do + use Ecto.Migration + + def change do + create table(:posts) do + add :title, :string + add :body, :string + add :published, :boolean + + timestamps + end + end +end diff --git a/priv/repo/migrations/2_create_comments.exs b/priv/repo/migrations/2_create_comments.exs new file mode 100644 index 0000000..5c6ac97 --- /dev/null +++ b/priv/repo/migrations/2_create_comments.exs @@ -0,0 +1,12 @@ +defmodule Scrivener.Repo.Migrations.CreateComments do + use Ecto.Migration + + def change do + create table(:comments) do + add :body, :string + add :post_id, :integer + + timestamps + end + end +end diff --git a/priv/repo/migrations/3_create_key_values.exs b/priv/repo/migrations/3_create_key_values.exs new file mode 100644 index 0000000..c71c865 --- /dev/null +++ b/priv/repo/migrations/3_create_key_values.exs @@ -0,0 +1,10 @@ +defmodule TestRepo.Migrations.CreateKeyValues do + use Ecto.Migration + + def change do + create table(:key_values, primary_key: false) do + add :key, :string, primary_key: true + add :value, :string + end + end +end diff --git a/test/scrivener/paginator/ecto/query_test.exs b/test/scrivener/paginator/ecto/query_test.exs new file mode 100644 index 0000000..25370f1 --- /dev/null +++ b/test/scrivener/paginator/ecto/query_test.exs @@ -0,0 +1,170 @@ +defmodule Scrivener.Paginator.Ecto.QueryTest do + use Scrivener.Ecto.TestCase + + alias Scrivener.Ecto.Post + alias Scrivener.Ecto.Comment + alias Scrivener.Ecto.KeyValue + + defp create_posts do + unpublished_post = %Post{ + title: "Title unpublished", + body: "Body unpublished", + published: false + } |> ScrivenerEcto.Repo.insert! + + Enum.map(1..2, fn i -> + %Comment{ + body: "Body #{i}", + post_id: unpublished_post.id + } |> ScrivenerEcto.Repo.insert! + end) + + Enum.map(1..6, fn i -> + %Post{ + title: "Title #{i}", + body: "Body #{i}", + published: true + } |> ScrivenerEcto.Repo.insert! + end) + end + + defp create_key_values do + Enum.map(1..10, fn i -> + %KeyValue{ + key: "key_#{i}", + value: (rem(i, 2) |> to_string) + } |> ScrivenerEcto.Repo.insert! + end) + end + + describe "paginate" do + it "uses defaults from the repo" do + posts = create_posts + + page = Post + |> Post.published + |> ScrivenerEcto.Repo.paginate + + assert page.page_size == 5 + assert page.page_number == 1 + assert page.entries == Enum.take(posts, 5) + assert page.total_entries == 6 + assert page.total_pages == 2 + end + + it "removes invalid clauses before counting total pages" do + posts = create_posts + + page = Post + |> Post.published + |> order_by([p], desc: p.inserted_at) + |> ScrivenerEcto.Repo.paginate + + assert page.page_size == 5 + assert page.page_number == 1 + assert page.entries == Enum.take(posts, 5) + assert page.total_pages == 2 + end + + it "can be provided the current page and page size as a params map" do + posts = create_posts + + page = Post + |> Post.published + |> ScrivenerEcto.Repo.paginate(%{"page" => "2", "page_size" => "3"}) + + assert page.page_size == 3 + assert page.page_number == 2 + assert page.entries == Enum.drop(posts, 3) + assert page.total_pages == 2 + end + + it "can be provided the current page and page size as options" do + posts = create_posts + + page = Post + |> Post.published + |> ScrivenerEcto.Repo.paginate(page: 2, page_size: 3) + + assert page.page_size == 3 + assert page.page_number == 2 + assert page.entries == Enum.drop(posts, 3) + assert page.total_pages == 2 + end + + it "will respect the max_page_size configuration" do + page = Post + |> Post.published + |> ScrivenerEcto.Repo.paginate(%{"page" => "1", "page_size" => "20"}) + + assert page.page_size == 10 + end + + it "can be used on a table with any primary key" do + create_key_values + + page = KeyValue + |> KeyValue.zero + |> ScrivenerEcto.Repo.paginate(page_size: 2) + + assert page.total_entries == 5 + assert page.total_pages == 3 + end + + it "can be used with a group by clause" do + create_posts + + page = Post + |> join(:left, [p], c in assoc(p, :comments)) + |> group_by([p], p.id) + |> ScrivenerEcto.Repo.paginate + + assert page.total_entries == 7 + end + + it "can be provided a Scrivener.Config directly" do + posts = create_posts + + config = %Scrivener.Config{ + module: ScrivenerEcto.Repo, + page_number: 2, + page_size: 4 + } + + page = Post + |> Post.published + |> Scrivener.paginate(config) + + assert page.page_size == 4 + assert page.page_number == 2 + assert page.entries == Enum.drop(posts, 4) + assert page.total_pages == 2 + end + + it "can be provided a keyword directly" do + posts = create_posts + + page = Post + |> Post.published + |> Scrivener.paginate(module: ScrivenerEcto.Repo, page: 2, page_size: 4) + + assert page.page_size == 4 + assert page.page_number == 2 + assert page.entries == Enum.drop(posts, 4) + assert page.total_pages == 2 + end + + it "can be provided a map directly" do + posts = create_posts + + page = Post + |> Post.published + |> Scrivener.paginate(%{"module" => ScrivenerEcto.Repo, "page" => 2, "page_size" => 4}) + + assert page.page_size == 4 + assert page.page_number == 2 + assert page.entries == Enum.drop(posts, 4) + assert page.total_pages == 2 + end + end +end diff --git a/test/support/comment.ex b/test/support/comment.ex new file mode 100644 index 0000000..b516130 --- /dev/null +++ b/test/support/comment.ex @@ -0,0 +1,11 @@ +defmodule Scrivener.Ecto.Comment do + use Ecto.Schema + + schema "comments" do + field :body, :string + + belongs_to :post, Scrivener.Ecto.Post + + timestamps + end +end diff --git a/test/support/keyvalue.ex b/test/support/keyvalue.ex new file mode 100644 index 0000000..686781c --- /dev/null +++ b/test/support/keyvalue.ex @@ -0,0 +1,13 @@ +defmodule Scrivener.Ecto.KeyValue do + use Ecto.Schema + import Ecto.Query + @primary_key {:key, :string, autogenerate: false} + + schema "key_values" do + field :value, :string + end + + def zero(query) do + query |> where([p], p.value == "0") + end +end diff --git a/test/support/post.ex b/test/support/post.ex new file mode 100644 index 0000000..9e85729 --- /dev/null +++ b/test/support/post.ex @@ -0,0 +1,22 @@ +defmodule Scrivener.Ecto.Post do + use Ecto.Schema + import Ecto.Query + + schema "posts" do + field :title, :string + field :body, :string + field :published, :boolean + + has_many :comments, Scrivener.Ecto.Comment + + timestamps + end + + def published(query) do + query |> where([p], p.published == true) + end + + def unpublished(query) do + query |> where([p], p.published == false) + end +end diff --git a/test/support/repo.ex b/test/support/repo.ex new file mode 100644 index 0000000..90702eb --- /dev/null +++ b/test/support/repo.ex @@ -0,0 +1,4 @@ +defmodule ScrivenerEcto.Repo do + use Ecto.Repo, otp_app: :scrivener_ecto + use Scrivener, page_size: 5, max_page_size: 10 +end diff --git a/test/test_helper.exs b/test/test_helper.exs new file mode 100644 index 0000000..606b294 --- /dev/null +++ b/test/test_helper.exs @@ -0,0 +1,19 @@ +defmodule Scrivener.Ecto.TestCase do + use ExUnit.CaseTemplate + + using(opts) do + quote do + use ExSpec, unquote(opts) + import Ecto.Query + end + end + + setup do + Ecto.Adapters.SQL.Sandbox.mode(ScrivenerEcto.Repo, :manual) + + :ok = Ecto.Adapters.SQL.Sandbox.checkout(ScrivenerEcto.Repo) + end +end + +ScrivenerEcto.Repo.start_link +ExUnit.start()