├── config
├── dev.exs
├── docs.exs
├── config.exs
└── test.exs
├── .tool-versions
├── help
├── walkthrough.md
└── nomenclature.md
├── src
└── rummage_logo.png
├── rummage_tester
├── README.md
├── .formatter.exs
├── test
│ ├── test_helper.exs
│ ├── rummage_tester_test.exs
│ └── support
│ │ └── case.ex
├── lib
│ ├── rummage_tester
│ │ ├── repo.ex
│ │ ├── application.ex
│ │ └── schema
│ │ │ ├── category.ex
│ │ │ ├── employee.ex
│ │ │ └── product.ex
│ └── rummage_tester.ex
├── priv
│ └── repo
│ │ ├── migrations
│ │ ├── 20180603143507_create_employees.exs
│ │ ├── 20180603142903_create_categories.exs
│ │ └── 20180603143051_create_products.exs
│ │ └── seeds.exs
├── .gitignore
├── config
│ └── config.exs
├── mix.exs
└── mix.lock
├── .formatter.exs
├── test
├── config_test.exs
├── hooks
│ ├── sort_test.exs
│ ├── search_test.exs
│ └── paginate_test.exs
├── test_helper.exs
├── support
│ ├── repo.ex
│ ├── category.ex
│ ├── employee.ex
│ ├── rummage_product.ex
│ └── product.ex
├── custom_hooks
│ ├── simple_sort_test.exs
│ ├── simple_search_test.exs
│ └── keyset_paginate_test.exs
├── schema_test.exs
├── schema
│ ├── search_test.exs
│ ├── sort_test.exs
│ ├── paginate_test.exs
│ └── macro_test.exs
├── services
│ └── build_search_query_test.exs
└── rummage_ecto_test.exs
├── coveralls.json
├── priv
└── repo
│ └── migrations
│ ├── 20180603143507_create_employees.exs
│ ├── 20180603142903_create_categories.exs
│ └── 20180603143051_create_products.exs
├── .travis.yml
├── .gitignore
├── lib
├── rummage_ecto
│ ├── query_utils.ex
│ ├── hooks
│ │ ├── custom_sort.ex
│ │ ├── custom_paginate.ex
│ │ ├── custom_search.ex
│ │ ├── sort.ex
│ │ ├── paginate.ex
│ │ └── search.ex
│ ├── schema
│ │ ├── paginate.ex
│ │ ├── sort.ex
│ │ ├── macro.ex
│ │ └── search.ex
│ ├── schema.ex
│ ├── hook.ex
│ ├── config.ex
│ └── custom_hooks
│ │ ├── simple_sort.ex
│ │ ├── simple_search.ex
│ │ └── keyset_paginate.ex
└── rummage_ecto.ex
├── LICENSE
├── doc_readme.md
├── mix.exs
├── CHANGELOG.md
├── README.md
└── mix.lock
/config/dev.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
--------------------------------------------------------------------------------
/config/docs.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.13.4-otp-24
2 | erlang 24.3.4
3 |
--------------------------------------------------------------------------------
/help/walkthrough.md:
--------------------------------------------------------------------------------
1 | # Walkthrough
2 |
3 | Work in Progress
4 |
--------------------------------------------------------------------------------
/help/nomenclature.md:
--------------------------------------------------------------------------------
1 | # Nomenclature
2 |
3 | Work in Progress
4 |
--------------------------------------------------------------------------------
/src/rummage_logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/annkissam/rummage_ecto/HEAD/src/rummage_logo.png
--------------------------------------------------------------------------------
/rummage_tester/README.md:
--------------------------------------------------------------------------------
1 | # RummageTester
2 |
3 | This app's purpose is to test Rummage.Ecto
4 |
5 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/config/config.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | env = config_env()
4 | if env in ~w(test dev docs)a, do: import_config "#{env}.exs"
5 |
--------------------------------------------------------------------------------
/test/config_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.ConfigTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Config
4 | end
5 |
--------------------------------------------------------------------------------
/rummage_tester/.formatter.exs:
--------------------------------------------------------------------------------
1 | # Used by "mix format"
2 | [
3 | inputs: ["mix.exs", "{config,lib,test}/**/*.{ex,exs}"]
4 | ]
5 |
--------------------------------------------------------------------------------
/test/hooks/sort_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.SortTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Hook.Sort
4 | end
5 |
--------------------------------------------------------------------------------
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | Rummage.Ecto.Repo.start_link()
3 | Ecto.Adapters.SQL.Sandbox.mode(Rummage.Ecto.Repo, :manual)
4 |
--------------------------------------------------------------------------------
/test/hooks/search_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.SearchTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Hook.Search
4 | end
5 |
--------------------------------------------------------------------------------
/test/support/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Repo do
2 | use Ecto.Repo, otp_app: :rummage_ecto, adapter: Ecto.Adapters.Postgres
3 | end
4 |
--------------------------------------------------------------------------------
/test/hooks/paginate_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.PaginateTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Hook.Paginate
4 | end
5 |
--------------------------------------------------------------------------------
/rummage_tester/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 | RummageTester.Repo.start_link()
3 | Ecto.Adapters.SQL.Sandbox.mode(RummageTester.Repo, :manual)
4 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Repo do
2 | use Ecto.Repo,otp_app: :rummage_tester, adapter: Ecto.Adapters.Postgres
3 | end
4 |
--------------------------------------------------------------------------------
/rummage_tester/test/rummage_tester_test.exs:
--------------------------------------------------------------------------------
1 | defmodule RummageTesterTest do
2 | use ExUnit.Case
3 | use RummageTester.Case
4 | doctest RummageTester
5 | end
6 |
--------------------------------------------------------------------------------
/test/custom_hooks/simple_sort_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.SimpleSortTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.CustomHook.SimpleSort
4 | end
5 |
--------------------------------------------------------------------------------
/test/custom_hooks/simple_search_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.SimpleSearchTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.CustomHook.SimpleSearch
4 | end
5 |
--------------------------------------------------------------------------------
/test/custom_hooks/keyset_paginate_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.KeysetPaginateTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.CustomHook.KeysetPaginate
4 | end
5 |
--------------------------------------------------------------------------------
/coveralls.json:
--------------------------------------------------------------------------------
1 | {
2 | "coverage_options": {
3 | "treat_no_relevant_lines_as_covered": true,
4 | "minimum_coverage": 90
5 | },
6 | "skip_files": [
7 | "test",
8 | ]
9 | }
10 |
--------------------------------------------------------------------------------
/rummage_tester/test/support/case.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Case do
2 | @moduledoc false
3 |
4 | use ExUnit.CaseTemplate
5 |
6 | setup do
7 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(RunnageTester.Repo)
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180603143507_create_employees.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Repo.Migrations.CreateEmployees do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:employees, primary_key: false) do
6 | add(:first_name, :string)
7 | add(:last_name, :string)
8 | add(:date_of_birth, :date)
9 |
10 | timestamps()
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: elixir
2 | elixir:
3 | - 1.6.0
4 | - 1.6.5
5 | - 1.7.3
6 | otp_release:
7 | - 20.3
8 | sudo: false
9 | env:
10 | - MIX_ENV=test POSTGRES_USER=postgres POSTGRES_PASSWORD=
11 | script:
12 | - mix test
13 | after_script:
14 | - mix hex.audit
15 | - mix coveralls.travis
16 | - mix credo
17 | - mix deps.get --only docs
18 | - MIX_ENV=docs mix inch.report
19 |
--------------------------------------------------------------------------------
/rummage_tester/priv/repo/migrations/20180603143507_create_employees.exs:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Repo.Migrations.CreateEmployees do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:employees, primary_key: false) do
6 | add :first_name, :string
7 | add :last_name, :string
8 | add :date_of_birth, :date
9 |
10 | timestamps()
11 | end
12 | end
13 | end
14 |
--------------------------------------------------------------------------------
/test/support/category.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Category do
2 | @moduledoc """
3 | This is a Category Ecto.Schema for testing Rummage.Ecto with a nested
4 | associations
5 | """
6 |
7 | use Rummage.Ecto.Schema
8 |
9 | schema "categories" do
10 | field(:name, :string)
11 | field(:description, :string)
12 |
13 | belongs_to(:parent_category, __MODULE__)
14 |
15 | timestamps()
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester/application.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Application do
2 | @moduledoc false
3 |
4 | use Application
5 | import Supervisor.Spec, warn: false
6 |
7 | def start(_type, _args) do
8 | children = [
9 | supervisor(RummageTester.Repo, [])
10 | ]
11 |
12 | opts = [strategy: :one_for_one, name: RummageTester.Supervisor]
13 | Supervisor.start_link(children, opts)
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/config/test.exs:
--------------------------------------------------------------------------------
1 | import Config
2 |
3 | config :logger, :console,
4 | level: :error
5 |
6 | config :rummage_ecto, Rummage.Ecto,
7 | repo: Rummage.Ecto.Repo,
8 | per_page: 2
9 |
10 | config :rummage_ecto, ecto_repos: [Rummage.Ecto.Repo]
11 |
12 | config :rummage_ecto, Rummage.Ecto.Repo,
13 | username: System.get_env("POSTGRES_USER"),
14 | password: System.get_env("POSTGRES_PASSWORD"),
15 | database: "rummage_ecto_test",
16 | pool: Ecto.Adapters.SQL.Sandbox
17 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180603142903_create_categories.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Repo.Migrations.CreateCategories do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:categories) do
6 | add(:name, :string)
7 | add(:description, :text)
8 | add(:parent_category_id, references(:categories))
9 |
10 | timestamps()
11 | end
12 |
13 | create(unique_index(:categories, [:name]))
14 | create(index(:categories, [:parent_category_id]))
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester/schema/category.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Category do
2 | @moduledoc """
3 | This is an example usage of `Rummage.Ecto.Schema`. This module has `id` as
4 | `primary_key` and has two fields and a `belongs_to` association.
5 | """
6 |
7 | use Rummage.Ecto.Schema
8 |
9 | schema "categories" do
10 | field :name, :string
11 | field :description, :string
12 |
13 | belongs_to :parent_category, __MODULE__
14 |
15 | timestamps()
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/rummage_tester/priv/repo/migrations/20180603142903_create_categories.exs:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Repo.Migrations.CreateCategories do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:categories) do
6 | add :name, :string
7 | add :description, :text
8 | add :parent_category_id, references(:categories)
9 |
10 | timestamps()
11 | end
12 |
13 | create unique_index(:categories, [:name])
14 | create index(:categories, [:parent_category_id])
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/.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 3rd-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 | # Inch CI
20 | /docs
21 |
22 | *.sqlite3
23 | *.sqlite3-*
24 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/query_utils.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.QueryUtils do
2 | @moduledoc false
3 | def schema_from_query(module) when is_atom(module), do: module
4 | def schema_from_query({_, module}) when is_atom(module), do: module
5 | def schema_from_query(%Ecto.Query{from: _from} = query), do: schema_from_query(query.from)
6 | def schema_from_query(%Ecto.SubQuery{query: query}), do: schema_from_query(query)
7 | def schema_from_query(%Ecto.Query.FromExpr{source: source}), do: schema_from_query(source)
8 |
9 | def schema_from_query(other),
10 | do: raise(ArgumentError, message: "argument error #{inspect(other)}")
11 | end
12 |
--------------------------------------------------------------------------------
/rummage_tester/.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 3rd-party dependencies like ExDoc output generated docs.
11 | /doc/
12 |
13 | # Ignore .fetch files in case you like to edit your project deps locally.
14 | /.fetch
15 |
16 | # If the VM crashes, it generates a dump, let's ignore it too.
17 | erl_crash.dump
18 |
19 | # Also ignore archive artifacts (built via "mix archive.build").
20 | *.ez
21 |
22 | # Ignore package tarball (built via "mix hex.build").
23 | rummage_tester-*.tar
24 |
25 |
--------------------------------------------------------------------------------
/priv/repo/migrations/20180603143051_create_products.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Repo.Migrations.CreateProducts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:products, primary_key: false) do
6 | add(:internal_code, :string, primary_key: true)
7 | add(:name, :string)
8 | add(:price, :float)
9 | add(:availability, :boolean)
10 | add(:description, :text)
11 | add(:category_id, references(:categories))
12 |
13 | timestamps()
14 | end
15 |
16 | create(unique_index(:products, [:name]))
17 | create(index(:products, [:category_id]))
18 | create(index(:products, [:price]))
19 | create(index(:products, [:availability]))
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/rummage_tester/priv/repo/migrations/20180603143051_create_products.exs:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Repo.Migrations.CreateProducts do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:products, primary_key: false) do
6 | add :internal_code, :string, primary_key: true
7 | add :name, :string
8 | add :price, :float
9 | add :availability, :boolean
10 | add :description, :text
11 | add :category_id, references(:categories)
12 |
13 | timestamps()
14 | end
15 |
16 | create unique_index(:products, [:name])
17 | create index(:products, [:category_id])
18 | create index(:products, [:price])
19 | create index(:products, [:availability])
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/rummage_tester/config/config.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | config :rummage_tester,
4 | ecto_repos: [RummageTester.Repo]
5 |
6 | config :rummage_tester, Rummage.Ecto,
7 | repo: RummageTester.Repo,
8 | per_page: 10
9 |
10 | case Mix.env() do
11 | :test ->
12 | config :rummage_tester, RummageTester.Repo,
13 | database: "rummage_tester_repo_test",
14 | username: System.get_env("POSTGRES_USER"),
15 | password: System.get_env("POSTGRES_PASSWORD"),
16 | pool: Ecto.Adapters.SQL.Sandbox
17 | :dev ->
18 | config :rummage_tester, RummageTester.Repo,
19 | database: "rummage_tester_repo_dev",
20 | username: System.get_env("POSTGRES_USER"),
21 | password: System.get_env("POSTGRES_PASSWORD")
22 | end
23 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester/schema/employee.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Employee do
2 | @moduledoc """
3 | This is an example usage of `Rummage.Ecto.Schema`. This module has no
4 | `primary_key` and three fields.
5 |
6 | This also has examples of using fragments to define a custom `rummage_field`.
7 | """
8 |
9 | use Rummage.Ecto.Schema
10 |
11 | @primary_key false
12 |
13 | schema "employees" do
14 | field :first_name, :string
15 | field :last_name, :string
16 | field :date_of_birth, :date
17 |
18 | timestamps()
19 | end
20 |
21 | rummage_field :year_of_birth do
22 | {:fragment, "date_part('year', ?)", :date_of_birth}
23 | end
24 |
25 | rummage_field :name do
26 | {:fragment, "concat(?, ?)", :first_name, :last_name}
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/test/support/employee.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Employee do
2 | @moduledoc """
3 | This is Product Ecto.Schema for testing Rummage.Ecto with float values
4 | and boolean values
5 | """
6 |
7 | use Rummage.Ecto.Schema
8 |
9 | @primary_key false
10 |
11 | schema "employees" do
12 | field(:first_name, :string)
13 | field(:last_name, :string)
14 | field(:date_of_birth, :date)
15 |
16 | timestamps()
17 | end
18 |
19 | rummage_field :year_of_birth do
20 | {:fragment, "date_part('year', ?)", :date_of_birth}
21 | end
22 |
23 | rummage_field :month_of_birth do
24 | {:fragment, "date_part('month', ?)", :date_of_birth}
25 | end
26 |
27 | rummage_field :first_name_or_last_name do
28 | {:fragment, "coalesce(?, ?)", :first_name, :last_name}
29 | end
30 |
31 | rummage_field :name do
32 | {:fragment, "concat(?, ?)", :first_name, :last_name}
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/rummage_tester/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | alias RummageTester.{Category, Employee, Product, Repo}
2 |
3 | [category_1, category_2] = for i <- 1..2 do
4 | %Category{
5 | name: "Name #{i}",
6 | description: "This Category includes #{i} related Products"
7 | }
8 | end |> Enum.map(&Repo.insert!/1)
9 |
10 | [category_3, category_4] = for i <- 3..4 do
11 | %Category{
12 | name: "Name #{i}",
13 | description: "This Category includes #{i} related Products",
14 | parent_category_id: i - 2
15 | }
16 | end |> Enum.map(&Repo.insert!/1)
17 |
18 | for i <- 1..4 do
19 | %Product{
20 | name: "Product #{i}000",
21 | internal_code: "#{i}000",
22 | price: i * 10.0,
23 | availability: i < 3,
24 | description: "#{i} Product is awesome!"
25 | }
26 | end |> Enum.map(&Repo.insert!/1)
27 |
28 | for i <- 1..4 do
29 | %Employee{
30 | first_name: "Employee #{i}",
31 | last_name: "MYEmployee #{i}",
32 | date_of_birth: elem(Date.new(2000 + 2 * i, 1 + i, 20 + i), 1),
33 | }
34 | end |> Enum.map(&Repo.insert!/1)
35 |
--------------------------------------------------------------------------------
/rummage_tester/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.MixProject do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :rummage_tester,
7 | version: "0.1.0",
8 | elixir: "~> 1.6",
9 | start_permanent: Mix.env() == :prod,
10 | elixirc_paths: elixirc_paths(Mix.env),
11 | aliases: aliases(),
12 | deps: deps()
13 | ]
14 | end
15 |
16 | def application do
17 | [
18 | extra_applications: [:logger, :rummage_ecto, :postgrex],
19 | mod: {RummageTester.Application, []}
20 | ]
21 | end
22 |
23 | defp deps do
24 | [
25 | {:rummage_ecto, path: "../"},
26 | {:postgrex, "~> 0.13"},
27 | ]
28 | end
29 |
30 | defp aliases do
31 | [
32 | "ecto.setup": ["ecto.create", "ecto.migrate"],
33 | "ecto.seed": ["run priv/repo/seeds.exs"],
34 | "ecto.reset": ["ecto.drop", "ecto.setup"],
35 | "test": ["ecto.setup --quite", "test"],
36 | ]
37 | end
38 |
39 | defp elixirc_paths(:test), do: ["lib", "priv", "test/support"]
40 | defp elixirc_paths(_), do: ["lib"]
41 | end
42 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester/schema/product.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester.Product do
2 | @moduledoc false
3 |
4 | use Rummage.Ecto.Schema
5 |
6 | @primary_key {:internal_code, :string, autogenerate: false}
7 |
8 | schema "products" do
9 | field :name, :string
10 | field :price, :float
11 | field :availability, :boolean
12 | field :description, :string
13 |
14 | belongs_to :category, RummageTester.Category
15 |
16 | timestamps()
17 | end
18 |
19 | rummage_field :created_at_month do
20 | {:fragment, "date_part('month', ?)", :inserted_at}
21 | end
22 |
23 | rummage_scope :category_name, [type: :search], fn(term) ->
24 | {:name, %{assoc: [inner: :category], search_term: term, search_type: :ilike}}
25 | end
26 |
27 | rummage_scope :category_name, [type: :sort], fn ->
28 | %{field: :name, assoc: [inner: :category], order: :asc, ci: :true}
29 | end
30 |
31 | rummage_scope :product_index, [type: :paginate], fn ->
32 | %{per_page: 10, page: 1}
33 | end
34 |
35 | rummage_scope :category_show, [type: :paginate], fn ->
36 | %{per_page: 5, page: 1}
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Annkissam
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/test/schema_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.SchemaTest do
2 | use ExUnit.Case
3 |
4 | defmodule TestSchema do
5 | @moduledoc false
6 |
7 | use Rummage.Ecto.Schema
8 |
9 | schema "test_schema" do
10 | field(:name, :string)
11 | field(:age, :integer)
12 |
13 | timestamps()
14 | end
15 |
16 | rummage_field :inserted_at_year do
17 | {:fragment, "date_part('year', ?)", :inserted_at}
18 | end
19 |
20 | rummage_field :upper_case_name do
21 | {:fragment, "upper(?)", :name}
22 | end
23 |
24 | rummage_scope(:small_page, [type: :custom_paginate], fn {query, page} ->
25 | offset = 5 * (page - 1)
26 |
27 | query
28 | |> limit(5)
29 | |> offset(^offset)
30 | end)
31 | end
32 |
33 | test "TestSchema has inserted_at_year function defined" do
34 | assert Code.ensure_loaded?(TestSchema) == true
35 | assert function_exported?(TestSchema, :__rummage_field_inserted_at_year, 0) == true
36 | assert function_exported?(TestSchema, :__rummage_field_upper_case_name, 0) == true
37 | assert function_exported?(TestSchema, :__rummage_custom_paginate_small_page, 1) == true
38 | end
39 | end
40 |
--------------------------------------------------------------------------------
/doc_readme.md:
--------------------------------------------------------------------------------
1 | # Rummage.Ecto
2 |
3 | **If you're looking for full `Phoenix` support, `Rummage.Phoenix` uses `Rummage.Ecto` and adds `HTML` and `Controller` support
4 | to it. You can check `Rummage.Phoenix` out by clicking [here](https://github.com/aditya7iyengar/rummage_phoenix)**
5 |
6 | **Please refer for `CHANGELOG` for version specific changes**
7 |
8 | `Rummage.Ecto` is a light weight, but powerful framework that can be used to alter `Ecto` queries with Search, Sort and Paginate operations.
9 |
10 | It accomplishes the above operations by using `Hooks`, which are modules that implement `Rummage.Ecto.Hook` behavior.
11 | Each operation: `Search`, `Sort` and `Paginate` have their hooks defined in `Rummage`. By doing this, `Rummage` is completely
12 | configurable.
13 |
14 | For example, if you don't like one of the implementations of `Rummage`, but like the other two, you can configure `Rummage` to not use it.
15 |
16 | **NOTE: `Rummage` is not like `Ransack`, and doesn't intend to be. It doesn't define functions based on search params.
17 | If you'd like to have that for a model, you can always configure `Rummage` to use your `Search` module for that model. This
18 | is why Rummage has been made configurable.**
19 |
--------------------------------------------------------------------------------
/rummage_tester/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
3 | "db_connection": {:hex, :db_connection, "1.1.3", "89b30ca1ef0a3b469b1c779579590688561d586694a3ce8792985d4d7e575a61", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
4 | "decimal": {:hex, :decimal, "1.5.0", "b0433a36d0e2430e3d50291b1c65f53c37d56f83665b43d79963684865beab68", [:mix], [], "hexpm"},
5 | "ecto": {:hex, :ecto, "2.2.10", "e7366dc82f48f8dd78fcbf3ab50985ceeb11cb3dc93435147c6e13f2cda0992e", [:mix], [{:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: true]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.8.0", [hex: :mariaex, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:poolboy, "~> 1.5", [hex: :poolboy, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: true]}], "hexpm"},
6 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
7 | "postgrex": {:hex, :postgrex, "0.13.5", "3d931aba29363e1443da167a4b12f06dcd171103c424de15e5f3fc2ba3e6d9c5", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm"},
8 | }
9 |
--------------------------------------------------------------------------------
/rummage_tester/lib/rummage_tester.ex:
--------------------------------------------------------------------------------
1 | defmodule RummageTester do
2 | @moduledoc false
3 |
4 | alias __MODULE__.{Category, Employee, Product}
5 |
6 | @paginate %{per_page: 5, page: 1}
7 |
8 | def list_products(opts) do
9 | opts
10 | |> list_products_query()
11 | |> (fn {q, _} -> q end).()
12 | |> RummageTester.Repo.all()
13 | end
14 |
15 | def list_employees(opts) do
16 | opts
17 | |> list_employees_query()
18 | |> (fn {q, _} -> q end).()
19 | |> RummageTester.Repo.all()
20 | end
21 |
22 | def list_employees_query(name: name) do
23 | Employee.rummage(%{
24 | search: %{name: %{assoc: [], search_term: name, search_type: :ilike}},
25 | sort: %{assoc: [], field: :last_name, order: :asc},
26 | paginate: @paginate})
27 | end
28 |
29 | def list_employees_query(year_of_birth: year_of_birth) do
30 | Employee.rummage(%{
31 | search: %{year_of_birth: %{assoc: [], search_term: year_of_birth, search_type: :eq}},
32 | sort: %{assoc: [], field: :last_name, order: :asc},
33 | paginate: @paginate})
34 | end
35 |
36 | def list_products_query(name: name) do
37 | Product.rummage(%{
38 | search: %{name: %{assoc: [], search_term: name, search_type: :ilike}},
39 | sort: %{assoc: [], field: :price, order: :asc},
40 | paginate: @paginate})
41 | end
42 |
43 | def list_products_query(created_at_month: created_at_month) do
44 | Product.rummage(%{
45 | search: %{created_at_month: %{assoc: [], search_term: created_at_month, search_type: :eq}},
46 | sort: %{assoc: [], field: :price, order: :asc},
47 | paginate: @paginate})
48 | end
49 |
50 | def list_products_query(category_name: category_name) do
51 | Product.rummage(%{
52 | search: %{category_name: category_name},
53 | sort: %{assoc: [], field: :price, order: :asc},
54 | paginate: @paginate})
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/custom_sort.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.CustomSort do
2 | @moduledoc false
3 |
4 | use Rummage.Ecto.Hook
5 |
6 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
7 | def run(q, s), do: handle_sort(q, s)
8 |
9 | # Helper function which handles addition of paginated query on top of
10 | # the sent queryable variable
11 | defp handle_sort(queryable, {field, order}) do
12 | module = get_module(queryable)
13 | name = :"__rummage_custom_sort_#{field}"
14 |
15 | case function_exported?(module, name, 1) do
16 | true -> apply(module, name, [{queryable, order}])
17 | _ -> "No scope `#{field}` of type custom_sort defined in #{module}"
18 | end
19 | end
20 |
21 | defp handle_sort(queryable, sort_params) do
22 | Rummage.Ecto.Hook.Sort.run(queryable, sort_params)
23 | end
24 |
25 | @doc """
26 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
27 |
28 | This function ensures that params for each field have keys `assoc`, `order1`
29 | which are essential for running this hook module.
30 |
31 | ## Examples
32 | iex> alias Rummage.Ecto.Hook.CustomSort
33 | iex> Sort.format_params(Parent, %{}, [])
34 | %{assoc: [], order: :asc}
35 | """
36 | @spec format_params(Ecto.Query.t(), map() | tuple(), keyword()) :: map()
37 | def format_params(queryable, {sort_scope, order}, opts) do
38 | module = get_module(queryable)
39 | name = :"__rummage_sort_#{sort_scope}"
40 |
41 | case function_exported?(module, name, 1) do
42 | true ->
43 | sort_params = apply(module, name, [order])
44 | format_params(queryable, sort_params, opts)
45 |
46 | _ ->
47 | case function_exported?(module, :"__rummage_custom_sort_#{sort_scope}", 1) do
48 | true -> {sort_scope, order}
49 | _ -> raise "No scope `#{sort_scope}` of type custom_sort defined in the #{module}"
50 | end
51 | end
52 | end
53 |
54 | def format_params(_queryable, sort_params, _opts) do
55 | sort_params
56 | |> Map.put_new(:assoc, [])
57 | |> Map.put_new(:order, :asc)
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/custom_paginate.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.CustomPaginate do
2 | @moduledoc false
3 |
4 | use Rummage.Ecto.Hook
5 |
6 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
7 | def run(q, s), do: handle_paginate(q, s)
8 |
9 | # Helper function which handles addition of paginated query on top of
10 | # the sent queryable variable
11 | defp handle_paginate(queryable, {field, page}) do
12 | module = get_module(queryable)
13 | name = :"__rummage_custom_paginate_#{field}"
14 |
15 | case function_exported?(module, name, 1) do
16 | true -> apply(module, name, [{queryable, page}])
17 | _ -> "No scope `#{field}` of type custom_paginate defined in #{module}"
18 | end
19 | end
20 |
21 | defp handle_paginate(queryable, paginate_params) do
22 | Rummage.Ecto.Hook.Paginate.run(queryable, paginate_params)
23 | end
24 |
25 | @doc """
26 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
27 |
28 | This function ensures that params for each field have keys `assoc`, `order1`
29 | which are essential for running this hook module.
30 |
31 | ## Examples
32 | iex> alias Rummage.Ecto.Hook.CustomSort
33 | iex> Sort.format_params(Parent, %{}, [])
34 | %{assoc: [], order: :asc}
35 | """
36 | @spec format_params(Ecto.Query.t(), map() | tuple(), keyword()) :: map()
37 | def format_params(queryable, {paginate_scope, page}, opts) do
38 | module = get_module(queryable)
39 | name = :"__rummage_paginate_#{paginate_scope}"
40 |
41 | case function_exported?(module, name, 1) do
42 | true ->
43 | paginate_params = apply(module, name, [page])
44 | format_params(queryable, paginate_params, opts)
45 |
46 | _ ->
47 | case function_exported?(module, :"__rummage_custom_paginate_#{paginate_scope}", 1) do
48 | true ->
49 | {paginate_scope, page}
50 |
51 | _ ->
52 | raise "No scope `#{paginate_scope}` of type custom_paginate defined in the #{module}"
53 | end
54 | end
55 | end
56 |
57 | def format_params(queryable, paginate_params, opts) do
58 | Rummage.Ecto.Hook.Paginate.format_params(queryable, paginate_params, opts)
59 | end
60 | end
61 |
--------------------------------------------------------------------------------
/test/schema/search_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.SearchTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Schema.Search
4 |
5 | alias Rummage.Ecto.Repo
6 | alias Rummage.Ecto.Product
7 | alias Rummage.Ecto.Category
8 |
9 | setup do
10 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
11 | end
12 |
13 | defp create_categories_and_products() do
14 | for x <- 1..4 do
15 | parent_category =
16 | %Category{name: "Parent Category #{10 - x}"}
17 | |> Repo.insert!()
18 |
19 | category =
20 | %Category{name: "Category #{x}", parent_category: parent_category}
21 | |> Repo.insert!()
22 |
23 | for y <- 1..2 do
24 | %Product{
25 | internal_code: "#{x}-#{y}",
26 | name: "Product #{x}-#{y}",
27 | price: 10.0 * x,
28 | category: category
29 | }
30 | |> Repo.insert!()
31 | end
32 | end
33 | end
34 |
35 | test "changeset (default)" do
36 | params = %{}
37 |
38 | changeset =
39 | Rummage.Ecto.Rummage.Product.Search.changeset(
40 | %Rummage.Ecto.Rummage.Product.Search{},
41 | params
42 | )
43 |
44 | assert changeset.changes == %{}
45 | assert changeset.data == %Rummage.Ecto.Rummage.Product.Search{}
46 | assert changeset.params == %{}
47 | end
48 |
49 | test "changeset" do
50 | params = %{"name" => "3-", "price_gteq" => "10.1"}
51 |
52 | changeset =
53 | Rummage.Ecto.Rummage.Product.Search.changeset(
54 | %Rummage.Ecto.Rummage.Product.Search{},
55 | params
56 | )
57 |
58 | assert changeset.changes == %{name: "3-", price_gteq: 10.1}
59 | assert changeset.data == %Rummage.Ecto.Rummage.Product.Search{}
60 | assert changeset.params == %{"name" => "3-", "price_gteq" => "10.1"}
61 | end
62 |
63 | test "rummage" do
64 | create_categories_and_products()
65 |
66 | params = %Rummage.Ecto.Rummage.Product.Search{name: "3-"}
67 |
68 | products =
69 | Rummage.Ecto.Product
70 | |> Rummage.Ecto.Rummage.Product.Search.rummage(params)
71 | |> Repo.all()
72 |
73 | assert length(products) == 2
74 | assert Enum.map(products, & &1.name) == ["Product 3-1", "Product 3-2"]
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/schema/paginate.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.Paginate do
2 | @moduledoc """
3 |
4 | Usage:
5 |
6 | ```elixir
7 | defmodule MyApp.Rummage.Paginate do
8 | use Rummage.Schema.Paginate
9 | end
10 | ```
11 | """
12 |
13 | defmacro __using__(opts) do
14 | per_page = Keyword.get(opts, :per_page, Rummage.Ecto.Config.per_page())
15 | repo = Keyword.get(opts, :repo, Rummage.Ecto.Config.repo())
16 |
17 | quote location: :keep do
18 | use Ecto.Schema
19 | import Ecto.Changeset
20 |
21 | @primary_key false
22 | embedded_schema do
23 | field(:page, :integer, default: 1)
24 | field(:per_page, :integer)
25 | field(:max_page, :integer)
26 | field(:total_count, :integer)
27 | end
28 |
29 | def changeset(paginate, attrs \\ %{}) do
30 | paginate
31 | |> cast(attrs, [:page, :per_page])
32 | |> set_default_per_page()
33 | end
34 |
35 | defp set_default_per_page(changeset) do
36 | per_page = get_field(changeset, :per_page)
37 |
38 | if per_page && per_page != "" do
39 | changeset
40 | else
41 | put_change(changeset, :per_page, unquote(per_page))
42 | end
43 | end
44 |
45 | defp rummage_changeset(paginate, attrs) do
46 | paginate
47 | |> cast(attrs, [:max_page, :total_count])
48 | end
49 |
50 | def rummage(query, nil), do: {query, nil}
51 |
52 | def rummage(query, paginate) do
53 | # Add total_count & max_page
54 | params = Rummage.Ecto.Hook.Paginate.format_params(query, paginate, repo: unquote(repo))
55 |
56 | # per_page -1 == Show all results
57 | params =
58 | if paginate.per_page == -1 do
59 | Map.put(params, :max_page, 1)
60 | else
61 | params
62 | end
63 |
64 | # skip pagination if there's only one page
65 | query =
66 | if params.max_page == 1 do
67 | query
68 | else
69 | Rummage.Ecto.Hook.Paginate.run(query, paginate)
70 | end
71 |
72 | paginate = rummage_changeset(paginate, params) |> apply_changes()
73 |
74 | {query, paginate}
75 | end
76 | end
77 | end
78 | end
79 |
--------------------------------------------------------------------------------
/test/support/rummage_product.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Rummage.Product do
2 | @moduledoc false
3 | use Rummage.Ecto.Schema.Macro,
4 | paginate: Rummage.Ecto.Rummage.Paginate,
5 | sort: Rummage.Ecto.Rummage.Product.Sort,
6 | search: Rummage.Ecto.Rummage.Product.Search,
7 | schema: Rummage.Ecto.Product
8 | end
9 |
10 | defmodule Rummage.Ecto.Rummage.Paginate do
11 | @moduledoc false
12 | use Rummage.Ecto.Schema.Paginate
13 | end
14 |
15 | defmodule Rummage.Ecto.Rummage.Product.Sort do
16 | @moduledoc false
17 | use Rummage.Ecto.Schema.Sort,
18 | default_name: "inserted_at",
19 | handlers: [
20 | category_name: %{field: :name, assoc: [inner: :category], ci: true},
21 | name: %{ci: true},
22 | price: %{}
23 | ]
24 |
25 | def sort(query, "inserted_at", order) do
26 | order = String.to_atom(order)
27 |
28 | from(p in query,
29 | order_by: [
30 | {^order, p.inserted_at},
31 | {^order, p.id}
32 | ]
33 | )
34 | end
35 |
36 | # Because we're overriding sort we need to call super...
37 | def sort(query, name, order) do
38 | super(query, name, order)
39 | end
40 | end
41 |
42 | defmodule Rummage.Ecto.Rummage.Product.Search do
43 | @moduledoc false
44 | use Rummage.Ecto.Schema.Search,
45 | handlers: [
46 | category_name: %{search_field: :name, search_type: :like, assoc: [inner: :category]},
47 | price_gteq: %{search_field: :price, search_type: :gteq, type: :float},
48 | price_lteq: %{search_field: :price, search_type: :lteq, type: :float},
49 | name: %{search_type: :like},
50 | month: :integer,
51 | year: :integer
52 | ]
53 |
54 | # Skip blank searches
55 | def search(query, _name, nil), do: query
56 | def search(query, _name, ""), do: query
57 |
58 | def search(query, :month, month) do
59 | from(p in query,
60 | where: fragment("date_part('month', ?)", p.inserted_at) == ^month
61 | )
62 | end
63 |
64 | def search(query, :year, year) do
65 | from(p in query,
66 | where: fragment("date_part('year', ?)", p.inserted_at) == ^year
67 | )
68 | end
69 |
70 | # Because we're overriding search we need to call super...
71 | def search(query, name, value) do
72 | super(query, name, value)
73 | end
74 | end
75 |
--------------------------------------------------------------------------------
/test/schema/sort_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.SortTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Schema.Sort
4 |
5 | alias Rummage.Ecto.Repo
6 | alias Rummage.Ecto.Product
7 | alias Rummage.Ecto.Category
8 |
9 | setup do
10 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
11 | end
12 |
13 | defp create_categories_and_products() do
14 | for x <- 1..4 do
15 | parent_category =
16 | %Category{name: "Parent Category #{10 - x}"}
17 | |> Repo.insert!()
18 |
19 | category =
20 | %Category{name: "Category #{x}", parent_category: parent_category}
21 | |> Repo.insert!()
22 |
23 | for y <- 1..2 do
24 | %Product{
25 | internal_code: "#{x}-#{y}",
26 | name: "Product #{x}-#{y}",
27 | price: 10.0 * x,
28 | category: category
29 | }
30 | |> Repo.insert!()
31 | end
32 | end
33 | end
34 |
35 | test "changeset (default)" do
36 | params = %{}
37 |
38 | changeset =
39 | Rummage.Ecto.Rummage.Product.Sort.changeset(%Rummage.Ecto.Rummage.Product.Sort{}, params)
40 |
41 | assert changeset.changes == %{name: "inserted_at", order: "asc"}
42 | assert changeset.data == %Rummage.Ecto.Rummage.Product.Sort{}
43 | assert changeset.params == %{}
44 | end
45 |
46 | test "changeset" do
47 | params = %{"name" => "name", "order" => "desc"}
48 |
49 | changeset =
50 | Rummage.Ecto.Rummage.Product.Sort.changeset(%Rummage.Ecto.Rummage.Product.Sort{}, params)
51 |
52 | assert changeset.changes == %{name: "name", order: "desc"}
53 | assert changeset.data == %Rummage.Ecto.Rummage.Product.Sort{}
54 | assert changeset.params == %{"name" => "name", "order" => "desc"}
55 | end
56 |
57 | test "rummage" do
58 | create_categories_and_products()
59 |
60 | params = %Rummage.Ecto.Rummage.Product.Sort{name: "name", order: "desc"}
61 |
62 | products =
63 | Rummage.Ecto.Product
64 | |> Rummage.Ecto.Rummage.Product.Sort.rummage(params)
65 | |> Repo.all()
66 |
67 | assert length(products) == 8
68 |
69 | assert Enum.map(products, & &1.name) == [
70 | "Product 4-2",
71 | "Product 4-1",
72 | "Product 3-2",
73 | "Product 3-1",
74 | "Product 2-2",
75 | "Product 2-1",
76 | "Product 1-2",
77 | "Product 1-1"
78 | ]
79 | end
80 | end
81 |
--------------------------------------------------------------------------------
/test/schema/paginate_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.PaginateTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto.Schema.Paginate
4 |
5 | alias Rummage.Ecto.Repo
6 | alias Rummage.Ecto.Product
7 | alias Rummage.Ecto.Category
8 |
9 | setup do
10 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
11 | end
12 |
13 | defp create_categories_and_products() do
14 | for x <- 1..4 do
15 | parent_category =
16 | %Category{name: "Parent Category #{10 - x}"}
17 | |> Repo.insert!()
18 |
19 | category =
20 | %Category{name: "Category #{x}", parent_category: parent_category}
21 | |> Repo.insert!()
22 |
23 | for y <- 1..2 do
24 | %Product{
25 | internal_code: "#{x}-#{y}",
26 | name: "Product #{x}-#{y}",
27 | price: 10.0 * x,
28 | category: category
29 | }
30 | |> Repo.insert!()
31 | end
32 | end
33 | end
34 |
35 | test "changeset (default)" do
36 | params = %{}
37 |
38 | changeset = Rummage.Ecto.Rummage.Paginate.changeset(%Rummage.Ecto.Rummage.Paginate{}, params)
39 |
40 | assert changeset.changes == %{per_page: 2}
41 | assert changeset.data == %Rummage.Ecto.Rummage.Paginate{}
42 | assert changeset.params == %{}
43 | end
44 |
45 | test "changeset" do
46 | params = %{"page" => 2, "per_page" => 4}
47 |
48 | changeset = Rummage.Ecto.Rummage.Paginate.changeset(%Rummage.Ecto.Rummage.Paginate{}, params)
49 |
50 | assert changeset.changes == %{per_page: 4, page: 2}
51 | assert changeset.data == %Rummage.Ecto.Rummage.Paginate{}
52 | assert changeset.params == %{"page" => 2, "per_page" => 4}
53 | end
54 |
55 | test "rummage" do
56 | create_categories_and_products()
57 |
58 | params = %Rummage.Ecto.Rummage.Paginate{page: 2, per_page: 4}
59 |
60 | {query, paginate} =
61 | Rummage.Ecto.Product
62 | |> Rummage.Ecto.Rummage.Paginate.rummage(params)
63 |
64 | products = Repo.all(query)
65 |
66 | assert length(products) == 4
67 |
68 | assert Enum.map(products, & &1.name) == [
69 | "Product 3-1",
70 | "Product 3-2",
71 | "Product 4-1",
72 | "Product 4-2"
73 | ]
74 |
75 | assert paginate == %Rummage.Ecto.Rummage.Paginate{
76 | max_page: 2,
77 | page: 2,
78 | per_page: 4,
79 | total_count: 8
80 | }
81 | end
82 | end
83 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/custom_search.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.CustomSearch do
2 | @moduledoc false
3 |
4 | use Rummage.Ecto.Hook
5 |
6 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
7 | def run(q, s), do: handle_search(q, s)
8 |
9 | defp handle_search(queryable, search_params) do
10 | search_params
11 | |> Map.to_list()
12 | |> Enum.reduce(queryable, &search_queryable(&2, &1))
13 | end
14 |
15 | defp search_queryable(queryable, {search_name, search_term}) do
16 | module = get_module(queryable)
17 | name = :"__rummage_custom_search_#{search_name}"
18 |
19 | case function_exported?(module, name, 1) do
20 | true -> apply(module, name, [{queryable, search_term}])
21 | _ -> Rummage.Ecto.Hook.Search.run(queryable, %{search_name => search_term})
22 | end
23 | end
24 |
25 | @doc """
26 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
27 |
28 | This function ensures that params for each field have keys `assoc`, `search_type` and
29 | `search_expr` which are essential for running this hook module.
30 |
31 | ## Examples
32 | iex> alias Rummage.Ecto.Hook.CustomSearch
33 | iex> Search.format_params(Parent, %{field: %{}}, [])
34 | %{field: %{assoc: [], search_expr: :where, search_type: :eq}}
35 | """
36 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
37 | def format_params(queryable, search_params, _opts) do
38 | search_params
39 | |> Map.to_list()
40 | |> Enum.map(&put_keys(&1, queryable))
41 | |> Enum.into(%{})
42 | end
43 |
44 | defp put_keys({field, %{} = field_params}, _queryable) do
45 | field_params =
46 | field_params
47 | |> Map.put_new(:assoc, [])
48 | |> Map.put_new(:search_type, :eq)
49 | |> Map.put_new(:search_expr, :where)
50 |
51 | {field, field_params}
52 | end
53 |
54 | defp put_keys({search_scope, field_value}, queryable) do
55 | module = get_module(queryable)
56 | name = :"__rummage_search_#{search_scope}"
57 |
58 | case function_exported?(module, name, 1) do
59 | true ->
60 | {field, search_params} = apply(module, name, [field_value])
61 | put_keys({field, search_params}, queryable)
62 |
63 | _ ->
64 | case function_exported?(module, :"__rummage_custom_search_#{search_scope}", 1) do
65 | true -> {search_scope, field_value}
66 | _ -> raise "No scope `#{search_scope}` of type custom_search defined in the #{module}"
67 | end
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/schema/sort.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.Sort do
2 | @moduledoc """
3 |
4 | Usage:
5 |
6 | ```elixir
7 | defmodule MyApp.Rummage.MyModel.Sort do
8 | use Rummage.Schema.Sort,
9 | default_name: "inserted_at",
10 | handlers: [
11 | category_name: %{field: :name, assoc: [inner: :category], ci: true},
12 | name: %{ci: true},
13 | price: %{},
14 | ]
15 |
16 | # Custom handlers...
17 | def sort(query, "inserted_at", order) do
18 | order = String.to_atom(order)
19 |
20 | from p in query,
21 | order_by: [
22 | {^order, p.inserted_at},
23 | {^order, p.id}
24 | ]
25 | end
26 |
27 | # Because we're overriding sort we need to call super...
28 | def sort(query, name, order) do
29 | super(query, name, order)
30 | end
31 | end
32 | ```
33 | """
34 |
35 | defmacro __using__(opts) do
36 | handlers = Keyword.get(opts, :handlers, [])
37 | default_name = Keyword.get(opts, :default_name, nil)
38 | default_order = Keyword.get(opts, :default_order, "asc")
39 |
40 | quote location: :keep do
41 | use Ecto.Schema
42 | import Ecto.Changeset
43 | import Ecto.Query, warn: false
44 |
45 | @primary_key false
46 | embedded_schema do
47 | field(:name, :string)
48 | field(:order, :string)
49 | end
50 |
51 | def changeset(sort, attrs \\ %{}) do
52 | sort
53 | |> cast(attrs, [:name, :order])
54 | |> default_sort()
55 | end
56 |
57 | defp default_sort(changeset) do
58 | name = get_field(changeset, :name)
59 |
60 | if name && name != "" do
61 | changeset
62 | else
63 | changeset
64 | |> put_change(:name, unquote(default_name))
65 | |> put_change(:order, unquote(default_order))
66 | end
67 | end
68 |
69 | def rummage(query, nil), do: query
70 |
71 | def rummage(query, sort) do
72 | if sort.name do
73 | sort(query, sort.name, sort.order)
74 | else
75 | query
76 | end
77 | end
78 |
79 | def sort(query, name, order) do
80 | handler = Keyword.get(unquote(handlers), String.to_atom(name))
81 |
82 | if handler do
83 | params =
84 | handler
85 | |> Map.put_new(:field, String.to_atom(name))
86 | |> Map.put_new(:assoc, [])
87 | |> Map.put(:order, String.to_atom(order))
88 |
89 | Rummage.Ecto.Hook.Sort.run(query, params)
90 | else
91 | raise "Unknown Sort: #{name}"
92 | end
93 | end
94 |
95 | defoverridable sort: 3
96 | end
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/test/support/product.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Product do
2 | @moduledoc """
3 | This is Product Ecto.Schema for testing Rummage.Ecto with float values
4 | and boolean values
5 | """
6 | use Rummage.Ecto.Schema,
7 | per_page: 1,
8 | search: Rummage.Ecto.Hook.CustomSearch,
9 | sort: Rummage.Ecto.Hook.CustomSort,
10 | paginate: Rummage.Ecto.Hook.CustomPaginate
11 |
12 | @primary_key {:internal_code, :string, autogenerate: false}
13 |
14 | schema "products" do
15 | field(:name, :string)
16 | field(:price, :float)
17 | field(:availability, :boolean)
18 | field(:description, :string)
19 |
20 | belongs_to(:category, Rummage.Ecto.Category)
21 | timestamps()
22 | end
23 |
24 | rummage_field :created_at_year do
25 | {:fragment, "date_part('year', ?)", :inserted_at}
26 | end
27 |
28 | rummage_field :created_at_month do
29 | {:fragment, "date_part('month', ?)", :inserted_at}
30 | end
31 |
32 | rummage_field :created_at_day do
33 | {:fragment, "date_part('day', ?)", :inserted_at}
34 | end
35 |
36 | rummage_field :created_at_hour do
37 | {:fragment, "date_part('hour', ?)", :inserted_at}
38 | end
39 |
40 | rummage_field :upper_case_name do
41 | {:fragment, "upper(?)", :name}
42 | end
43 |
44 | rummage_field :name_or_description do
45 | {:fragment, "coalesce(?, ?)", :name, :description}
46 | end
47 |
48 | rummage_scope(:category_name, [type: :search], fn term ->
49 | {:name, %{assoc: [inner: :category], search_term: term, search_type: :ilike}}
50 | end)
51 |
52 | rummage_scope(:category_name, [type: :sort], fn order ->
53 | %{field: :name, assoc: [inner: :category], order: order, ci: true}
54 | end)
55 |
56 | rummage_scope(:product_index, [type: :paginate], fn page ->
57 | %{per_page: 10, page: page}
58 | end)
59 |
60 | rummage_scope(:category_show, [type: :paginate], fn page ->
61 | %{per_page: 5, page: page}
62 | end)
63 |
64 | rummage_scope(:category_quarter, [type: :custom_search], fn {query, term} ->
65 | query
66 | |> join(:inner, [q], c in Rummage.Ecto.Category, on: q.category_id == c.id)
67 | |> where([..., c], fragment("date_part('quarter', ?)", c.inserted_at) == ^term)
68 | end)
69 |
70 | rummage_scope(:category_microseconds, [type: :custom_sort], fn {query, order} ->
71 | query
72 | |> join(:inner, [q], c in Rummage.Ecto.Category, on: q.category_id == c.id)
73 | |> order_by([..., c], [{^order, fragment("date_part('microseconds', ?)", c.inserted_at)}])
74 | end)
75 |
76 | rummage_scope(:small_page, [type: :custom_paginate], fn {query, page} ->
77 | offset = 5 * (page - 1)
78 |
79 | query
80 | |> limit(5)
81 | |> offset(^offset)
82 | end)
83 | end
84 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/schema.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema do
2 | @moduledoc """
3 | This module is meant to be `use`d by a module (typically an `Ecto.Schema`).
4 |
5 | This isn't a required module for using `Rummage`, but it allows us to extend
6 | its functionality.
7 | """
8 |
9 | @rummage_scope_types ~w{search sort paginate custom_search custom_sort custom_paginate}a
10 |
11 | @doc """
12 | This macro allows us to leverage features in `Rummage.Ecto.Schema`. It takes
13 | advantage of `Ecto`, `rummage_field` and `rummage_scope`
14 |
15 | ## Usage:
16 |
17 | ```elixir
18 | defmodule MySchema do
19 | use Rummage.Ecto.Schema
20 |
21 | schema "my_table" do
22 | field :field1, :integer
23 | field :field2, :integer
24 |
25 | timestamps()
26 | end
27 |
28 | rummage_field :field1_or_field2 do
29 | {:fragment, "coalesce(?, ?)", :name, :description}
30 | end
31 |
32 | rummage_scope :show_page, [type: :paginate], fn(page) ->
33 | %{per_page: 10, page: page}
34 | end
35 | end
36 | ```
37 | """
38 | defmacro __using__(opts) do
39 | quote do
40 | use Ecto.Schema
41 | use Rummage.Ecto, unquote(opts)
42 | import Ecto.Query
43 | import unquote(__MODULE__)
44 | end
45 | end
46 |
47 | @doc """
48 | Rummage Field is a way to define a field which can be used to search, sort,
49 | paginate through. This field might not exist in the database or the schema,
50 | but can be represented as a `fragments` query using multiple fields.
51 |
52 | NOTE: Currently this feature has some limitations due to limitations put on
53 | Ecto's fragments. Ecto 3.0 is expected to come out with `unsafe_fragment`,
54 | which will give this feature great flexibility. This feature is also quite
55 | dependent on what database engine is being used. For now, we have made
56 | a few fragments available (the list can be seen [here]()) which are thoroughly
57 | tested on postgres. If these fragments don't do it, you can use `rummage_scope`
58 | to accomplish a similar functionality.
59 |
60 | ## Usage:
61 |
62 | To use upper case name as rummage field:
63 |
64 | ```elixir
65 | rummage_field :upper_case_name do
66 | {:fragment, "upper(?)", :name}
67 | end
68 | ```
69 |
70 | To use the hour for created_at as rummage field:
71 | ```elixir
72 | rummage_field :created_at_hour do
73 | {:fragment, "date_part('hour', ?)", :inserted_at}
74 | end
75 | ```
76 | """
77 | defmacro rummage_field(field, do: block) do
78 | name = :"__rummage_field_#{field}"
79 |
80 | quote do
81 | def unquote(name)(), do: unquote(block)
82 | end
83 | end
84 |
85 | defmacro rummage_scope(scope, [type: type], fun) when type in @rummage_scope_types do
86 | name = :"__rummage_#{type}_#{scope}"
87 |
88 | quote do
89 | def unquote(name)(term), do: unquote(fun).(term)
90 | end
91 | end
92 | end
93 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Mixfile do
2 | use Mix.Project
3 |
4 | @version "2.1.0"
5 | @elixir "~> 1.13"
6 | @url "https://github.com/annkissam/rummage_ecto"
7 |
8 | def project do
9 | [
10 | app: :rummage_ecto,
11 | version: @version,
12 | elixir: @elixir,
13 | deps: deps(),
14 | build_embedded: Mix.env() == :prod,
15 | start_permanent: Mix.env() == :prod,
16 |
17 | # Test
18 | test_coverage: [tool: ExCoveralls],
19 | preferred_cli_env: [coveralls: :test],
20 | aliases: aliases(),
21 | elixirc_paths: elixirc_paths(Mix.env()),
22 |
23 | # Hex
24 | description: description(),
25 | package: package(),
26 |
27 | # Docs
28 | name: "Rummage.Ecto",
29 | docs: docs()
30 | ]
31 | end
32 |
33 | def application do
34 | [
35 | extra_applications: [
36 | :logger
37 | ]
38 | ]
39 | end
40 |
41 | def package do
42 | [
43 | files: ["lib", "mix.exs", "README.md"],
44 | maintainers: ["Adi Iyengar"],
45 | licenses: ["MIT"],
46 | links: %{"Github" => @url}
47 | ]
48 | end
49 |
50 | defp deps do
51 | [
52 | # Development Dependency
53 | {:ecto, "~> 3.8"},
54 | {:ecto_sql, "~> 3.8"},
55 | # Other Dependencies
56 | {:credo, "~> 1.6", only: [:dev, :test], runtime: false},
57 | {:excoveralls, "~> 0.14", only: :test, runtime: false},
58 | {:ex_doc, "~> 0.28", only: :dev, runtime: false},
59 | {:inch_ex, github: "rrrene/inch_ex", only: [:dev, :test, :docs], runtime: false},
60 | {:postgrex, ">= 0.0.0", only: :test}
61 | ]
62 | end
63 |
64 | defp description do
65 | """
66 | A library that allows searching, sorting and paginating ecto queries
67 | """
68 | end
69 |
70 | def docs do
71 | [
72 | main: "Rummage.Ecto",
73 | source_url: @url,
74 | extras: ["README.md", "CHANGELOG.md", "help/nomenclature.md", "help/walkthrough.md"],
75 | source_ref: "v#{@version}"
76 | ]
77 | end
78 |
79 | defp aliases do
80 | [
81 | "ecto.setup": ["ecto.create", "ecto.migrate"],
82 | "ecto.reset": ["ecto.drop", "ecto.setup"],
83 | test: ["ecto.setup", "test"],
84 | "test.watch.stale": &test_watch_stale/1,
85 | publish: ["hex.publish", &git_tag/1]
86 | ]
87 | end
88 |
89 | defp git_tag(_args) do
90 | System.cmd("git", ["tag", Mix.Project.config()[:version]])
91 | System.cmd("git", ["push", "--tags"])
92 | end
93 |
94 | defp test_watch_stale(_) do
95 | System.cmd(
96 | "sh",
97 | ["-c", "#{get_system_watcher()} lib/ test/ | mix test --stale --listen-on-stdin"],
98 | into: IO.stream(:stdio, :line)
99 | )
100 | end
101 |
102 | # Works only for Mac and Linux
103 | defp get_system_watcher do
104 | case System.cmd("uname", []) do
105 | # For Linux systems inotify should work
106 | {"Linux\n", 0} -> "inotifywait -e modify -e create -e delete -mr"
107 | # For Macs, fswatch comes directly installed
108 | {"Darwin\n", 0} -> "fswatch"
109 | {kernel, 0} -> raise "Watcher not supported on kernel: #{kernel}"
110 | end
111 | end
112 |
113 | defp elixirc_paths(:test), do: ["lib", "priv", "test/support"]
114 | defp elixirc_paths(_), do: ["lib"]
115 | end
116 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hook.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook do
2 | @moduledoc """
3 | This module defines a behaviour that `Rummage.Ecto.Hook`s have to follow.
4 |
5 | This module also defines a `__using__` macro which mandates certain
6 | behaviours for a `Hook` module to follow.
7 |
8 | Native hooks that come with `Rummage.Ecto` follow this behaviour.
9 |
10 | Custom Search, Sort and Paginate hooks should follow this behaviour
11 | as well, in order for them to work well with `Rummage.Ecto`
12 |
13 | ## Usage
14 |
15 | - This is the preferred way of creating a Custom Hook. Using
16 | `Rummage.Ecto.Hook.__using__/1` macro, it can be ensured that `run/2` and
17 | `format_params/2` functions have been implemented.
18 |
19 | ```elixir
20 | defmodule MyCustomHook do
21 | use Rummage.Ecto.Hook
22 |
23 | def run(queryable, params), do: queryable
24 |
25 | def format_params(querable, params, opts), do: params
26 | end
27 | ```
28 |
29 | - A Custom Hook can also be created by using `Rummage.Ecto.Hook` `@behviour`
30 |
31 | ```elixir
32 | defmodule MyCustomHook do
33 | @behviour Rummage.Ecto.Hook
34 |
35 | def run(queryable, params), do: queryable
36 |
37 | def format_params(querable, params, opts), do: params
38 | end
39 | ```
40 |
41 | """
42 |
43 | @doc """
44 | All callback invoked by `Rummage.Ecto` which applies a set of translations
45 | to an ecto query, based on operations defined in the hook.
46 | """
47 | @callback run(Ecto.Query.t(), map()) :: Ecto.Query.t()
48 |
49 | @doc """
50 | All callback invoked by `Rummage.Ecto` which applies a set of translations
51 | to params passed to the hook. This is responsible for making sure that
52 | the params passed to the hook's `run/2` function are santized.
53 | """
54 | @callback format_params(Ecto.Query.t(), map(), keyword()) :: map()
55 |
56 | @doc """
57 | This macro allows us to write rummage hooks in an easier way. This includes
58 | a `@behaviour` module attribute and defines `raisable` callback implementations
59 | for the hook `using` this module. It also makes `run/2` and `format_params/3`
60 | overridable and expects them to be defined in the hook.
61 |
62 | ## Usage:
63 |
64 | ```elixir
65 | defmodule MyHook do
66 | use Rummage.Ecto.Hook
67 |
68 | def run(queryable, params), do: "do something"
69 |
70 | def format_params(q, params, opts), do: "do something"
71 | end
72 | ```
73 |
74 | For a better example, check out `Rummage.Ecto.Hook.Paginate` or any other
75 | hooks defined in `Rummage.Ecto`
76 | """
77 | defmacro __using__(_opts) do
78 | quote do
79 | import unquote(__MODULE__)
80 | @behviour unquote(__MODULE__)
81 |
82 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
83 | def run(queryable, params) do
84 | raise "run/2 not implemented for hook: #{__MODULE__}"
85 | end
86 |
87 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
88 | def format_params(queryable, params, opts) do
89 | raise "format_params/2 not implemented for hook: #{__MODULE__}"
90 | end
91 |
92 | defoverridable run: 2, format_params: 3
93 | end
94 | end
95 |
96 | def resolve_field(field, queryable) do
97 | module = get_module(queryable)
98 | name = :"__rummage_field_#{field}"
99 |
100 | case function_exported?(module, name, 0) do
101 | true -> apply(module, name, [])
102 | _ -> field
103 | end
104 | end
105 |
106 | def get_module(module), do: Rummage.Ecto.QueryUtils.schema_from_query(module)
107 | end
108 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/config.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Config do
2 | @moduledoc """
3 | This module encapsulates all the Rummage's runtime configurations
4 | that can be set in the config.exs file.
5 |
6 | __This configuration is optional, as `Rummage.Ecto` can accept the same
7 | arguments as optional arguments to the function `Rummage.Ecto.rummage/3`__
8 |
9 | ## Usage:
10 |
11 | A basic example without overriding default hooks:
12 | ### config.exs:
13 |
14 | config :app_name, Rummage.Ecto,
15 | per_page: 10,
16 | repo: AppName.Repo
17 |
18 | This is a more advanced usage where you can specify the default hooks:
19 | ### config.exs:
20 |
21 | config :app_name, Rummage.Ecto,
22 | per_page: 10,
23 | repo: AppName.Repo,
24 | search: Rummage.Ecto.Hook.Search,
25 | sort: Rummage.Ecto.Hook.Sort,
26 | paginate: Rummage.Ecto.Hook.Paginate
27 |
28 | """
29 |
30 | @doc """
31 | `:search` hook can also be set at run time
32 | in the `config.exs` file. This pulls the configuration
33 | assocated with the application, `application`. When no
34 | application is given this defaults to `rummage_ecto`.
35 |
36 | ## Examples
37 | When no config is set, if returns the default hook
38 | (`Rummage.Ecto.Hook.Search`):
39 | iex> alias Rummage.Ecto.Config
40 | iex> Config.search
41 | Rummage.Ecto.Hook.Search
42 | """
43 | def search(application \\ :rummage_ecto) do
44 | config(:search, Rummage.Ecto.Hook.Search, application)
45 | end
46 |
47 | @doc """
48 | `:sort` hook can also be set at run time
49 | in the `config.exs` file
50 |
51 | ## Examples
52 | When no config is set, if returns the default hook
53 | (`Rummage.Ecto.Hook.Sort`):
54 | iex> alias Rummage.Ecto.Config
55 | iex> Config.sort
56 | Rummage.Ecto.Hook.Sort
57 | """
58 | def sort(application \\ :rummage_ecto) do
59 | config(:sort, Rummage.Ecto.Hook.Sort, application)
60 | end
61 |
62 | @doc """
63 | `:paginate` hook can also be set at run time
64 | in the `config.exs` file
65 |
66 | ## Examples
67 | When no config is set, if returns the default hook
68 | (`Rummage.Ecto.Hook.Paginate`):
69 | iex> alias Rummage.Ecto.Config
70 | iex> Config.paginate
71 | Rummage.Ecto.Hook.Paginate
72 | """
73 | def paginate(application \\ :rummage_ecto) do
74 | config(:paginate, Rummage.Ecto.Hook.Paginate, application)
75 | end
76 |
77 | @doc """
78 | `:per_page` can also be set at run time
79 | in the `config.exs` file
80 |
81 | ## Examples
82 | Returns default `Repo` set in the config
83 | (2 in `rummage_ecto`'s test env):
84 | iex> alias Rummage.Ecto.Config
85 | iex> Config.per_page
86 | 2
87 | """
88 | def per_page(application \\ :rummage_ecto) do
89 | config(:per_page, 10, application)
90 | end
91 |
92 | @doc """
93 | `:repo` can also be set at run time
94 | in the config.exs file
95 |
96 | ## Examples
97 | Returns default `Repo` set in the config
98 | (`Rummage.Ecto.Repo` in `rummage_ecto`'s test env):
99 | iex> alias Rummage.Ecto.Config
100 | iex> Config.repo
101 | Rummage.Ecto.Repo
102 | """
103 | def repo(application \\ :rummage_ecto) do
104 | config(:repo, nil, application)
105 | end
106 |
107 | defp config(application) do
108 | Application.get_env(application, Rummage.Ecto, [])
109 | end
110 |
111 | defp config(key, default, application) do
112 | application
113 | |> config()
114 | |> Keyword.get(key, default)
115 | |> resolve_config(default)
116 | end
117 |
118 | defp resolve_config(value, _default), do: value
119 | end
120 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/schema/macro.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.Macro do
2 | @moduledoc """
3 | Usage:
4 | ```elixir
5 | defmodule MyApp.Rummage.MyModel do
6 | use Rummage.Schema,
7 | paginate: MyApp.Rummage.Paginate,
8 | sort: MyApp.Rummage.MyModel.Sort,
9 | search: MyApp.Rummage.MyModel.Search,
10 | schema: MyApp.MyModel
11 | end
12 | ```
13 | """
14 |
15 | defmacro __using__(opts) do
16 | paginate = Keyword.fetch!(opts, :paginate)
17 | sort = Keyword.fetch!(opts, :sort)
18 | search = Keyword.fetch!(opts, :search)
19 | schema = Keyword.fetch!(opts, :schema)
20 | repo = Keyword.get(opts, :repo, Rummage.Ecto.Config.repo())
21 |
22 | quote location: :keep do
23 | use Ecto.Schema
24 | import Ecto.Changeset
25 | import Ecto.Query, warn: false
26 |
27 | @primary_key false
28 | embedded_schema do
29 | embeds_one(:paginate, unquote(paginate))
30 | embeds_one(:search, unquote(search))
31 | embeds_one(:sort, unquote(sort))
32 |
33 | field(:params, :map)
34 | field(:changeset, :map)
35 | end
36 |
37 | def changeset(nil), do: changeset(struct(__MODULE__), %{})
38 | def changeset(attrs), do: changeset(struct(__MODULE__), attrs)
39 |
40 | def changeset(rummage_schema, attrs) do
41 | attrs = Map.put_new(attrs, "paginate", %{})
42 | attrs = Map.put_new(attrs, "search", %{})
43 | attrs = Map.put_new(attrs, "sort", %{})
44 |
45 | rummage_schema
46 | |> cast(attrs, [])
47 | |> cast_embed(:paginate)
48 | |> cast_embed(:search)
49 | |> cast_embed(:sort)
50 | end
51 |
52 | def rummage(params, opts \\ []) do
53 | query = Keyword.get(opts, :query, unquote(schema))
54 |
55 | changeset = changeset(params)
56 | rummage = apply_changes(changeset)
57 |
58 | # changest - For use w/ 'search' form
59 | rummage = Map.put(rummage, :changeset, changeset)
60 |
61 | {query, rummage} =
62 | query
63 | |> search(rummage)
64 | |> sort(rummage)
65 | |> paginate(rummage)
66 |
67 | query =
68 | case Keyword.get(opts, :preload) do
69 | nil -> query
70 | preload -> from(a in query, preload: ^preload)
71 | end
72 |
73 | records = unquote(repo).all(query)
74 |
75 | paginate_params =
76 | if rummage.paginate do
77 | %{page: rummage.paginate.page, per_page: rummage.paginate.per_page}
78 | else
79 | nil
80 | end
81 |
82 | search_params =
83 | if rummage.search do
84 | Map.from_struct(rummage.search)
85 | else
86 | nil
87 | end
88 |
89 | sort_params =
90 | if rummage.sort do
91 | Map.from_struct(rummage.sort)
92 | else
93 | nil
94 | end
95 |
96 | # params - For use w/ sort and paginate links...
97 | rummage =
98 | Map.put(rummage, :params, %{
99 | paginate: paginate_params,
100 | search: search_params,
101 | sort: sort_params
102 | })
103 |
104 | {rummage, records}
105 | end
106 |
107 | # Note: rummage.paginate is modified - it gets a total_count
108 | defp paginate(query, %{paginate: paginate} = rummage) do
109 | {query, paginate} = unquote(paginate).rummage(query, paginate)
110 | {query, Map.put(rummage, :paginate, paginate)}
111 | end
112 |
113 | defp search(query, %{search: search}) do
114 | unquote(search).rummage(query, search)
115 | end
116 |
117 | defp sort(query, %{sort: sort}) do
118 | unquote(sort).rummage(query, sort)
119 | end
120 | end
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/schema/search.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.Search do
2 | @moduledoc """
3 |
4 | Usage:
5 |
6 | ```elixir
7 | defmodule MyApp.Rummage.MyModel.Search do
8 | use Rummage.Schema.Search,
9 | handlers: [
10 | category_name: %{search_field: :name, search_type: :ilike, assoc: [inner: :category]},
11 | price_gteq: %{search_field: :price, search_type: :gteq},
12 | price_lteq: %{search_field: :price, search_type: :lteq},
13 | name: %{search_type: :ilike},
14 | month: :integer,
15 | year: :integer,
16 | ]
17 |
18 | # Skip blank searches
19 | def search(query, name, nil), do: query
20 | def search(query, name, ""), do: query
21 |
22 | def search(query, :month, month) do
23 | from p in query,
24 | where: fragment("date_part('month', ?)", p.inserted_at) == ^month
25 | end
26 |
27 | def search(query, :year, year) do
28 | from p in query,
29 | where: fragment("date_part('year', ?)", p.inserted_at) == ^year
30 | end
31 |
32 | # Because we're overriding search we need to call super...
33 | def search(query, name, value) do
34 | super(query, name, value)
35 | end
36 | end
37 | ```
38 | """
39 |
40 | defmacro __using__(opts) do
41 | handlers = Keyword.get(opts, :handlers, [])
42 | changeset_fields = Keyword.keys(handlers)
43 |
44 | schema_fields =
45 | Enum.map(handlers, fn {name, handler} ->
46 | if is_atom(handler) do
47 | quote do
48 | field(unquote(name), unquote(handler))
49 | end
50 | else
51 | quote do
52 | type = Map.get(unquote(handler), :type, :string)
53 | field(unquote(name), type)
54 | end
55 | end
56 | end)
57 |
58 | # TODO: Is this better?
59 | # search_functions = Enum.map(handlers, fn{name, handler} ->
60 | # quote do
61 | # def search(query, unquote(name), value) do
62 | # params = unquote(handler)
63 | # |> Map.put_new(:assoc, [])
64 | # |> Map.put_new(:search_field, unquote(name))
65 | # |> Map.put(:search_term, value)
66 | # |> Map.put_new(:search_expr, :where)
67 |
68 | # Rummage.Ecto.Hooks.Search.run(query, %{Atom.to_string(unquote(name)) => params})
69 | # end
70 | # end
71 | # end)
72 |
73 | quote location: :keep do
74 | use Ecto.Schema
75 | import Ecto.Changeset
76 | import Ecto.Query, warn: false
77 |
78 | @primary_key false
79 | embedded_schema do
80 | unquote(schema_fields)
81 | end
82 |
83 | def changeset(sort, attrs \\ %{}) do
84 | sort
85 | |> cast(attrs, unquote(changeset_fields))
86 | end
87 |
88 | def rummage(query, nil), do: query
89 |
90 | def rummage(query, search) do
91 | fields = unquote(changeset_fields)
92 |
93 | Enum.reduce(fields, query, fn field, q ->
94 | search(q, field, Map.get(search, field))
95 | end)
96 | end
97 |
98 | def search(query, _name, nil), do: query
99 | def search(query, _name, ""), do: query
100 |
101 | # unquote(search_functions)
102 |
103 | def search(query, name, value) do
104 | handler = Keyword.get(unquote(handlers), name)
105 |
106 | if handler && is_map(handler) do
107 | params =
108 | handler
109 | |> Map.drop([:type])
110 | |> Map.put_new(:assoc, [])
111 | |> Map.put_new(:search_field, name)
112 | |> Map.put(:search_term, value)
113 | |> Map.put_new(:search_expr, :where)
114 |
115 | Rummage.Ecto.Hook.Search.run(query, %{
116 | name => params
117 | })
118 | else
119 | raise "Unknown Search: #{name}"
120 | end
121 | end
122 |
123 | defoverridable search: 3
124 | end
125 | end
126 | end
127 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Versions CHANGELOG
2 |
3 | ## Version 2.1.0
4 |
5 | - Bumps
6 | * elixir version 1.13
7 | * dependencies versions
8 |
9 | - Fix credo issues
10 |
11 | ## Version 2.0.0-rc.0
12 |
13 | - Change in namespace/module names:
14 | * Replace `Rummage.Ecto.Hooks` with `Rummage.Ecto.Hook`.
15 | * Replace `Rummage.Ecto.CustomHooks` with `Rummage.Ecto.CustomHook`.
16 |
17 | - Introducing `Rummage.Ecto.Schema`:
18 |
19 | - Changes to `Rummage.Ecto.Hook.Search`:
20 |
21 | - Changes to `Rummage.Ecto.Hook.Sort`:
22 |
23 | - Changes to `Rummage.Ecto.Hook.Paginate`:
24 |
25 | - Changes in Configurations:
26 |
27 |
28 |
29 | ## Version: 1.3.0-rc.0
30 |
31 | - Better Behaviour definition for Hooks.
32 | * Add `__using__` macro, instead of using module_attribute `@behviour`.
33 | * Use better function names `run/2` and `format_params`,
34 | * Use `defoverridable` for `@behaviour` callbacks.
35 |
36 | - Make Native hooks more generalized instead of targeted for `phoenix`.
37 | * Use `atoms` over `strings` for keys in maps and params.
38 | * Keep hooks more agnostic of configurations.
39 | * Make Rummage.Ecto delegate configurations to hooks.
40 |
41 | - The return of `Rummage.Ecto.__using__/1` macro.
42 | * This allows `rummage_ecto` to resolve configurations at compile time.
43 | * This allows better/easier usage of `Rummage.Ecto`.
44 |
45 | - App specific configurations.
46 | * `config :appname, Rummage.Ecto .....` instead of `config: :rummage_ecto, Rummage.Ecto`.
47 | * This allows using rummage with two different apps in an umbrella app with different rummage configurations.
48 | * These configurations are loaded with the help of `__using__` macro, based on the application the module belongs to.
49 |
50 | - Search hook has `search_expr`.
51 | * This allows usage of `or_where` and `not_where` queries.
52 | * Defaults to `where`.
53 |
54 | - Search hook has `search_type` : `is_nil`
55 | * This allows for searching for `NULL` or `NOT NULL`
56 |
57 | - Tested/Examples with different `field_types`, `boolean`, `float`, `string` etc.
58 |
59 | - Paginate hook works with or without a `primary_key`:
60 | * the default paginate hook used to work only for Schemas with `id` as primary keys, now it works for all and even Schemas without a primary key.
61 |
62 | - Keyset Pagination CustomHook.
63 | * Adds faster/lighter weight pagination option.
64 | * Documentation specifies when to use it and when not to.
65 |
66 | - SimpleSort and SimpleSearch CustomHook updates.
67 | * Same as sort and search, but without associations, so cleaner and faster.
68 |
69 | - Better documentation.
70 | * Search and Sort associations are better documented.
71 | * CustomHooks are better documented.
72 |
73 |
74 | ## Version: 1.2.0
75 |
76 | - Faster Pagination Hooks
77 |
78 | ## Version: 1.1.0
79 |
80 | ### Changes to Rummage as whole:
81 | - More functional way of calling `Rummage`:
82 | - Instead of `EctoSchema.rummage(query, rummage)`, call `Rummage.Ecto.rummage(query, rummage)`
83 |
84 | - Default `Hooks` can handle any number of associations.
85 |
86 | ### Changes to complexity:
87 | - `Hooks` are more independent of each other due to a newly introduced `before_hook` feature. This
88 | allows us to format `rummage_params` based on what a hook is expecting and keep the code clean.
89 |
90 | ### In Progress:
91 | - A `CustomHook` with `key-set` pagination based on [this](http://use-the-index-luke.com/no-offset) link.
92 |
93 |
94 | ## Version: 1.0.0
95 |
96 | ### Major changes to default hooks:
97 | - `Search`:
98 | - Can now search more than just `like`.
99 | - Added case insensitive `like` feature.
100 | - Added support for `like`, `ilike`, `eq`, `gt`, `lt`, `gteq`, `lteq` as `search_types` (Refer to docs for more details)
101 | - Can search on an association field (Refer to docs for more details)
102 |
103 | - `Sort`:
104 | - Added case insensitive `sort`.
105 | - Can sort on an association field (Refer to docs for more details)
106 |
107 | - `Pagination`: NO CHANGES
108 |
109 | ### Change in `rummage` struct syntaxes:
110 | - `search` key:
111 | - Earlier:
112 | ```elixir
113 | rummage = %{"search" => %{"field_1" => "field_!"}}
114 | ```
115 |
116 | - Now:
117 | ```elixir
118 | rummage = %{"search" => %{"field_1" => %{"assoc" => ["assoc_1", "assoc_2"], "search_type" => "like", "search_term" => "field_!"}}
119 | ```
120 |
121 | - `sort` key:
122 | - Earlier:
123 | ```elixir
124 | rummage = %{"sort" => "field_1.asc"}
125 | ```
126 |
127 | - Now:
128 | ```elixir
129 | rummage = %{"sort" => %{"assoc" => ["assoc_1", "assoc_2"], "field" => "field_1.asc"}}
130 | ```
131 |
132 | - `paginate` key: NO CHANGES
133 |
134 | ### Custom Hooks Examples Included:
135 | - Included some examples for custom hooks:
136 | - `Rumamage.Ecto.CustomHooks.SimpleSearch`: Vanilla search feature with support for only `like`
137 | - `Rumamage.Ecto.CustomHooks.SimpleSort`: Vanilla sort feature
138 |
139 |
140 | ## Version: 0.6.0
141 |
142 | - First version with Rummage.Phoenix compatibility
143 | - First major version
144 |
145 |
--------------------------------------------------------------------------------
/test/schema/macro_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Schema.MacroTest do
2 | use ExUnit.Case
3 |
4 | alias Rummage.Ecto.Repo
5 | alias Rummage.Ecto.Product
6 | alias Rummage.Ecto.Category
7 |
8 | setup do
9 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
10 | end
11 |
12 | defp create_categories_and_products() do
13 | for x <- 1..4 do
14 | parent_category =
15 | %Category{name: "Parent Category #{10 - x}"}
16 | |> Repo.insert!()
17 |
18 | category =
19 | %Category{name: "Category #{x}", parent_category: parent_category}
20 | |> Repo.insert!()
21 |
22 | for y <- 1..2 do
23 | %Product{
24 | internal_code: "#{x}-#{y}",
25 | name: "Product #{x}-#{y}",
26 | price: 10.0 * x,
27 | category: category
28 | }
29 | |> Repo.insert!()
30 | end
31 | end
32 | end
33 |
34 | test "changeset (default)" do
35 | params = %{}
36 |
37 | changeset = Rummage.Ecto.Rummage.Product.changeset(params)
38 |
39 | assert changeset.changes[:paginate].changes == %{per_page: 2}
40 |
41 | assert changeset.changes[:paginate].data == %Rummage.Ecto.Rummage.Paginate{
42 | max_page: nil,
43 | page: 1,
44 | per_page: nil,
45 | total_count: nil
46 | }
47 |
48 | assert changeset.changes[:paginate].params == %{}
49 |
50 | assert changeset.changes[:search].changes == %{}
51 |
52 | assert changeset.changes[:search].data == %Rummage.Ecto.Rummage.Product.Search{
53 | category_name: nil,
54 | month: nil,
55 | name: nil,
56 | price_gteq: nil,
57 | price_lteq: nil,
58 | year: nil
59 | }
60 |
61 | assert changeset.changes[:search].params == %{}
62 |
63 | assert changeset.changes[:sort].changes == %{name: "inserted_at", order: "asc"}
64 |
65 | assert changeset.changes[:sort].data == %Rummage.Ecto.Rummage.Product.Sort{
66 | name: nil,
67 | order: nil
68 | }
69 |
70 | assert changeset.changes[:sort].params == %{}
71 |
72 | assert changeset.data == %Rummage.Ecto.Rummage.Product{}
73 | assert changeset.params == %{"paginate" => %{}, "search" => %{}, "sort" => %{}}
74 | end
75 |
76 | test "changeset" do
77 | params = %{
78 | "search" => %{"name" => "3-"},
79 | "sort" => %{"name" => "name", "order" => "desc"},
80 | "paginate" => %{"page" => 2, "per_page" => 4}
81 | }
82 |
83 | changeset = Rummage.Ecto.Rummage.Product.changeset(params)
84 |
85 | assert changeset.changes[:paginate].changes == %{per_page: 4, page: 2}
86 |
87 | assert changeset.changes[:paginate].data == %Rummage.Ecto.Rummage.Paginate{
88 | max_page: nil,
89 | page: 1,
90 | per_page: nil,
91 | total_count: nil
92 | }
93 |
94 | assert changeset.changes[:paginate].params == %{"page" => 2, "per_page" => 4}
95 |
96 | assert changeset.changes[:search].changes == %{name: "3-"}
97 |
98 | assert changeset.changes[:search].data == %Rummage.Ecto.Rummage.Product.Search{
99 | category_name: nil,
100 | month: nil,
101 | name: nil,
102 | price_gteq: nil,
103 | price_lteq: nil,
104 | year: nil
105 | }
106 |
107 | assert changeset.changes[:search].params == %{"name" => "3-"}
108 |
109 | assert changeset.changes[:sort].changes == %{name: "name", order: "desc"}
110 |
111 | assert changeset.changes[:sort].data == %Rummage.Ecto.Rummage.Product.Sort{
112 | name: nil,
113 | order: nil
114 | }
115 |
116 | assert changeset.changes[:sort].params == %{"name" => "name", "order" => "desc"}
117 |
118 | assert changeset.data == %Rummage.Ecto.Rummage.Product{}
119 |
120 | assert changeset.params == %{
121 | "paginate" => %{"page" => 2, "per_page" => 4},
122 | "search" => %{"name" => "3-"},
123 | "sort" => %{"name" => "name", "order" => "desc"}
124 | }
125 | end
126 |
127 | test "rummage" do
128 | create_categories_and_products()
129 |
130 | params = %{
131 | "search" => %{"name" => "3-"},
132 | "sort" => %{"name" => "name", "order" => "desc"},
133 | "paginate" => %{"page" => 1, "per_page" => 4}
134 | }
135 |
136 | {rummage, products} = Rummage.Ecto.Rummage.Product.rummage(params)
137 |
138 | assert length(products) == 2
139 | assert Enum.map(products, & &1.name) == ["Product 3-2", "Product 3-1"]
140 |
141 | assert rummage.paginate == %Rummage.Ecto.Rummage.Paginate{
142 | max_page: 1,
143 | page: 1,
144 | per_page: 4,
145 | total_count: 2
146 | }
147 |
148 | assert rummage.sort == %Rummage.Ecto.Rummage.Product.Sort{name: "name", order: "desc"}
149 |
150 | assert rummage.search == %Rummage.Ecto.Rummage.Product.Search{
151 | category_name: nil,
152 | month: nil,
153 | name: "3-",
154 | price_gteq: nil,
155 | price_lteq: nil,
156 | year: nil
157 | }
158 |
159 | assert rummage.params == %{
160 | paginate: %{page: 1, per_page: 4},
161 | search: %{
162 | category_name: nil,
163 | month: nil,
164 | name: "3-",
165 | price_gteq: nil,
166 | price_lteq: nil,
167 | year: nil
168 | },
169 | sort: %{name: "name", order: "desc"}
170 | }
171 |
172 | assert rummage.changeset
173 | end
174 | end
175 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Rummage.Ecto
2 |
3 |
4 |
5 | [](https://travis-ci.org/annkissam/rummage_ecto)
6 | [](https://coveralls.io/github/annkissam/rummage_ecto?branch=master)
7 | [](https://hex.pm/packages/rummage_ecto)
8 | [](https://hex.pm/packages/rummage_ecto)
9 | [](https://hexdocs.pm/rummage_ecto)
10 | [](http://inch-ci.org/github/annkissam/rummage_ecto)
11 | [](https://raw.githubusercontent.com/annkissam/rummage_ecto/master/LICENSE)
12 |
13 | **If you're looking for full `Phoenix` support, `Rummage.Phoenix` uses `Rummage.Ecto` and adds `HTML` and `Controller` support
14 | to it. You can check `Rummage.Phoenix` out by clicking [here](https://github.com/annkissam/rummage_phoenix)**
15 |
16 | **Please refer for [CHANGELOG](CHANGELOG.md) for version specific changes**
17 |
18 | `Rummage.Ecto` is a light weight, but powerful framework that can be used to alter `Ecto` queries with Search, Sort and Paginate operations.
19 |
20 | It accomplishes the above operations by using `Hooks`, which are modules that implement `Rummage.Ecto.Hook` behavior.
21 | Each operation: `Search`, `Sort` and `Paginate` have their hooks defined in `Rummage`. By doing this, `Rummage` is completely
22 | configurable.
23 |
24 | For example, if you don't like one of the hooks of `Rummage`, but you do like the other two, you can configure `Rummage` to not use it and write your own custom
25 | hook.
26 |
27 | **NOTE: `Rummage` is not like `Ransack`, and it doesn't intend to be like `Ransack`. It doesn't define functions based on search parameters.
28 | If you'd like to have something like that, you can always configure `Rummage` to use your `Search` module for that model. This
29 | is why Rummage has been made configurable.**
30 |
31 | To see an example usage of `rummage`, check [this](https://github.com/annkissam/rummage_ecto_example) repository.
32 |
33 | ## Installation
34 |
35 | This package is [available in Hex](https://hexdocs.pm/rummage_ecto/), and can be installed as:
36 |
37 | - Add `rummage_ecto` to your list of dependencies in `mix.exs`:
38 |
39 | ```elixir
40 | def deps do
41 | [{:rummage_ecto, "~> 2.0.0-rc.0"}]
42 | end
43 | ```
44 |
45 | ## Blogs
46 |
47 | ### Current Blogs:
48 |
49 | - [Rummage Demo & Basics](https://medium.com/aditya7iyengar/searching-sorting-and-pagination-in-elixir-phoenix-with-rummage-part-1-933106ec50ca#.der0yrnvq)
50 | - [Using Rummage.Ecto](https://medium.com/aditya7iyengar/searching-sorting-and-pagination-in-elixir-phoenix-with-rummage-part-2-8e36558984c2#.vviioi5ia)
51 | - [Using Rummage.Phoenix: Part 1](https://medium.com/aditya7iyengar/searching-sorting-and-pagination-in-elixir-phoenix-with-rummage-part-3-7cf5023bc226#.q08478ud2)
52 |
53 | ### Coming up next:
54 |
55 | - Using Rummage.Phoenix: Part 2
56 | - Using the Rummage Search hook
57 | - Using the Rummage Sort hook
58 | - Writing a Custom Rummage.Ecto Hook
59 | - Writing a Custom Rummage.Phoenix HTML helper
60 |
61 | ## Hooks
62 |
63 | - Hooks are modules (that use `Rummage.Ecto.Hook`) and implement callbacks for
64 | `Rummage.Ecto.Hook` behaviour. Each ecto operation which can transform the
65 | query is defined by a `Hook`. Hooks have `run/2` function using which they
66 | can transform an `Ecto.Queryable` variable and have `format_params/3` function
67 | using which they can transform params passed to them through `rummage_ecto`
68 |
69 |
70 | ## Configuration
71 |
72 | - **NOTE: This is Optional. If no configuration is provided, `Rummage` will use default hooks and `AppName.Repo` as the repo**
73 | - If you want to override any of the `Rummage` default hooks,
74 | add `rummage_ecto` config to your list of configs in `dev.exs`:
75 |
76 | ```elixir
77 | config :rummage_ecto,
78 | Rummage.Ecto,
79 | search: MyApp.SearchModule
80 | ```
81 |
82 | - For configuring a repo:
83 |
84 | ```elixir
85 | config :rummage_ecto,
86 | Rummage.Ecto,
87 | repo: MyApp.Repo # This can be overridden per model basis, if need be.
88 | ```
89 |
90 | - Other config options are: `repo`, `sort`, `paginate`, `per_page`
91 |
92 | - `Rummage.Ecto` can be configured globally with a `per_page` value (which can be overridden for a model).
93 | If you want to set different `per_page` for different the models, add it to `model.exs` file while using `Rummage.Ecto`
94 | as shown in the [Advanced Usage Section](#advanced-usage).
95 |
96 |
97 | ## Usage
98 |
99 | `Rummage.Ecto` comes with a lot of powerful features which are available right away,
100 | without writing a whole lot of code.
101 |
102 | Below are the ways `Rummage.Ecto` can be used:
103 |
104 | ### Basic Usage:
105 |
106 | - Add the `Repo` of your app and the desired `per_page` (if using Rummage's Pagination) to the `rummage_ecto` configuration in `config.exs`:
107 |
108 | ```elixir
109 | config :rummage_ecto, Rummage.Ecto,
110 | repo: MyApp.Repo,
111 | per_page: 10
112 | ```
113 |
114 | - And you should be able to use `Rummage.Ecto` with any `Ecto` model.
115 |
116 | ### Advanced Usage:
117 |
118 | - If you'd like to override any of `Rummage`'s default hooks with your custom hook, add the `CustomHook` of your app with the desired operation to the
119 | `rummage_ecto` configuration in `config.exs`:
120 |
121 | ```elixir
122 | config :rummage_ecto, Rummage.Ecto,
123 | repo: MyApp.Repo,
124 | search: MyApp.SearchModule,
125 | paginate: MyApp.PaginateModule
126 | ```
127 |
128 | - When using `Rummage.Ecto` with an app that has multiple `Repo`s, or when there's a need to configure `Repo` per model basis, it can be passed along with
129 | with the call to `Rummage.Ecto`. This overrides the default repo set in the configuration:
130 |
131 | ```elixir
132 | {queryable, rummage} = Product
133 | |> Rummage.Ecto.rummage(rummage, repo: MyApp.Repo2)
134 | ```
135 |
136 | - And you should be able to use `Rummage.Ecto` with `Product` model which is in a different `Repo` than the default one.
137 |
138 |
139 | ## Examples
140 |
141 | - Setting up the application above will allow us to do the following:
142 |
143 | ```elixir
144 | rummage = %{
145 | search: %{field_1 => %{search_type: :like, search_term: "field_!"}},
146 | sort: %{field: :field1, order: :asc},
147 | paginate: %{per_page: 5, page: 1}
148 | }
149 |
150 | {queryable, rummage} = Product
151 | |> Rummage.Ecto.rummage(rummage)
152 |
153 | products = queryable
154 | |> Product.another_operation # <-- Since `Rummage` is Ecto, we can pipe the result queryable into another queryable operation.
155 | |> Repo.all
156 | ```
157 |
158 | - Rummage responds to `params` with keys: `search`, `sort` and/or `paginate`. It doesn't need to have all the keys, or any keys for that matter.
159 | If invalid keys are passed, they won't alter any operations in rummage. Here's an example of `Rummage` params:
160 |
161 | ```elixir
162 | rummage = %{
163 | search: %{field_1 => %{search_type: :like, search_term: "field_!"}},
164 | sort: %{field: :field1, order: :asc},
165 | paginate: %{per_page: 5, page: 1}
166 | }
167 | ```
168 |
--------------------------------------------------------------------------------
/test/services/build_search_query_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Services.BuildSearchQueryTest do
2 | use ExUnit.Case
3 | alias Rummage.Ecto.Services.BuildSearchQuery
4 | doctest BuildSearchQuery
5 |
6 | @supported_fragments_one [
7 | "date_part('day', ?)",
8 | "date_part('month', ?)",
9 | "date_part('year', ?)",
10 | "date_part('hour', ?)",
11 | "lower(?)",
12 | "upper(?)"
13 | ]
14 |
15 | @supported_fragments_two ["concat(?, ?)", "coalesce(?, ?)"]
16 |
17 | @search_types ~w{like ilike eq gt lt gteq lteq in}a
18 | @search_exprs ~w{where or_where not_where}a
19 |
20 | test "Definitions of Single Interpolation Fragments" do
21 | for fragment <- @supported_fragments_one do
22 | for search_type <- @search_types do
23 | for search_expr <- @search_exprs do
24 | name = :"handle_#{search_type}"
25 | queryable = Rummage.Ecto.Product
26 | term = "abcd"
27 |
28 | result =
29 | apply(BuildSearchQuery, name, [
30 | queryable,
31 | {:fragment, fragment, :field},
32 | term,
33 | search_expr
34 | ])
35 |
36 | assert result ==
37 | apply(BuildSearchQuery, name, [
38 | queryable,
39 | {:fragment, fragment, :field},
40 | term,
41 | search_expr
42 | ])
43 | end
44 | end
45 |
46 | search_type = :in
47 |
48 | for search_expr <- @search_exprs do
49 | name = :"handle_#{search_type}"
50 | queryable = Rummage.Ecto.Product
51 | term = true
52 |
53 | result =
54 | apply(BuildSearchQuery, name, [
55 | queryable,
56 | {:fragment, fragment, :field},
57 | term,
58 | search_expr
59 | ])
60 |
61 | assert result ==
62 | apply(BuildSearchQuery, name, [
63 | queryable,
64 | {:fragment, fragment, :field},
65 | term,
66 | search_expr
67 | ])
68 | end
69 |
70 | for search_expr <- @search_exprs do
71 | name = :"handle_#{search_type}"
72 | queryable = Rummage.Ecto.Product
73 | term = false
74 |
75 | result =
76 | apply(BuildSearchQuery, name, [
77 | queryable,
78 | {:fragment, fragment, :field},
79 | term,
80 | search_expr
81 | ])
82 |
83 | assert result ==
84 | apply(BuildSearchQuery, name, [
85 | queryable,
86 | {:fragment, fragment, :field},
87 | term,
88 | search_expr
89 | ])
90 | end
91 |
92 | search_type = :is_nil
93 |
94 | for search_expr <- @search_exprs do
95 | name = :"handle_#{search_type}"
96 | queryable = Rummage.Ecto.Product
97 | term = true
98 |
99 | result =
100 | apply(BuildSearchQuery, name, [
101 | queryable,
102 | {:fragment, fragment, :field},
103 | term,
104 | search_expr
105 | ])
106 |
107 | assert result ==
108 | apply(BuildSearchQuery, name, [
109 | queryable,
110 | {:fragment, fragment, :field},
111 | term,
112 | search_expr
113 | ])
114 | end
115 |
116 | for search_expr <- @search_exprs do
117 | name = :"handle_#{search_type}"
118 | queryable = Rummage.Ecto.Product
119 | term = false
120 |
121 | result =
122 | apply(BuildSearchQuery, name, [
123 | queryable,
124 | {:fragment, fragment, :field},
125 | term,
126 | search_expr
127 | ])
128 |
129 | assert result ==
130 | apply(BuildSearchQuery, name, [
131 | queryable,
132 | {:fragment, fragment, :field},
133 | term,
134 | search_expr
135 | ])
136 | end
137 | end
138 | end
139 |
140 | test "Definitions of Double Interpolation Fragments" do
141 | for fragment <- @supported_fragments_two do
142 | for search_type <- @search_types do
143 | for search_expr <- @search_exprs do
144 | name = :"handle_#{search_type}"
145 | queryable = Rummage.Ecto.Product
146 | term = "abcd"
147 |
148 | result =
149 | apply(BuildSearchQuery, name, [
150 | queryable,
151 | {:fragment, fragment, :field1, :field2},
152 | term,
153 | search_expr
154 | ])
155 |
156 | assert result ==
157 | apply(BuildSearchQuery, name, [
158 | queryable,
159 | {:fragment, fragment, :field1, :field2},
160 | term,
161 | search_expr
162 | ])
163 | end
164 | end
165 |
166 | search_type = :in
167 |
168 | for search_expr <- @search_exprs do
169 | name = :"handle_#{search_type}"
170 | queryable = Rummage.Ecto.Product
171 | term = true
172 |
173 | result =
174 | apply(BuildSearchQuery, name, [
175 | queryable,
176 | {:fragment, fragment, :field1, :field2},
177 | term,
178 | search_expr
179 | ])
180 |
181 | assert result ==
182 | apply(BuildSearchQuery, name, [
183 | queryable,
184 | {:fragment, fragment, :field1, :field2},
185 | term,
186 | search_expr
187 | ])
188 | end
189 |
190 | for search_expr <- @search_exprs do
191 | name = :"handle_#{search_type}"
192 | queryable = Rummage.Ecto.Product
193 | term = false
194 |
195 | result =
196 | apply(BuildSearchQuery, name, [
197 | queryable,
198 | {:fragment, fragment, :field1, :field2},
199 | term,
200 | search_expr
201 | ])
202 |
203 | assert result ==
204 | apply(BuildSearchQuery, name, [
205 | queryable,
206 | {:fragment, fragment, :field1, :field2},
207 | term,
208 | search_expr
209 | ])
210 | end
211 |
212 | search_type = :is_nil
213 |
214 | for search_expr <- @search_exprs do
215 | name = :"handle_#{search_type}"
216 | queryable = Rummage.Ecto.Product
217 | term = true
218 |
219 | result =
220 | apply(BuildSearchQuery, name, [
221 | queryable,
222 | {:fragment, fragment, :field1, :field2},
223 | term,
224 | search_expr
225 | ])
226 |
227 | assert result ==
228 | apply(BuildSearchQuery, name, [
229 | queryable,
230 | {:fragment, fragment, :field1, :field2},
231 | term,
232 | search_expr
233 | ])
234 | end
235 |
236 | for search_expr <- @search_exprs do
237 | name = :"handle_#{search_type}"
238 | queryable = Rummage.Ecto.Product
239 | term = false
240 |
241 | result =
242 | apply(BuildSearchQuery, name, [
243 | queryable,
244 | {:fragment, fragment, :field1, :field2},
245 | term,
246 | search_expr
247 | ])
248 |
249 | assert result ==
250 | apply(BuildSearchQuery, name, [
251 | queryable,
252 | {:fragment, fragment, :field1, :field2},
253 | term,
254 | search_expr
255 | ])
256 | end
257 | end
258 | end
259 | end
260 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/custom_hooks/simple_sort.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.SimpleSort do
2 | @moduledoc """
3 | `Rummage.Ecto.CustomHook.SimpleSort` is the default sort hook that comes with
4 | `Rummage.Ecto`.
5 |
6 | This module provides a operations that can add sorting functionality to
7 | a pipeline of `Ecto` queries. This module works by taking the `field` that should
8 | be used to `order_by`, `order` which can be `asc` or `desc`.
9 |
10 | This module doesn't support associations and hence is a simple alternative
11 | to Rummage's default search hook.
12 |
13 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
14 | This module `uses` `Rummage.Ecto.Hook`.
15 |
16 | _____________________________________________________________________________
17 |
18 | # ABOUT:
19 |
20 | ## Arguments:
21 |
22 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
23 | `sort_params` (a `Map`). The map should be in the format:
24 | `%{field: :field_name, order: :asc}`
25 |
26 | Details:
27 |
28 | * `field`: The field name (atom) to sorted by.
29 | * `order`: Specifies the type of order `asc` or `desc`.
30 | * `ci` : Case Insensitivity. Defaults to `false`
31 |
32 |
33 | For example, if we want to sort products with descending `price`, we would
34 | do the following:
35 |
36 | ```elixir
37 | Rummage.Ecto.CustomHook.SimpleSort.run(Product, %{field: :price,
38 | order: :desc})
39 | ```
40 |
41 | ## Assoications:
42 |
43 | This module doesn't support assocations.
44 |
45 | ____________________________________________________________________________
46 |
47 | # ASSUMPTIONS/NOTES:
48 |
49 | * This Hook has the default `order` of `:asc`.
50 | * This Hook assumes that the searched field is a part of the schema passed
51 | as the `queryable`.
52 |
53 | ____________________________________________________________________________
54 |
55 | # USAGE:
56 |
57 | For a regular sort:
58 |
59 | This returns a `queryable` which upon running will give a list of `Parent`(s)
60 | sorted by ascending `field_1`
61 |
62 | ```elixir
63 | alias Rummage.Ecto.CustomHook.SimpleSort
64 |
65 | sorted_queryable = SimpleSort.run(Parent, %{field: :name, order: :asc}})
66 | ```
67 |
68 | For a case-insensitive sort:
69 |
70 | This returns a `queryable` which upon running will give a list of `Parent`(s)
71 | sorted by ascending case insensitive `field_1`.
72 |
73 | Keep in mind that `case_insensitive` can only be called for `text` fields
74 |
75 | ```elixir
76 | alias Rummage.Ecto.CustomHook.SimpleSort
77 |
78 | sorted_queryable = SimpleSort.run(Parent, %{field: :name, order: :asc, ci: true}})
79 | ```
80 |
81 |
82 | This module can be overridden with a custom module while using `Rummage.Ecto`
83 | in `Ecto` struct module.
84 |
85 | In the `Ecto` module:
86 | ```elixir
87 | Rummage.Ecto.rummage(queryable, rummage, sort: CustomHook)
88 | ```
89 |
90 | OR
91 |
92 | Globally for all models in `config.exs`:
93 | ```elixir
94 | config :rummage_ecto,
95 | Rummage.Ecto,
96 | sort: CustomHook
97 | ```
98 |
99 | The `CustomHook` must use `Rummage.Ecto.Hook`. For examples of `CustomHook`,
100 | check out some `custom_hooks` that are shipped with `Rummage.Ecto`:
101 | `Rummage.Ecto.CustomHook.SimpleSearch`, `Rummage.Ecto.CustomHook.SimpleSort`,
102 | `Rummage.Ecto.CustomHook.SimplePaginate`
103 |
104 | """
105 |
106 | use Rummage.Ecto.Hook
107 |
108 | import Ecto.Query
109 |
110 | @expected_keys ~w(field order)a
111 | @err_msg "Error in params, No values given for keys: "
112 |
113 | @doc """
114 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
115 |
116 | Builds a sort `Ecto.Query.t` on top of the given `Ecto.Queryable` variable
117 | using given `params`.
118 |
119 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
120 | implements `Ecto.Queryable`
121 |
122 | Params is a `Map` which is expected to have the keys `#{Enum.join(@expected_keys, ", ")}`.
123 |
124 | This funciton expects a `field` atom, `order` which can be `asc` or `desc`,
125 | `ci` which is a boolean indicating the case-insensitivity.
126 |
127 | ## Examples
128 | When an empty map is passed as `params`:
129 |
130 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
131 | iex> SimpleSort.run(Parent, %{})
132 | ** (RuntimeError) Error in params, No values given for keys: field, order
133 |
134 | When a non-empty map is passed as `params`, but with a missing key:
135 |
136 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
137 | iex> SimpleSort.run(Parent, %{field: :name})
138 | ** (RuntimeError) Error in params, No values given for keys: order
139 |
140 | When a valid map of params is passed with an `Ecto.Schema` module:
141 |
142 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
143 | iex> SimpleSort.run(Rummage.Ecto.Product, %{field: :name, order: :asc})
144 | #Ecto.Query
145 |
146 | When the `queryable` passed is an `Ecto.Query` variable:
147 |
148 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
149 | iex> import Ecto.Query
150 | iex> queryable = from u in "products"
151 | #Ecto.Query
152 | iex> SimpleSort.run(queryable, %{field: :name, order: :asc})
153 | #Ecto.Query
154 |
155 |
156 | When the `queryable` passed is an `Ecto.Query` variable, with `desc` order:
157 |
158 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
159 | iex> import Ecto.Query
160 | iex> queryable = from u in "products"
161 | #Ecto.Query
162 | iex> SimpleSort.run(queryable, %{field: :name, order: :desc})
163 | #Ecto.Query
164 |
165 | When the `queryable` passed is an `Ecto.Query` variable, with `ci` true:
166 |
167 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
168 | iex> import Ecto.Query
169 | iex> queryable = from u in "products"
170 | #Ecto.Query
171 | iex> SimpleSort.run(queryable, %{field: :name, order: :asc, ci: true})
172 | #Ecto.Query
173 |
174 | """
175 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
176 | def run(queryable, sort_params) do
177 | :ok = validate_params(sort_params)
178 |
179 | handle_sort(queryable, sort_params)
180 | end
181 |
182 | # Helper function which handles addition of paginated query on top of
183 | # the sent queryable variable
184 | defp handle_sort(queryable, sort_params) do
185 | order = Map.get(sort_params, :order)
186 | field = Map.get(sort_params, :field)
187 | ci = Map.get(sort_params, :ci, false)
188 |
189 | handle_ordering(from(e in subquery(queryable)), field, order, ci)
190 | end
191 |
192 | # This is a helper macro to get case_insensitive query using fragments
193 | defmacrop case_insensitive(field) do
194 | quote do
195 | fragment("lower(?)", unquote(field))
196 | end
197 | end
198 |
199 | # Helper function that handles adding order_by to a query based on order type
200 | # case insensitivity and field
201 | defp handle_ordering(queryable, field, order, ci) do
202 | order_by_assoc(queryable, order, field, ci)
203 | end
204 |
205 | defp order_by_assoc(queryable, order_type, field, false) do
206 | order_by(queryable, [p0, ..., p2], [{^order_type, field(p2, ^field)}])
207 | end
208 |
209 | defp order_by_assoc(queryable, order_type, field, true) do
210 | order_by(queryable, [p0, ..., p2], [{^order_type, case_insensitive(field(p2, ^field))}])
211 | end
212 |
213 | # Helper function that validates the list of params based on
214 | # @expected_keys list
215 | defp validate_params(params) do
216 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
217 |
218 | case Enum.filter(key_validations, &(&1 == :error)) do
219 | [] -> :ok
220 | _ -> raise @err_msg <> missing_keys(key_validations)
221 | end
222 | end
223 |
224 | # Helper function used to build error message using missing keys
225 | defp missing_keys(key_validations) do
226 | key_validations
227 | |> Enum.with_index()
228 | |> Enum.filter(fn {v, _i} -> v == :error end)
229 | |> Enum.map_join(", ", fn {_v, i} ->
230 | @expected_keys
231 | |> Enum.at(i)
232 | |> to_string()
233 | end)
234 | end
235 |
236 | @doc """
237 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
238 |
239 | This function ensures that params for each field have keys `assoc`, `order1`
240 | which are essential for running this hook module.
241 |
242 | ## Examples
243 | iex> alias Rummage.Ecto.CustomHook.SimpleSort
244 | iex> SimpleSort.format_params(Parent, %{}, [])
245 | %{order: :asc}
246 | """
247 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
248 | def format_params(_queryable, sort_params, _opts) do
249 | sort_params
250 | |> Map.put_new(:order, :asc)
251 | end
252 | end
253 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"},
3 | "certifi": {:hex, :certifi, "2.9.0", "6f2a475689dd47f19fb74334859d460a2dc4e3252a3324bd2111b8f0429e7e21", [:rebar3], [], "hexpm", "266da46bdb06d6c6d35fde799bcb28d36d985d424ad7c08b5bb48f5b5cdd4641"},
4 | "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
5 | "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"},
6 | "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"},
7 | "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"},
8 | "earmark": {:hex, :earmark, "1.4.9", "837e4c1c5302b3135e9955f2bbf52c6c52e950c383983942b68b03909356c0d9", [:mix], [{:earmark_parser, ">= 1.4.9", [hex: :earmark_parser, repo: "hexpm", optional: false]}], "hexpm", "0d72df7d13a3dc8422882bed5263fdec5a773f56f7baeb02379361cb9e5b0d8e"},
9 | "earmark_parser": {:hex, :earmark_parser, "1.4.26", "f4291134583f373c7d8755566122908eb9662df4c4b63caa66a0eabe06569b0a", [:mix], [], "hexpm", "48d460899f8a0c52c5470676611c01f64f3337bad0b26ddab43648428d94aabc"},
10 | "ecto": {:hex, :ecto, "3.8.4", "e06b8b87e62b27fea17fd2ff6041572ddd10339fd16cdf58446e402c6c90a74b", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f9244288b8d42db40515463a008cf3f4e0e564bb9c249fe87bf28a6d79fe82d4"},
11 | "ecto_sql": {:hex, :ecto_sql, "3.8.3", "a7d22c624202546a39d615ed7a6b784580391e65723f2d24f65941b4dd73d471", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.8.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.15.0 or ~> 0.16.0 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", "348cb17fb9e6daf6f251a87049eafcb57805e2892e5e6a0f5dea0985d367329b"},
12 | "esqlite": {:hex, :esqlite, "0.2.3", "1a8b60877fdd3d50a8a84b342db04032c0231cc27ecff4ddd0d934485d4c0cd5", [], [], "hexpm"},
13 | "ex_doc": {:hex, :ex_doc, "0.28.4", "001a0ea6beac2f810f1abc3dbf4b123e9593eaa5f00dd13ded024eae7c523298", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bf85d003dd34911d89c8ddb8bda1a958af3471a274a4c2150a9c01c78ac3f8ed"},
14 | "excoveralls": {:hex, :excoveralls, "0.14.5", "5c685449596e962c779adc8f4fb0b4de3a5b291c6121097572a3aa5400c386d3", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e9b4a9bf10e9a6e48b94159e13b4b8a1b05400f17ac16cc363ed8734f26e1f4e"},
15 | "exjsx": {:hex, :exjsx, "4.0.0", "60548841e0212df401e38e63c0078ec57b33e7ea49b032c796ccad8cde794b5c", [:mix], [{:jsx, "~> 2.8.0", [hex: :jsx, repo: "hexpm", optional: false]}], "hexpm"},
16 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
17 | "hackney": {:hex, :hackney, "1.18.1", "f48bf88f521f2a229fc7bae88cf4f85adc9cd9bcf23b5dc8eb6a1788c662c4f6", [:rebar3], [{:certifi, "~>2.9.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a4ecdaff44297e9b5894ae499e9a070ea1888c84afdd1fd9b7b2bc384950128e"},
18 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
19 | "inch_ex": {:git, "https://github.com/rrrene/inch_ex.git", "d37c3cd41ceda869696499569547d6f9a416751c", []},
20 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"},
21 | "jsx": {:hex, :jsx, "2.8.3", "a05252d381885240744d955fbe3cf810504eb2567164824e19303ea59eef62cf", [:mix, :rebar3], [], "hexpm"},
22 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"},
23 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"},
24 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"},
25 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
26 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
27 | "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
28 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"},
29 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
30 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
31 | "postgrex": {:hex, :postgrex, "0.16.3", "fac79a81a9a234b11c44235a4494d8565303fa4b9147acf57e48978a074971db", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "aeaae1d2d1322da4e5fe90d241b0a564ce03a3add09d7270fb85362166194590"},
32 | "sbroker": {:hex, :sbroker, "1.0.0", "28ff1b5e58887c5098539f236307b36fe1d3edaa2acff9d6a3d17c2dcafebbd0", [], [], "hexpm"},
33 | "sqlite_ecto2": {:hex, :sqlite_ecto2, "2.2.2", "7a3e5c0521e1cb6e30a4907ba4d952b97db9b2ab5d1a4806ceeb66a10b23ba65", [], [{:connection, "~> 1.0.3", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 1.1.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.2", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 2.2.2", [hex: :ecto, repo: "hexpm", optional: false]}, {:esqlite, "~> 0.2.3", [hex: :esqlite, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.13.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:sbroker, "~> 1.0", [hex: :sbroker, repo: "hexpm", optional: false]}, {:sqlitex, "~> 1.3.2 or ~> 1.4", [hex: :sqlitex, repo: "hexpm", optional: false]}], "hexpm"},
34 | "sqlitex": {:hex, :sqlitex, "1.3.3", "3aac5fd702be346f71d9de6e01702c9954484cd0971aa443490bb3bde045d919", [], [{:decimal, "~> 1.1", [hex: :decimal, repo: "hexpm", optional: false]}, {:esqlite, "~> 0.2.3", [hex: :esqlite, repo: "hexpm", optional: false]}], "hexpm"},
35 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"},
36 | "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
37 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"},
38 | }
39 |
--------------------------------------------------------------------------------
/lib/rummage_ecto.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto do
2 | @moduledoc """
3 | Rummage.Ecto is a light weight, but powerful framework that can be used to alter Ecto
4 | queries with Search, Sort and Paginate operations.
5 |
6 | It accomplishes the above operations by using `Hooks`, which are modules that
7 | implement `Rummage.Ecto.Hook` behavior. Each operation: Search, Sort and Paginate
8 | have their hooks defined in Rummage. By doing this, we have made rummage completely
9 | configurable. For example, if you don't like one of the implementations of Rummage,
10 | but like the other two, you can configure Rummage to not use it.
11 |
12 | If you want to check a sample application that uses Rummage, please check
13 | [this link](https://github.com/aditya7iyengar/rummage_ecto_example).
14 |
15 | Usage:
16 |
17 | ```elixir
18 | defmodule Rummage.Ecto.Category do
19 | use Ecto.Schema
20 | use Rummage.Ecto
21 |
22 | schema "categories" do
23 | field :name, :string
24 | end
25 |
26 | end
27 | ```
28 |
29 | This allows you to do:
30 |
31 | iex> rummage = %{search: %{name: %{assoc: [], search_type: :ilike, search_term: "field_!"}}}
32 | iex> {queryable, rummage} = Rummage.Ecto.Category.rummageq(Rummage.Ecto.Category, rummage)
33 | iex> queryable
34 | #Ecto.Query
35 | iex> rummage
36 | %{search: %{name: %{assoc: [], search_expr: :where,
37 | search_term: "field_!", search_type: :ilike}}}
38 |
39 | This also allows you to do call `rummage/2` without a `queryable` which defaults
40 | to the module calling `rummage`, which is `Rummage.Ecto.Category` in this case:
41 |
42 | iex> rummage = %{search: %{name: %{assoc: [], search_type: :ilike, search_term: "field_!"}}}
43 | iex> {queryable, rummage} = Rummage.Ecto.Category.rummage(rummage)
44 | iex> queryable
45 | #Ecto.Query
46 | iex> rummage
47 | %{search: %{name: %{assoc: [], search_expr: :where,
48 | search_term: "field_!", search_type: :ilike}}}
49 |
50 | """
51 |
52 | alias Rummage.Ecto.Config, as: RConfig
53 |
54 | @doc """
55 | This is the function which calls to the `Rummage` `hooks`.
56 | It is the entry-point to `Rummage.Ecto`.
57 |
58 | This function takes in a `queryable`, a `rummage` map and an `opts` keyword.
59 | Recognized `opts` keys are:
60 |
61 | * `repo`: If you haven't set up a `repo` at the config level or `__using__`
62 | level, this a way of passing `repo` to `rummage`. If you have
63 | already configured your app to use a default `repo` and/or
64 | specified the `repo` at `__using__` level, this is a way of
65 | overriding those defaults.
66 |
67 | * `per_page`: If you haven't set up a `per_page` at the config level or `__using__`
68 | level, this a way of passing `per_page` to `rummage`. If you have
69 | already configured your app to use a default `per_page` and/or
70 | specified the `per_page` at `__using__` level, this is a way of
71 | overriding those defaults.
72 |
73 | * `search`: If you haven't set up a `search` at the config level or `__using__`
74 | level, this a way of passing `search` to `rummage`. If you have
75 | already configured your app to use a default `search` and/or
76 | specified the `search` at `__using__` level, this is a way of
77 | overriding those defaults. This can be used to override native
78 | `Rummage.Ecto.Hook.Search` to a custom hook.
79 |
80 | * `sort`: If you haven't set up a `sort` at the config level or `__using__`
81 | level, this a way of passing `sort` to `rummage`. If you have
82 | already configured your app to use a default `sort` and/or
83 | specified the `sort` at `__using__` level, this is a way of
84 | overriding those defaults. This can be used to override native
85 | `Rummage.Ecto.Hook.Sort` to a custom hook.
86 |
87 | * `paginate`: If you haven't set up a `paginate` at the config level or `__using__`
88 | level, this a way of passing `paginate` to `rummage`. If you have
89 | already configured your app to use a default `paginate` and/or
90 | specified the `paginate` at `__using__` level, this is a way of
91 | overriding those defaults. This can be used to override native
92 | `Rummage.Ecto.Hook.Paginate` to a custom hook.
93 |
94 |
95 | ## Examples
96 | When no hook params are given, it just returns the queryable and the params:
97 |
98 | iex> import Rummage.Ecto
99 | iex> alias Rummage.Ecto.Product
100 | iex> rummage = %{}
101 | iex> {queryable, rummage} = rummage(Product, rummage)
102 | iex> rummage
103 | %{}
104 | iex> queryable
105 | Rummage.Ecto.Product
106 |
107 | When `nil` hook module is given, it just returns the queryable and the params:
108 |
109 | iex> import Rummage.Ecto
110 | iex> alias Rummage.Ecto.Product
111 | iex> rummage = %{paginate: %{page: 1}}
112 | iex> {queryable, rummage} = rummage(Product, rummage, paginate: nil)
113 | iex> rummage
114 | %{paginate: %{page: 1}}
115 | iex> queryable
116 | Rummage.Ecto.Product
117 |
118 |
119 | When a hook param is given, with hook module it just returns the
120 | `queryable` and the `params`:
121 |
122 | iex> import Rummage.Ecto
123 | iex> alias Rummage.Ecto.Product
124 | iex> rummage = %{paginate: %{page: 1}}
125 | iex> repo = Rummage.Ecto.Repo
126 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
127 | iex> opts = [paginate: Rummage.Ecto.Hook.Paginate, repo: repo]
128 | iex> {queryable, rummage} = rummage(Product, rummage, opts)
129 | iex> rummage
130 | %{paginate: %{max_page: 0, page: 1, per_page: 10, total_count: 0}}
131 | iex> queryable
132 | #Ecto.Query
133 |
134 |
135 | When a hook is given, with correspondng params, it updates and returns the
136 | `queryable` and the `params` accordingly:
137 |
138 | iex> import Rummage.Ecto
139 | iex> alias Rummage.Ecto.Product
140 | iex> rummage = %{paginate: %{per_page: 1, page: 1}}
141 | iex> repo = Rummage.Ecto.Repo
142 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
143 | iex> repo.insert!(%Product{name: "name", internal_code: "100"})
144 | iex> repo.insert!(%Product{name: "name2", internal_code: "101"})
145 | iex> opts = [paginate: Rummage.Ecto.Hook.Paginate,
146 | ...> repo: repo]
147 | iex> {queryable, rummage} = rummage(Product, rummage, opts)
148 | iex> rummage
149 | %{paginate: %{max_page: 2, page: 1, per_page: 1, total_count: 2}}
150 | iex> queryable
151 | #Ecto.Query
152 |
153 | """
154 | @spec rummage(Ecto.Query.t(), map(), Keyword.t()) :: {Ecto.Query.t(), map()}
155 | def rummage(queryable, rummage, opts \\ []) do
156 | hooks = [
157 | search: Keyword.get(opts, :search, RConfig.search()),
158 | sort: Keyword.get(opts, :sort, RConfig.sort()),
159 | paginate: Keyword.get(opts, :paginate, RConfig.paginate())
160 | ]
161 |
162 | rummage = Enum.reduce(hooks, rummage, &format_hook_params(&1, &2, queryable, opts))
163 |
164 | {Enum.reduce(hooks, queryable, &run_hook(&1, &2, rummage)), rummage}
165 | end
166 |
167 | defp format_hook_params({_, nil}, rummage, _, _), do: rummage
168 |
169 | defp format_hook_params({type, hook_mod}, rummage, queryable, opts) do
170 | case Map.get(rummage, type) do
171 | nil -> rummage
172 | params -> Map.put(rummage, type, apply(hook_mod, :format_params, [queryable, params, opts]))
173 | end
174 | end
175 |
176 | defp run_hook({_, nil}, queryable, _), do: queryable
177 |
178 | defp run_hook({type, hook_mod}, queryable, rummage) do
179 | case Map.get(rummage, type) do
180 | nil -> queryable
181 | params -> apply(hook_mod, :run, [queryable, params])
182 | end
183 | end
184 |
185 | @doc """
186 | This macro allows an `Ecto.Schema` to leverage rummage's features with
187 | ease. This macro defines a function `rummage/2` which can be called on
188 | the Module `using` this which delegates to `Rummage.Ecto.rummage/3`, but
189 | before doing that it resolves the options with default values for `repo`,
190 | `search` hook, `sort` hook and `paginate` hook. If `rummage/2` is called with
191 | those options in form of keys given to the last argument `opts`, then it
192 | sets those keys to what's given else it delegates it to the defaults
193 | specficied by `__using__` macro. If no defaults are specified, then it
194 | further delegates it to configurations.
195 |
196 | The function `rummage/2` takes in `rummage params` and `opts` and calls
197 | `Rummage.Ecto.rummage/3` with whatever schema is calling it as the
198 | `queryable`.
199 |
200 | This macro also defines a function `rummageq/3` where q implies `queryable`.
201 | Therefore this function can take a `queryable` as the first argument.
202 |
203 | In this way this macro makes it very easy to use `Rummage.Ecto`.
204 |
205 | ## Usage:
206 |
207 | ### Basic Usage where a default repo is specified as options to the macro.
208 | ```elixir
209 | defmodule MyApp.MySchema do
210 | use Ecto.Schema
211 | use Rummage.Ecto, repo: MyApp.Repo, per_page: 10
212 | end
213 | ```
214 |
215 | ### Advanced Usage where search and sort hooks are overrident for this module.
216 | ```elixir
217 | defmodule MyApp.MySchema do
218 | use Ecto.Schema
219 | use Rummage.Ecto, repo: MyApp.Repo, per_page: 10,
220 | search: CustomSearchModule,
221 | sort: CustomSortModule
222 | end
223 |
224 | This allows you do just do `MyApp.Schema.rummage(rummage_params)` with specific
225 | `rummage_params` and add `Rummage.Ecto`'s power to your schema.
226 | ```
227 |
228 | """
229 | defmacro __using__(opts) do
230 | quote do
231 | alias Rummage.Ecto.Config, as: RConfig
232 |
233 | def rummage(rummage, opts \\ []) do
234 | Rummage.Ecto.rummage(__MODULE__, rummage, uniq_merge(opts, defaults()))
235 | end
236 |
237 | def rummageq(queryable, rummage, opts \\ []) do
238 | Rummage.Ecto.rummage(queryable, rummage, uniq_merge(opts, defaults()))
239 | end
240 |
241 | defp defaults() do
242 | keys = ~w{repo per_page search sort paginate}a
243 | Enum.map(keys, &get_defs/1)
244 | end
245 |
246 | defp get_defs(key) do
247 | app = Application.get_application(__MODULE__)
248 | {key, Keyword.get(unquote(opts), key, apply(RConfig, key, [app]))}
249 | end
250 |
251 | defp uniq_merge(keyword1, keyword2) do
252 | keyword2
253 | |> Keyword.merge(keyword1)
254 | |> Keyword.new()
255 | end
256 | end
257 | end
258 | end
259 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/custom_hooks/simple_search.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.SimpleSearch do
2 | @moduledoc """
3 | `Rummage.Ecto.CustomHook.SimpleSearch` is an example of a Custom Hook that
4 | comes with `Rummage.Ecto`.
5 |
6 | This module provides a operations that can add searching functionality to
7 | a pipeline of `Ecto` queries. This module works by taking fields, and
8 | `search_type` and `search_term`.
9 |
10 | This module doesn't support associations and hence is a simple alternative
11 | to Rummage's default search hook.
12 |
13 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
14 | This module `uses` `Rummage.Ecto.Hook`.
15 |
16 | _____________________________________________________________________________
17 |
18 | # ABOUT:
19 |
20 | ## Arguments:
21 |
22 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
23 | `search_params` (a `Map`). The map should be in the format:
24 | `%{field_name: %{search_term: true, search_type: :eq}}`
25 |
26 | Details:
27 |
28 | * `field_name`: The field name to search by.
29 | * `search_term`: Term to compare the `field_name` against.
30 | * `search_type`: Determines the kind of search to perform. If `:eq`, it
31 | expects the `field_name`'s value to be equal to `search_term`,
32 | If `lt`, it expects it to be less than `search_term`.
33 | To see all the `search_type`s, check
34 | `Rummage.Ecto.Services.BuildSearchQuery`
35 | * `search_expr`: This is optional. Defaults to `:where`. This is the way the
36 | search expression is appended to the existing query.
37 | To see all the `search_expr`s, check
38 | `Rummage.Ecto.Services.BuildSearchQuery`
39 |
40 |
41 | For example, if we want to search products with `available` = `true`, we would
42 | do the following:
43 |
44 | ```elixir
45 | Rummage.Ecto.CustomHook.SimpleSearch.run(Product, %{available:
46 | %{search_type: :eq,
47 | search_term: true}})
48 | ```
49 |
50 | This can be used for a search with multiple fields as well. Say, we want to
51 | search for products that are `available`, but have a price less than `10.0`.
52 |
53 | ```elixir
54 | Rummage.Ecto.CustomHook.SimpleSearch.run(Product,
55 | %{available: %{search_type: :eq,
56 | search_term: true},
57 | %{price: %{search_type: :lt,
58 | search_term: 10.0}})
59 | ```
60 |
61 | ## Assoications:
62 |
63 | This module doesn't support assocations.
64 |
65 | ____________________________________________________________________________
66 |
67 | # ASSUMPTIONS/NOTES:
68 |
69 | * This Hook assumes that the searched field is a part of the schema passed
70 | as the `queryable`.
71 | * This Hook has the default `search_type` of `:eq`.
72 | * This Hook has the default `search_expr` of `:where`.
73 |
74 | ____________________________________________________________________________
75 |
76 | # USAGE:
77 |
78 | For a regular search:
79 |
80 | This returns a `queryable` which upon running will give a list of `Parent`(s)
81 | searched by ascending `field_1`
82 |
83 | ```elixir
84 | alias Rummage.Ecto.CustomHook.SimpleSearch
85 |
86 | searched_queryable = SimpleSearch.run(Parent,
87 | %{field_1: %{search_type: :like, search_term: "field_!"}}})
88 |
89 | ```
90 |
91 | For a case-insensitive search:
92 |
93 | This returns a `queryable` which upon running will give a list of `Parent`(s)
94 | searched by ascending case insensitive `field_1`.
95 |
96 | Keep in mind that `case_insensitive` can only be called for `text` fields
97 |
98 | ```elixir
99 | alias Rummage.Ecto.CustomHook.SimpleSearch
100 |
101 | searched_queryable = SimpleSearch.run(Parent,
102 | %{field_1: %{ search_type: "ilike", search_term: "field_!"}}})
103 |
104 | ```
105 |
106 | There are many other `search_types`. Check out
107 | `Rummage.Ecto.Services.BuildSearchQuery` docs to explore more `search_types`.
108 |
109 | This module can be used by overriding the default module. This can be done
110 | in the following ways:
111 |
112 | In the `Rummage.Ecto` call:
113 | ```elixir
114 | Rummage.Ecto.rummage(queryable, rummage,
115 | search: Rummage.Ecto.CustomHook.SimpleSearch)
116 |
117 | or
118 |
119 | MySchema.rummage(rummage, search: Rummage.Ecto.CustomHook.SimpleSearch)
120 | ```
121 |
122 | OR
123 |
124 | Globally for all models in `config.exs`:
125 | ```elixir
126 | config :my_app,
127 | Rummage.Ecto,
128 | search: Rummage.Ecto.CustomHook.SimpleSearch
129 | ```
130 |
131 | OR
132 |
133 | When `using` Rummage.Ecto with an `Ecto.Schema`:
134 | ```elixir
135 | defmodule MySchema do
136 | use Rummage.Ecto, repo: SomeRepo,
137 | search: Rummage.Ecto.CustomHook.SimpleSearch
138 | end
139 | """
140 |
141 | use Rummage.Ecto.Hook
142 |
143 | import Ecto.Query
144 |
145 | @expected_keys ~w(search_type search_term)a
146 | @err_msg "Error in params, No values given for keys: "
147 |
148 | alias Rummage.Ecto.Services.BuildSearchQuery
149 |
150 | @doc ~S"""
151 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
152 |
153 | Builds a search `Ecto.Query.t` on top of a given `Ecto.Query.t` variable
154 | with given `params`.
155 |
156 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
157 | implements `Ecto.Queryable`
158 |
159 | Params is a `Map`, keys of which are field names which will be searched for and
160 | value corresponding to that key is a list of params for that key, which
161 | should include the keys: `#{Enum.join(@expected_keys, ", ")}`.
162 |
163 | This function expects a `search_expr`, `search_type`.
164 | The `search_term` is what the `field`
165 | will be matched to based on the `search_type` and `search_expr`.
166 |
167 | If no `search_expr` is given, it defaults to `where`.
168 |
169 | For all `search_exprs`, refer to `Rummage.Ecto.Services.BuildSearchQuery`.
170 |
171 | For all `search_types`, refer to `Rummage.Ecto.Services.BuildSearchQuery`.
172 |
173 | If an expected key isn't given, a `Runtime Error` is raised.
174 |
175 | NOTE:This hook isn't responsible for doing type validations. That's the
176 | responsibility of the user sending `search_term` and `search_type`.
177 | ## Examples
178 | When search_params are empty, it simply returns the same `queryable`:
179 |
180 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
181 | iex> import Ecto.Query
182 | iex> SimpleSearch.run(Parent, %{})
183 | Parent
184 |
185 | When a non-empty map is passed as a field `params`, but with a missing key:
186 |
187 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
188 | iex> import Ecto.Query
189 | iex> SimpleSearch.run(Parent, %{field: %{search_type: :eq}})
190 | ** (RuntimeError) Error in params, No values given for keys: search_term
191 |
192 | When a valid map of params is passed with an `Ecto.Schema` module:
193 |
194 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
195 | iex> import Ecto.Query
196 | iex> search_params = %{field1: %{
197 | ...> search_type: :like,
198 | ...> search_term: "field1",
199 | ...> search_expr: :where}}
200 | iex> SimpleSearch.run(Rummage.Ecto.Product, search_params)
201 | #Ecto.Query
202 |
203 | When a valid map of params is passed with an `Ecto.Query.t`:
204 |
205 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
206 | iex> import Ecto.Query
207 | iex> search_params = %{field1: %{
208 | ...> search_type: :like,
209 | ...> search_term: "field1",
210 | ...> search_expr: :where}}
211 | iex> query = from p0 in "products"
212 | iex> SimpleSearch.run(query, search_params)
213 | #Ecto.Query
214 |
215 | When a valid map of params is passed with an `Ecto.Query.t` and `:on_where`:
216 |
217 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
218 | iex> import Ecto.Query
219 | iex> search_params = %{field1: %{
220 | ...> search_type: :like,
221 | ...> search_term: "field1",
222 | ...> search_expr: :or_where}}
223 | iex> query = from p0 in "products"
224 | iex> SimpleSearch.run(query, search_params)
225 | #Ecto.Query
226 |
227 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
228 | a boolean param
229 |
230 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
231 | iex> import Ecto.Query
232 | iex> search_params = %{available: %{
233 | ...> search_type: :eq,
234 | ...> search_term: true,
235 | ...> search_expr: :where}}
236 | iex> query = from p0 in "products"
237 | iex> SimpleSearch.run(query, search_params)
238 | #Ecto.Query
239 |
240 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
241 | a float param
242 |
243 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
244 | iex> import Ecto.Query
245 | iex> search_params = %{price: %{
246 | ...> search_type: :gteq,
247 | ...> search_term: 10.0,
248 | ...> search_expr: :where}}
249 | iex> query = from p0 in "products"
250 | iex> SimpleSearch.run(query, search_params)
251 | #Ecto.Query= ^10.0>
252 |
253 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
254 | a boolean param, but with a wrong `search_type`.
255 | NOTE: This doesn't validate the search_type of search_term
256 |
257 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
258 | iex> import Ecto.Query
259 | iex> search_params = %{available: %{
260 | ...> search_type: :ilike,
261 | ...> search_term: true,
262 | ...> search_expr: :where}}
263 | iex> query = from p0 in "products"
264 | iex> SimpleSearch.run(query, search_params)
265 | ** (ArgumentError) argument error
266 |
267 | """
268 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
269 | def run(q, s), do: handle_search(q, s)
270 |
271 | # Helper function which handles addition of search query on top of
272 | # the sent queryable variable, for all search fields.
273 | defp handle_search(queryable, search_params) do
274 | search_params
275 | |> Map.to_list()
276 | |> Enum.reduce(queryable, &search_queryable(&1, &2))
277 | end
278 |
279 | # Helper function which handles addition of search query on top of
280 | # the sent queryable variable, for ONE search fields.
281 | # This delegates the query building to `BuildSearchQuery` module
282 | defp search_queryable(param, queryable) do
283 | field = elem(param, 0)
284 | field_params = elem(param, 1)
285 |
286 | :ok = validate_params(field_params)
287 |
288 | search_type = Map.get(field_params, :search_type)
289 | search_term = Map.get(field_params, :search_term)
290 | search_expr = Map.get(field_params, :search_expr, :where)
291 |
292 | BuildSearchQuery.run(
293 | from(e in subquery(queryable)),
294 | field,
295 | {search_expr, search_type},
296 | search_term
297 | )
298 | end
299 |
300 | # Helper function that validates the list of params based on
301 | # @expected_keys list
302 | defp validate_params(params) do
303 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
304 |
305 | case Enum.filter(key_validations, &(&1 == :error)) do
306 | [] -> :ok
307 | _ -> raise @err_msg <> missing_keys(key_validations)
308 | end
309 | end
310 |
311 | # Helper function used to build error message using missing keys
312 | defp missing_keys(key_validations) do
313 | key_validations
314 | |> Enum.with_index()
315 | |> Enum.filter(fn {v, _i} -> v == :error end)
316 | |> Enum.map_join(", ", fn {_v, i} ->
317 | @expected_keys
318 | |> Enum.at(i)
319 | |> to_string()
320 | end)
321 | end
322 |
323 | @doc """
324 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
325 |
326 | This function ensures that params for each field have keys `assoc`, `search_type` and
327 | `search_expr` which are essential for running this hook module.
328 |
329 | ## Examples
330 | iex> alias Rummage.Ecto.CustomHook.SimpleSearch
331 | iex> SimpleSearch.format_params(Parent, %{field: %{}}, [])
332 | %{field: %{search_expr: :where, search_type: :eq}}
333 | """
334 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
335 | def format_params(_queryable, search_params, _opts) do
336 | search_params
337 | |> Map.to_list()
338 | |> Enum.map(&put_keys/1)
339 | |> Enum.into(%{})
340 | end
341 |
342 | defp put_keys({field, field_params}) do
343 | field_params =
344 | field_params
345 | |> Map.put_new(:search_type, :eq)
346 | |> Map.put_new(:search_expr, :where)
347 |
348 | {field, field_params}
349 | end
350 | end
351 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/sort.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.Sort do
2 | @moduledoc """
3 | `Rummage.Ecto.Hook.Sort` is the default sort hook that comes with
4 | `Rummage.Ecto`.
5 |
6 | This module provides a operations that can add sorting functionality to
7 | a pipeline of `Ecto` queries. This module works by taking the `field` that should
8 | be used to `order_by`, `order` which can be `asc` or `desc` and `assoc`,
9 | which is a keyword list of assocations associated with those `fields`.
10 |
11 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
12 | This module `uses` `Rummage.Ecto.Hook`.
13 |
14 | _____________________________________________________________________________
15 |
16 | # ABOUT:
17 |
18 | ## Arguments:
19 |
20 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
21 | `sort_params` (a `Map`). The map should be in the format:
22 | `%{field: :field_name, assoc: [], order: :asc}`
23 |
24 | Details:
25 |
26 | * `field`: The field name (atom) to sorted by.
27 | * `assoc`: List of associations in the sort.
28 | * `order`: Specifies the type of order `asc` or `desc`.
29 | * `ci` : Case Insensitivity. Defaults to `false`
30 |
31 |
32 | For example, if we want to sort products with descending `price`, we would
33 | do the following:
34 |
35 | ```elixir
36 | Rummage.Ecto.Hook.Sort.run(Product, %{field: :price,
37 | assoc: [], order: :desc})
38 | ```
39 |
40 | ## Assoications:
41 |
42 | Assocaitions can be given to this module's run function as a key corresponding
43 | to params associated with a field. For example, if we want to sort products
44 | that belong to a category by ascending category_name, we would do the
45 | following:
46 |
47 | ```elixir
48 | params = %{field: :category_name, assoc: [inner: :category],
49 | order: :asc}
50 |
51 | Rummage.Ecto.Hook.Sort.run(Product, params)
52 | ```
53 |
54 | The above operation will return an `Ecto.Query.t` struct which represents
55 | a query equivalent to:
56 |
57 | ```elixir
58 | from p0 in Product
59 | |> join(:inner, :category)
60 | |> order_by([p, c], {asc, c.category_name})
61 | ```
62 |
63 | ____________________________________________________________________________
64 |
65 | # ASSUMPTIONS/NOTES:
66 |
67 | * This Hook has the default `order` of `:asc`.
68 | * This Hook has the default `assoc` of `[]`.
69 | * This Hook assumes that the field passed is a field on the `Ecto.Schema`
70 | that corresponds to the last association in the `assoc` list or the `Ecto.Schema`
71 | that corresponds to the `from` in `queryable`, if `assoc` is an empty list.
72 |
73 | NOTE: It is adviced to not use multiple associated sorts in one operation
74 | as `assoc` still has some minor bugs when used with multiple sorts. If you
75 | need to use two sorts with associations, I would pipe the call to another
76 | sort operation:
77 |
78 | ```elixir
79 | Sort.run(queryable, params1}
80 | |> Sort.run(%{field2: params2}
81 | ```
82 |
83 | ____________________________________________________________________________
84 |
85 | # USAGE:
86 |
87 | For a regular sort:
88 |
89 | This returns a `queryable` which upon running will give a list of `Parent`(s)
90 | sorted by ascending `field_1`
91 |
92 | ```elixir
93 | alias Rummage.Ecto.Hook.Sort
94 |
95 | sorted_queryable = Sort.run(Parent, %{assoc: [], field: :name, order: :asc}})
96 | ```
97 |
98 | For a case-insensitive sort:
99 |
100 | This returns a `queryable` which upon running will give a list of `Parent`(s)
101 | sorted by ascending case insensitive `field_1`.
102 |
103 | Keep in mind that `case_insensitive` can only be called for `text` fields
104 |
105 | ```elixir
106 | alias Rummage.Ecto.Hook.Sort
107 |
108 | sorted_queryable = Sort.run(Parent, %{assoc: [], field: :name, order: :asc, ci: true}})
109 | ```
110 |
111 |
112 | This module can be overridden with a custom module while using `Rummage.Ecto`
113 | in `Ecto` struct module.
114 |
115 | In the `Ecto` module:
116 | ```elixir
117 | Rummage.Ecto.rummage(queryable, rummage, sort: CustomHook)
118 | ```
119 |
120 | OR
121 |
122 | Globally for all models in `config.exs`:
123 | ```elixir
124 | config :rummage_ecto,
125 | Rummage.Ecto,
126 | sort: CustomHook
127 | ```
128 |
129 | The `CustomHook` must use `Rummage.Ecto.Hook`. For examples of `CustomHook`,
130 | check out some `custom_hooks` that are shipped with `Rummage.Ecto`:
131 | `Rummage.Ecto.CustomHook.SimpleSearch`, `Rummage.Ecto.CustomHook.SimpleSort`,
132 | `Rummage.Ecto.CustomHook.SimplePaginate`
133 |
134 | """
135 |
136 | use Rummage.Ecto.Hook
137 |
138 | import Ecto.Query
139 |
140 | @expected_keys ~w{field order assoc}a
141 | @err_msg ~s{Error in params, No values given for keys: }
142 |
143 | # Only for Postgres (only one interpolation is supported)
144 | # TODO: Fix this once Ecto 3.0 comes out with `unsafe_fragment`
145 | @supported_fragments_one [
146 | "date_part('day', ?)",
147 | "date_part('month', ?)",
148 | "date_part('year', ?)",
149 | "date_part('hour', ?)",
150 | "lower(?)",
151 | "upper(?)"
152 | ]
153 |
154 | @supported_fragments_two ["concat(?, ?)", "coalesce(?, ?)"]
155 |
156 | @doc """
157 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
158 |
159 | Builds a sort `Ecto.Query.t` on top of the given `Ecto.Queryable` variable
160 | using given `params`.
161 |
162 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
163 | implements `Ecto.Queryable`
164 |
165 | Params is a `Map` which is expected to have the keys `#{Enum.join(@expected_keys, ", ")}`.
166 |
167 | This funciton expects a `field` atom, `order` which can be `asc` or `desc`,
168 | `ci` which is a boolean indicating the case-insensitivity and `assoc` which
169 | is a list of associations with their join types.
170 |
171 | ## Examples
172 | When an empty map is passed as `params`:
173 |
174 | iex> alias Rummage.Ecto.Hook.Sort
175 | iex> Sort.run(Parent, %{})
176 | ** (RuntimeError) Error in params, No values given for keys: field, order, assoc
177 |
178 | When a non-empty map is passed as `params`, but with a missing key:
179 |
180 | iex> alias Rummage.Ecto.Hook.Sort
181 | iex> Sort.run(Parent, %{field: :name})
182 | ** (RuntimeError) Error in params, No values given for keys: order, assoc
183 |
184 | When a valid map of params is passed with an `Ecto.Schema` module:
185 |
186 | iex> alias Rummage.Ecto.Hook.Sort
187 | iex> Sort.run(Rummage.Ecto.Product, %{field: :name, assoc: [], order: :asc})
188 | #Ecto.Query
189 |
190 | When the `queryable` passed is an `Ecto.Query` variable:
191 |
192 | iex> alias Rummage.Ecto.Hook.Sort
193 | iex> import Ecto.Query
194 | iex> queryable = from u in "products"
195 | #Ecto.Query
196 | iex> Sort.run(queryable, %{field: :name, assoc: [], order: :asc})
197 | #Ecto.Query
198 |
199 |
200 | When the `queryable` passed is an `Ecto.Query` variable, with `desc` order:
201 |
202 | iex> alias Rummage.Ecto.Hook.Sort
203 | iex> import Ecto.Query
204 | iex> queryable = from u in "products"
205 | #Ecto.Query
206 | iex> Sort.run(queryable, %{field: :name, assoc: [], order: :desc})
207 | #Ecto.Query
208 |
209 | When the `queryable` passed is an `Ecto.Query` variable, with `ci` true:
210 |
211 | iex> alias Rummage.Ecto.Hook.Sort
212 | iex> import Ecto.Query
213 | iex> queryable = from u in "products"
214 | #Ecto.Query
215 | iex> Sort.run(queryable, %{field: :name, assoc: [], order: :asc, ci: true})
216 | #Ecto.Query
217 |
218 | When the `queryable` passed is an `Ecto.Query` variable, with associations:
219 |
220 | iex> alias Rummage.Ecto.Hook.Sort
221 | iex> import Ecto.Query
222 | iex> queryable = from u in "products"
223 | #Ecto.Query
224 | iex> Sort.run(queryable, %{field: :name, assoc: [inner: :category, left: :category], order: :asc})
225 | #Ecto.Query
226 |
227 | When the `queryable` passed is an `Ecto.Schema` module with associations,
228 | `desc` order and `ci` true:
229 |
230 | iex> alias Rummage.Ecto.Hook.Sort
231 | iex> queryable = Rummage.Ecto.Product
232 | Rummage.Ecto.Product
233 | iex> Sort.run(queryable, %{field: :name, assoc: [inner: :category], order: :desc, ci: true})
234 | #Ecto.Query
235 | """
236 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
237 | def run(queryable, sort_params) do
238 | :ok = validate_params(sort_params)
239 |
240 | handle_sort(queryable, sort_params)
241 | end
242 |
243 | # Helper function which handles addition of paginated query on top of
244 | # the sent queryable variable
245 | defp handle_sort(queryable, sort_params) do
246 | order = Map.get(sort_params, :order)
247 |
248 | field =
249 | sort_params
250 | |> Map.get(:field)
251 | |> resolve_field(queryable)
252 |
253 | assocs = Map.get(sort_params, :assoc)
254 | ci = Map.get(sort_params, :ci, false)
255 |
256 | assocs
257 | |> Enum.reduce(from(e in subquery(queryable)), &join_by_assoc(&1, &2))
258 | |> handle_ordering(field, order, ci)
259 | end
260 |
261 | # Helper function which handles associations in a query with a join
262 | # type.
263 | defp join_by_assoc({join, assoc}, query) do
264 | join(query, join, [..., p1], p2 in assoc(p1, ^assoc))
265 | end
266 |
267 | # This is a helper macro to get case_insensitive query using fragments
268 | defmacrop case_insensitive(field) do
269 | quote do
270 | fragment("lower(?)", unquote(field))
271 | end
272 | end
273 |
274 | # NOTE: These functions can be used in future for multiple sort fields that
275 | # are associated.
276 | # defp applied_associations(queryable) when is_atom(queryable), do: []
277 | # defp applied_associations(queryable), do: Enum.map(queryable.joins, & Atom.to_string(elem(&1.assoc, 1)))
278 |
279 | # Helper function that handles adding order_by to a query based on order type
280 | # case insensitivity and field
281 | defp handle_ordering(queryable, field, order, ci) do
282 | order_by_assoc(queryable, order, field, ci)
283 | end
284 |
285 | for fragment <- @supported_fragments_one do
286 | defp order_by_assoc(queryable, order_type, {:fragment, unquote(fragment), field}, false) do
287 | order_by(queryable, [p0, ..., p2], [
288 | {^order_type, fragment(unquote(fragment), field(p2, ^field))}
289 | ])
290 | end
291 |
292 | defp order_by_assoc(queryable, order_type, {:fragment, unquote(fragment), field}, true) do
293 | order_by(queryable, [p0, ..., p2], [
294 | {^order_type, case_insensitive(fragment(unquote(fragment), field(p2, ^field)))}
295 | ])
296 | end
297 | end
298 |
299 | for fragment <- @supported_fragments_two do
300 | defp order_by_assoc(
301 | queryable,
302 | order_type,
303 | {:fragment, unquote(fragment), field1, field2},
304 | false
305 | ) do
306 | order_by(queryable, [p0, ..., p2], [
307 | {^order_type, fragment(unquote(fragment), field(p2, ^field1), field(p2, ^field2))}
308 | ])
309 | end
310 |
311 | defp order_by_assoc(
312 | queryable,
313 | order_type,
314 | {:fragment, unquote(fragment), field1, field2},
315 | true
316 | ) do
317 | order_by(queryable, [p0, ..., p2], [
318 | {^order_type,
319 | case_insensitive(fragment(unquote(fragment), field(p2, ^field1), field(p2, ^field2)))}
320 | ])
321 | end
322 | end
323 |
324 | defp order_by_assoc(queryable, order_type, field, false) do
325 | order_by(queryable, [p0, ..., p2], [{^order_type, field(p2, ^field)}])
326 | end
327 |
328 | defp order_by_assoc(queryable, order_type, field, true) do
329 | order_by(queryable, [p0, ..., p2], [{^order_type, case_insensitive(field(p2, ^field))}])
330 | end
331 |
332 | # Helper function that validates the list of params based on
333 | # @expected_keys list
334 | defp validate_params(params) do
335 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
336 |
337 | case Enum.filter(key_validations, &(&1 == :error)) do
338 | [] -> :ok
339 | _ -> raise @err_msg <> missing_keys(key_validations)
340 | end
341 | end
342 |
343 | # Helper function used to build error message using missing keys
344 | defp missing_keys(key_validations) do
345 | key_validations
346 | |> Enum.with_index()
347 | |> Enum.filter(fn {v, _i} -> v == :error end)
348 | |> Enum.map_join(", ", fn {_v, i} ->
349 | @expected_keys
350 | |> Enum.at(i)
351 | |> to_string()
352 | end)
353 | end
354 |
355 | @doc """
356 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
357 |
358 | This function ensures that params for each field have keys `assoc`, `order1`
359 | which are essential for running this hook module.
360 |
361 | ## Examples
362 | iex> alias Rummage.Ecto.Hook.Sort
363 | iex> Sort.format_params(Parent, %{}, [])
364 | %{assoc: [], order: :asc}
365 | """
366 | @spec format_params(Ecto.Query.t(), map() | tuple(), keyword()) :: map()
367 | def format_params(queryable, {sort_scope, order}, opts) do
368 | module = get_module(queryable)
369 | name = :"__rummage_sort_#{sort_scope}"
370 |
371 | sort_params =
372 | case function_exported?(module, name, 1) do
373 | true -> apply(module, name, [order])
374 | _ -> raise "No scope `#{sort_scope}` of type sort defined in the #{module}"
375 | end
376 |
377 | format_params(queryable, sort_params, opts)
378 | end
379 |
380 | def format_params(_queryable, sort_params, _opts) do
381 | sort_params
382 | |> Map.put_new(:assoc, [])
383 | |> Map.put_new(:order, :asc)
384 | end
385 | end
386 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/custom_hooks/keyset_paginate.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.CustomHook.KeysetPaginate do
2 | @moduledoc """
3 | `Rummage.Ecto.CustomHook.KeysetPaginate` is an example of a Custom Hook that
4 | comes with `Rummage.Ecto`.
5 |
6 | This module uses `keyset` pagination to add a pagination query expression
7 | on top a given `Ecto.Queryable`.
8 |
9 | For more information on Keyset Pagination, check this
10 | [article](http://use-the-index-luke.com/no-offset)
11 |
12 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
13 | This module `uses` `Rummage.Ecto.Hook`.
14 |
15 | _____________________________________________________________________________
16 |
17 | # ABOUT:
18 |
19 | ## Arguments:
20 |
21 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
22 | `paginate_params` (a `Map`). The map should be in the format:
23 | `%{per_page: 10, page: 1, last_seen_pk: 10, pk: :id}`
24 |
25 | Details:
26 |
27 | * `per_page`: Specifies the entries in each page.
28 | * `page`: Specifies the `page` number.
29 | * `last_seen_pk`: Specifies the primary_key value of last_seen entry,
30 | This hook uses this entry instead of offset.
31 | * `pk`: Specifies what's the `primary_key` for the entries being paginated.
32 | Cannot be `nil`
33 |
34 |
35 | For example, if we want to paginate products (primary_key = :id), we would
36 | do the following:
37 |
38 | ```elixir
39 | Rummage.Ecto.CustomHook.KeysetPaginate.run(Product,
40 | %{per_page: 10, page: 1, last_seen_pk: 10, pk: :id})
41 | ```
42 |
43 | ## When to Use KeysetPaginate?
44 |
45 | - Keyset Pagination is mainly here to make pagination faster for complex
46 | pages. It is recommended that you use `Rummage.Ecto.Hook.Paginate` for a
47 | simple pagination operation, as this module has a lot of assumptions and
48 | it's own ordering on top of the given query.
49 |
50 | NOTE: __It is not recommended to use this with the native sort hook__
51 |
52 | _____________________________________________________________________________
53 |
54 | # ASSUMPTIONS/NOTES:
55 |
56 | * This Hook assumes that the querried `Ecto.Schema` has a `primary_key`.
57 | * This Hook also orders the query by ascending `primary_key`
58 |
59 | _____________________________________________________________________________
60 |
61 | # USAGE
62 |
63 | ```elixir
64 | alias Rummage.Ecto.CustomHook.KeysetPaginate
65 |
66 | queryable = KeysetPaginate.run(Parent,
67 | %{per_page: 10, page: 1, last_seen_pk: 10, pk: :id})
68 | ```
69 |
70 | This module can be used by overriding the default module. This can be done
71 | in the following ways:
72 |
73 | In the `Rummage.Ecto` call:
74 | ```elixir
75 | Rummage.Ecto.rummage(queryable, rummage,
76 | paginate: Rummage.Ecto.CustomHook.KeysetPaginate)
77 | ```
78 |
79 | OR
80 |
81 | Globally for all models in `config.exs`:
82 | ```elixir
83 | config :my_app,
84 | Rummage.Ecto,
85 | paginate: Rummage.Ecto.CustomHook.KeysetPaginate
86 | ```
87 |
88 | OR
89 |
90 | When `using` Rummage.Ecto with an `Ecto.Schema`:
91 | ```elixir
92 | defmodule MySchema do
93 | use Rummage.Ecto, repo: SomeRepo,
94 | paginate: Rummage.Ecto.CustomHook.KeysetPaginate
95 | end
96 | ```
97 | """
98 |
99 | use Rummage.Ecto.Hook
100 |
101 | import Ecto.Query
102 |
103 | @expected_keys ~w(per_page page last_seen_pk pk)a
104 | @err_msg "Error in params, No values given for keys: "
105 |
106 | @per_page 10
107 |
108 | @doc """
109 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
110 |
111 | Builds a paginate `Ecto.Query.t` on top of a given `Ecto.Query.t` variable
112 | with given `params`.
113 |
114 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
115 | implements `Ecto.Queryable`
116 |
117 | Params is a `Map` which is expected to have the keys `#{Enum.join(@expected_keys, ", ")}`.
118 |
119 | If an expected key isn't given, a `Runtime Error` is raised.
120 |
121 | ## Examples
122 | When an empty map is passed as `params`:
123 |
124 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
125 | iex> KeysetPaginate.run(Parent, %{})
126 | ** (RuntimeError) Error in params, No values given for keys: per_page, page, last_seen_pk, pk
127 |
128 | When a non-empty map is passed as `params`, but with a missing key:
129 |
130 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
131 | iex> KeysetPaginate.run(Parent, %{per_page: 10})
132 | ** (RuntimeError) Error in params, No values given for keys: page, last_seen_pk, pk
133 |
134 | When a valid map of params is passed with an `Ecto.Schema` module:
135 |
136 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
137 | iex> params = %{per_page: 10, page: 1, last_seen_pk: 0, pk: :id}
138 | iex> KeysetPaginate.run(Rummage.Ecto.Product, params)
139 | #Ecto.Query ^0, limit: ^10>
140 |
141 | When the `queryable` passed is an `Ecto.Query` variable:
142 |
143 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
144 | iex> import Ecto.Query
145 | iex> queryable = from u in "products"
146 | #Ecto.Query
147 | iex> params = %{per_page: 10, page: 1, last_seen_pk: 0, pk: :id}
148 | iex> KeysetPaginate.run(queryable, params)
149 | #Ecto.Query ^0, limit: ^10>
150 |
151 |
152 | More examples:
153 |
154 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
155 | iex> import Ecto.Query
156 | iex> params = %{per_page: 5, page: 5, last_seen_pk: 25, pk: :id}
157 | iex> queryable = from u in "products"
158 | #Ecto.Query
159 | iex> KeysetPaginate.run(queryable, params)
160 | #Ecto.Query ^25, limit: ^5>
161 |
162 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
163 | iex> import Ecto.Query
164 | iex> params = %{per_page: 5, page: 1, last_seen_pk: 0, pk: :some_id}
165 | iex> queryable = from u in "products"
166 | #Ecto.Query
167 | iex> KeysetPaginate.run(queryable, params)
168 | #Ecto.Query ^0, limit: ^5>
169 |
170 | """
171 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
172 | def run(queryable, paginate_params) do
173 | :ok = validate_params(paginate_params)
174 |
175 | handle_paginate(queryable, paginate_params)
176 | end
177 |
178 | # Helper function which handles addition of paginated query on top of
179 | # the sent queryable variable
180 | defp handle_paginate(queryable, paginate_params) do
181 | per_page = Map.get(paginate_params, :per_page)
182 | last_seen_pk = Map.get(paginate_params, :last_seen_pk)
183 | pk = Map.get(paginate_params, :pk)
184 |
185 | queryable
186 | |> where([p1, ...], field(p1, ^pk) > ^last_seen_pk)
187 | |> limit(^per_page)
188 | end
189 |
190 | # Helper function that validates the list of params based on
191 | # @expected_keys list
192 | defp validate_params(params) do
193 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
194 |
195 | case Enum.filter(key_validations, &(&1 == :error)) do
196 | [] -> :ok
197 | _ -> raise @err_msg <> missing_keys(key_validations)
198 | end
199 | end
200 |
201 | # Helper function used to build error message using missing keys
202 | defp missing_keys(key_validations) do
203 | key_validations
204 | |> Enum.with_index()
205 | |> Enum.filter(fn {v, _i} -> v == :error end)
206 | |> Enum.map_join(", ", fn {_v, i} ->
207 | @expected_keys
208 | |> Enum.at(i)
209 | |> to_string()
210 | end)
211 | end
212 |
213 | @doc """
214 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
215 |
216 | This function takes an `Ecto.Query.t` or `queryable`, `paginate_params` which
217 | will be passed to the `run/2` function, but also takes a list of options,
218 | `opts`.
219 |
220 | The function expects `opts` to include a `repo` key which points to the
221 | `Ecto.Repo` which will be used to calculate the `total_count` and `max_page`
222 | for this paginate hook module.
223 |
224 |
225 | ## Examples
226 |
227 | When a `repo` isn't passed in `opts` it gives an error:
228 |
229 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
230 | iex> alias Rummage.Ecto.Category
231 | iex> KeysetPaginate.format_params(Category, %{per_page: 1, page: 1}, [])
232 | ** (RuntimeError) Expected key `repo` in `opts`, got []
233 |
234 | When `paginate_params` given aren't valid, it uses defaults to populate params:
235 |
236 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
237 | iex> alias Rummage.Ecto.Category
238 | iex> Ecto.Adapters.SQL.Sandbox.checkout(Rummage.Ecto.Repo)
239 | iex> KeysetPaginate.format_params(Category, %{}, [repo: Rummage.Ecto.Repo])
240 | %{max_page: 0, page: 1, per_page: 10, total_count: 0, pk: :id,
241 | last_seen_pk: 0}
242 |
243 | When `paginate_params` and `opts` given are valid:
244 |
245 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
246 | iex> alias Rummage.Ecto.Category
247 | iex> paginate_params = %{
248 | ...> per_page: 1,
249 | ...> page: 1
250 | ...> }
251 | iex> repo = Rummage.Ecto.Repo
252 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
253 | iex> KeysetPaginate.format_params(Category, paginate_params, [repo: repo])
254 | %{max_page: 0, last_seen_pk: 0, page: 1,
255 | per_page: 1, total_count: 0, pk: :id}
256 |
257 | When `paginate_params` and `opts` given are valid:
258 |
259 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
260 | iex> alias Rummage.Ecto.Category
261 | iex> paginate_params = %{
262 | ...> per_page: 1,
263 | ...> page: 1
264 | ...> }
265 | iex> repo = Rummage.Ecto.Repo
266 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
267 | iex> repo.insert!(%Category{name: "name"})
268 | iex> repo.insert!(%Category{name: "name2"})
269 | iex> KeysetPaginate.format_params(Category, paginate_params, [repo: repo])
270 | %{max_page: 2, last_seen_pk: 0, page: 1,
271 | per_page: 1, total_count: 2, pk: :id}
272 |
273 | When `paginate_params` and `opts` given are valid and when the `queryable`
274 | passed has a `primary_key` defaulted to `id`.
275 |
276 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
277 | iex> alias Rummage.Ecto.Category
278 | iex> paginate_params = %{
279 | ...> per_page: 1,
280 | ...> page: 1
281 | ...> }
282 | iex> repo = Rummage.Ecto.Repo
283 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
284 | iex> repo.insert!(%Category{name: "name"})
285 | iex> repo.insert!(%Category{name: "name2"})
286 | iex> KeysetPaginate.format_params(Category, paginate_params, [repo: repo])
287 | %{max_page: 2, last_seen_pk: 0, page: 1,
288 | per_page: 1, total_count: 2, pk: :id}
289 |
290 | When `paginate_params` and `opts` given are valid and when the `queryable`
291 | passed has a custom `primary_key`.
292 |
293 | iex> alias Rummage.Ecto.CustomHook.KeysetPaginate
294 | iex> alias Rummage.Ecto.Product
295 | iex> paginate_params = %{
296 | ...> per_page: 1,
297 | ...> page: 2
298 | ...> }
299 | iex> repo = Rummage.Ecto.Repo
300 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
301 | iex> repo.insert!(%Product{internal_code: "100"})
302 | iex> repo.insert!(%Product{internal_code: "101"})
303 | iex> KeysetPaginate.format_params(Product, paginate_params, [repo: repo])
304 | %{max_page: 2, last_seen_pk: 1, page: 2,
305 | per_page: 1, total_count: 2, pk: :internal_code}
306 |
307 | """
308 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
309 | def format_params(queryable, paginate_params, opts) do
310 | paginate_params = populate_params(queryable, paginate_params, opts)
311 |
312 | case Keyword.get(opts, :repo) do
313 | nil -> raise "Expected key `repo` in `opts`, got #{inspect(opts)}"
314 | repo -> get_params(queryable, paginate_params, repo)
315 | end
316 | end
317 |
318 | # Helper function that populate the list of params based on
319 | # @expected_keys list
320 | defp populate_params(queryable, params, opts) do
321 | params =
322 | params
323 | |> Map.put_new(:per_page, Keyword.get(opts, :per_page, @per_page))
324 | |> Map.put_new(:pk, pk(queryable))
325 | |> Map.put_new(:page, 1)
326 |
327 | Map.put_new(params, :last_seen_pk, get_last_seen(params))
328 | end
329 |
330 | # Helper function which gets the default last_seen_pk from
331 | # page and per_page
332 | defp get_last_seen(params) do
333 | Map.get(params, :per_page) * (Map.get(params, :page) - 1)
334 | end
335 |
336 | # Helper function which gets formatted list of params including
337 | # page, per_page, total_count and max_page keys
338 | defp get_params(queryable, paginate_params, repo) do
339 | per_page = Map.get(paginate_params, :per_page)
340 | total_count = get_total_count(queryable, repo)
341 |
342 | max_page =
343 | total_count
344 | |> (&(&1 / per_page)).()
345 | |> Float.ceil()
346 | |> trunc()
347 |
348 | %{
349 | page: Map.get(paginate_params, :page),
350 | pk: Map.get(paginate_params, :pk),
351 | last_seen_pk: Map.get(paginate_params, :last_seen_pk),
352 | per_page: per_page,
353 | total_count: total_count,
354 | max_page: max_page
355 | }
356 | end
357 |
358 | # Helper function which gets total count of a queryable based on
359 | # the given repo.
360 | # This excludes operations such as select, preload and order_by
361 | # to make the query more effectient
362 | defp get_total_count(queryable, repo) do
363 | queryable
364 | |> exclude(:select)
365 | |> exclude(:preload)
366 | |> exclude(:order_by)
367 | |> get_count(repo, pk(queryable))
368 | end
369 |
370 | # This function gets count of a query and repo passed.
371 | # A primary key must be passed and it just counts
372 | # the distinct primary keys
373 | defp get_count(query, repo, pk) do
374 | query = select(query, [s], count(field(s, ^pk), :distinct))
375 | hd(apply(repo, :all, [query]))
376 | end
377 |
378 | # Helper function which returns the primary key associated with a
379 | # Queryable.
380 | defp pk(queryable) do
381 | schema = (is_map(queryable) && elem(queryable.from, 1)) || queryable
382 |
383 | case schema.__schema__(:primary_key) do
384 | [] -> nil
385 | list -> hd(list)
386 | end
387 | end
388 | end
389 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/paginate.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.Paginate do
2 | @moduledoc """
3 | `Rummage.Ecto.Hook.Paginate` is the default pagination hook that comes with
4 | `Rummage.Ecto`.
5 |
6 | This module provides a operations that can add pagination functionality to
7 | a pipeline of `Ecto` queries. This module works by taking a `per_page`, which
8 | it uses to add a `limit` to the query and by setting the `offset` using the
9 | `page` variable, which signifies the current page of entries to be displayed.
10 |
11 |
12 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
13 | This module `uses` `Rummage.Ecto.Hook`.
14 |
15 | _____________________________________________________________________________
16 |
17 | # ABOUT:
18 |
19 | ## Arguments:
20 |
21 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
22 | `paginate_params` (a `Map`). The map should be in the format:
23 | `%{per_page: 10, page: 1}`
24 |
25 | Details:
26 |
27 | * `per_page`: Specifies the entries in each page.
28 | * `page`: Specifies the `page` number.
29 |
30 |
31 | For example, if we want to paginate products, we would
32 | do the following:
33 |
34 | ```elixir
35 | Rummage.Ecto.Hook.Paginate.run(Product, %{per_page: 10, page: 1})
36 | ```
37 |
38 | _____________________________________________________________________________
39 |
40 | # ASSUMPTIONS/NOTES:
41 |
42 | NONE: This Hook should work for all the `Schema` types. Whether the schema has
43 | a primary_key or not, this should handle that.
44 |
45 | _____________________________________________________________________________
46 |
47 | ## USAGE:
48 |
49 | To add pagination to a `Ecto.Queryable`, simply do the following:
50 |
51 | ```ex
52 | Rummage.Ecto.Hook.Paginate.run(queryable, %{per_page: 10, page: 2})
53 | ```
54 |
55 | ## Overriding:
56 |
57 | This module can be overridden with a custom module while using `Rummage.Ecto`
58 | in `Ecto` struct module.
59 |
60 | In the `Ecto` module:
61 | ```elixir
62 | Rummage.Ecto.rummage(queryable, rummage, paginate: CustomHook)
63 | ```
64 |
65 | OR
66 |
67 | Globally for all models in `config.exs`:
68 | ```elixir
69 | config :rummage_ecto,
70 | Rummage.Ecto,
71 | .paginate: CustomHook
72 | ```
73 |
74 | The `CustomHook` must use `Rummage.Ecto.Hook`. For examples of `CustomHook`,
75 | check out some `custom_hooks` that are shipped with `Rummage.Ecto`:
76 | `Rummage.Ecto.CustomHook.SimpleSearch`, `Rummage.Ecto.CustomHook.SimpleSort`,
77 | Rummage.Ecto.CustomHook.SimplePaginate
78 | """
79 |
80 | use Rummage.Ecto.Hook
81 |
82 | import Ecto.Query
83 |
84 | @expected_keys ~w{per_page page}a
85 | @err_msg ~s{Error in params, No values given for keys: }
86 |
87 | @per_page 10
88 |
89 | @doc """
90 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
91 |
92 | Builds a paginate `Ecto.Query.t` on top of a given `Ecto.Query.t` variable
93 | with given `params`.
94 |
95 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
96 | implements `Ecto.Queryable`
97 |
98 | Params is a `Map` which is expected to have the keys `#{Enum.join(@expected_keys, ", ")}`.
99 |
100 | If an expected key isn't given, a `Runtime Error` is raised.
101 |
102 | ## Examples
103 | When an empty map is passed as `params`:
104 |
105 | iex> alias Rummage.Ecto.Hook.Paginate
106 | iex> import Ecto.Query
107 | iex> Paginate.run(Parent, %{})
108 | ** (RuntimeError) Error in params, No values given for keys: per_page, page
109 |
110 | When a non-empty map is passed as `params`, but with a missing key:
111 |
112 | iex> alias Rummage.Ecto.Hook.Paginate
113 | iex> import Ecto.Query
114 | iex> Paginate.run(Parent, %{per_page: 10})
115 | ** (RuntimeError) Error in params, No values given for keys: page
116 |
117 | When a valid map of params is passed with an `Ecto.Schema` module:
118 |
119 | iex> alias Rummage.Ecto.Hook.Paginate
120 | iex> import Ecto.Query
121 | iex> Paginate.run(Rummage.Ecto.Product, %{per_page: 10, page: 1})
122 | #Ecto.Query
123 |
124 | When the `queryable` passed is an `Ecto.Query` variable:
125 |
126 | iex> alias Rummage.Ecto.Hook.Paginate
127 | iex> import Ecto.Query
128 | iex> queryable = from u in "products"
129 | #Ecto.Query
130 | iex> Paginate.run(queryable, %{per_page: 10, page: 2})
131 | #Ecto.Query
132 |
133 |
134 | More examples:
135 |
136 | iex> alias Rummage.Ecto.Hook.Paginate
137 | iex> import Ecto.Query
138 | iex> rummage = %{per_page: 1, page: 1}
139 | iex> queryable = from u in "products"
140 | #Ecto.Query
141 | iex> Paginate.run(queryable, rummage)
142 | #Ecto.Query
143 |
144 | iex> alias Rummage.Ecto.Hook.Paginate
145 | iex> import Ecto.Query
146 | iex> rummage = %{per_page: 5, page: 2}
147 | iex> queryable = from u in "products"
148 | #Ecto.Query
149 | iex> Paginate.run(queryable, rummage)
150 | #Ecto.Query
151 |
152 | """
153 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
154 | def run(queryable, paginate_params) do
155 | :ok = validate_params(paginate_params)
156 |
157 | handle_paginate(queryable, paginate_params)
158 | end
159 |
160 | # Helper function which handles addition of paginated query on top of
161 | # the sent queryable variable
162 | defp handle_paginate(queryable, paginate_params) do
163 | per_page = Map.get(paginate_params, :per_page)
164 | page = Map.get(paginate_params, :page)
165 | offset = per_page * (page - 1)
166 |
167 | queryable
168 | |> limit(^per_page)
169 | |> offset(^offset)
170 | end
171 |
172 | # Helper function that validates the list of params based on
173 | # @expected_keys list
174 | defp validate_params(params) do
175 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
176 |
177 | case Enum.filter(key_validations, &(&1 == :error)) do
178 | [] -> :ok
179 | _ -> raise @err_msg <> missing_keys(key_validations)
180 | end
181 | end
182 |
183 | # Helper function used to build error message using missing keys
184 | defp missing_keys(key_validations) do
185 | key_validations
186 | |> Enum.with_index()
187 | |> Enum.filter(fn {v, _i} -> v == :error end)
188 | |> Enum.map_join(", ", fn {_v, i} ->
189 | @expected_keys
190 | |> Enum.at(i)
191 | |> to_string()
192 | end)
193 | end
194 |
195 | @doc """
196 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
197 |
198 | This function takes an `Ecto.Query.t` or `queryable`, `paginate_params` which
199 | will be passed to the `run/2` function, but also takes a list of options,
200 | `opts`.
201 |
202 | The function expects `opts` to include a `repo` key which points to the
203 | `Ecto.Repo` which will be used to calculate the `total_count` and `max_page`
204 | for this paginate hook module.
205 |
206 |
207 | ## Examples
208 |
209 | When a `repo` isn't passed in `opts` it gives an error:
210 |
211 | iex> alias Rummage.Ecto.Hook.Paginate
212 | iex> alias Rummage.Ecto.Category
213 | iex> Paginate.format_params(Category, %{per_page: 1, page: 1}, [])
214 | ** (RuntimeError) Expected key `repo` in `opts`, got []
215 |
216 | When `paginate_params` given aren't valid, it uses defaults to populate params:
217 |
218 | iex> alias Rummage.Ecto.Hook.Paginate
219 | iex> alias Rummage.Ecto.Category
220 | iex> Ecto.Adapters.SQL.Sandbox.checkout(Rummage.Ecto.Repo)
221 | iex> Paginate.format_params(Category, %{}, [repo: Rummage.Ecto.Repo])
222 | %{max_page: 0, page: 1, per_page: 10, total_count: 0}
223 |
224 | When `paginate_params` and `opts` given are valid:
225 |
226 | iex> alias Rummage.Ecto.Hook.Paginate
227 | iex> alias Rummage.Ecto.Category
228 | iex> paginate_params = %{
229 | ...> per_page: 1,
230 | ...> page: 1
231 | ...> }
232 | iex> repo = Rummage.Ecto.Repo
233 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
234 | iex> Paginate.format_params(Category, paginate_params, [repo: repo])
235 | %{max_page: 0, page: 1, per_page: 1, total_count: 0}
236 |
237 | When `paginate_params` and `opts` given are valid:
238 |
239 | iex> alias Rummage.Ecto.Hook.Paginate
240 | iex> alias Rummage.Ecto.Category
241 | iex> paginate_params = %{
242 | ...> per_page: 1,
243 | ...> page: 1
244 | ...> }
245 | iex> repo = Rummage.Ecto.Repo
246 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
247 | iex> repo.insert!(%Category{name: "name"})
248 | iex> repo.insert!(%Category{name: "name2"})
249 | iex> Paginate.format_params(Category, paginate_params, [repo: repo])
250 | %{max_page: 2, page: 1, per_page: 1, total_count: 2}
251 |
252 | When `paginate_params` and `opts` given are valid and when the `queryable`
253 | passed has a `primary_key` defaulted to `id`.
254 |
255 | iex> alias Rummage.Ecto.Hook.Paginate
256 | iex> alias Rummage.Ecto.Category
257 | iex> paginate_params = %{
258 | ...> per_page: 1,
259 | ...> page: 1
260 | ...> }
261 | iex> repo = Rummage.Ecto.Repo
262 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
263 | iex> repo.insert!(%Category{name: "name"})
264 | iex> repo.insert!(%Category{name: "name2"})
265 | iex> Paginate.format_params(Category, paginate_params, [repo: repo])
266 | %{max_page: 2, page: 1, per_page: 1, total_count: 2}
267 |
268 | When `paginate_params` and `opts` given are valid and when the `queryable`
269 | passed has a custom `primary_key`.
270 |
271 | iex> alias Rummage.Ecto.Hook.Paginate
272 | iex> alias Rummage.Ecto.Product
273 | iex> paginate_params = %{
274 | ...> per_page: 1,
275 | ...> page: 1
276 | ...> }
277 | iex> repo = Rummage.Ecto.Repo
278 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
279 | iex> repo.insert!(%Product{internal_code: "100"})
280 | iex> repo.insert!(%Product{internal_code: "101"})
281 | iex> Paginate.format_params(Product, paginate_params, [repo: repo])
282 | %{max_page: 2, page: 1, per_page: 1, total_count: 2}
283 |
284 | When `paginate_params` and `opts` given are valid and when the `queryable`
285 | passed has a custom `primary_key`.
286 |
287 | iex> alias Rummage.Ecto.Hook.Paginate
288 | iex> alias Rummage.Ecto.Employee
289 | iex> paginate_params = %{
290 | ...> per_page: 1,
291 | ...> page: 1
292 | ...> }
293 | iex> repo = Rummage.Ecto.Repo
294 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
295 | iex> repo.insert!(%Employee{first_name: "First"})
296 | iex> repo.insert!(%Employee{first_name: "Second"})
297 | iex> Paginate.format_params(Employee, paginate_params, [repo: repo])
298 | %{max_page: 2, page: 1, per_page: 1, total_count: 2}
299 |
300 | When `paginate_params` and `opts` given are valid and when the `queryable`
301 | passed is not a `Ecto.Schema` module, but an `Ecto.Query.t`.
302 |
303 | iex> alias Rummage.Ecto.Hook.Paginate
304 | iex> alias Rummage.Ecto.Employee
305 | iex> paginate_params = %{
306 | ...> per_page: 1,
307 | ...> page: 1
308 | ...> }
309 | iex> repo = Rummage.Ecto.Repo
310 | iex> Ecto.Adapters.SQL.Sandbox.checkout(repo)
311 | iex> repo.insert!(%Employee{first_name: "First"})
312 | iex> repo.insert!(%Employee{first_name: "Second"})
313 | iex> import Ecto.Query
314 | iex> queryable = from u in Employee, where: u.first_name == "First"
315 | iex> Paginate.format_params(queryable, paginate_params, [repo: repo])
316 | %{max_page: 1, page: 1, per_page: 1, total_count: 1}
317 |
318 | """
319 | @spec format_params(Ecto.Query.t(), map() | atom(), keyword()) :: map()
320 | def format_params(queryable, {paginate_scope, page}, opts) do
321 | module = get_module(queryable)
322 | name = :"__rummage_paginate_#{paginate_scope}"
323 |
324 | paginate_params =
325 | case function_exported?(module, name, 1) do
326 | true -> apply(module, name, [page])
327 | _ -> raise "No scope `#{paginate_scope}` of type paginate defined in the #{module}"
328 | end
329 |
330 | format_params(queryable, paginate_params, opts)
331 | end
332 |
333 | def format_params(queryable, paginate_params, opts) do
334 | paginate_params = populate_params(paginate_params, opts)
335 |
336 | case Keyword.get(opts, :repo) do
337 | nil -> raise "Expected key `repo` in `opts`, got #{inspect(opts)}"
338 | repo -> get_params(queryable, paginate_params, repo)
339 | end
340 | end
341 |
342 | # Helper function that populate the list of params based on
343 | # @expected_keys list
344 | defp populate_params(params, opts) do
345 | params
346 | |> Map.put_new(:per_page, Keyword.get(opts, :per_page, @per_page))
347 | |> Map.put_new(:page, 1)
348 | end
349 |
350 | # Helper function which gets formatted list of params including
351 | # page, per_page, total_count and max_page keys
352 | defp get_params(queryable, paginate_params, repo) do
353 | per_page = Map.get(paginate_params, :per_page)
354 | total_count = get_total_count(queryable, repo)
355 |
356 | max_page =
357 | total_count
358 | |> (&(&1 / per_page)).()
359 | |> Float.ceil()
360 | |> trunc()
361 |
362 | %{
363 | page: Map.get(paginate_params, :page),
364 | per_page: per_page,
365 | total_count: total_count,
366 | max_page: max_page
367 | }
368 | end
369 |
370 | # Helper function which gets total count of a queryable based on
371 | # the given repo.
372 | # This excludes operations such as select, preload and order_by
373 | # to make the query more effectient
374 | defp get_total_count(queryable, repo) do
375 | queryable
376 | |> exclude(:select)
377 | |> exclude(:preload)
378 | |> exclude(:order_by)
379 | |> get_count(repo, pk(queryable))
380 | end
381 |
382 | # This function gets count of a query and repo passed.
383 | # When primary key passed is nil, it just gets all the elements
384 | # and counts them, but when a primary key is passed it just counts
385 | # the distinct primary keys
386 | defp get_count(query, repo, nil) do
387 | repo
388 | |> apply(:all, [distinct(query, true)])
389 | |> Enum.count()
390 | end
391 |
392 | defp get_count(query, repo, pk) do
393 | query = select(query, [s], count(field(s, ^pk), :distinct))
394 | hd(apply(repo, :all, [query]))
395 | end
396 |
397 | # Helper function which returns the primary key associated with a
398 | # Queryable.
399 | defp pk(queryable) do
400 | schema =
401 | queryable
402 | |> Ecto.Queryable.to_query()
403 | |> Rummage.Ecto.QueryUtils.schema_from_query()
404 |
405 | case schema.__schema__(:primary_key) do
406 | [] -> nil
407 | list -> hd(list)
408 | end
409 | end
410 | end
411 |
--------------------------------------------------------------------------------
/lib/rummage_ecto/hooks/search.ex:
--------------------------------------------------------------------------------
1 | defmodule Rummage.Ecto.Hook.Search do
2 | @moduledoc """
3 | `Rummage.Ecto.Hook.Search` is the default search hook that comes with
4 | `Rummage.Ecto`.
5 |
6 | This module provides a operations that can add searching functionality to
7 | a pipeline of `Ecto` queries. This module works by taking fields, and `search_type`,
8 | `search_term` and `assoc` associated with those `fields`.
9 |
10 | NOTE: This module doesn't return a list of entries, but a `Ecto.Query.t`.
11 | This module `uses` `Rummage.Ecto.Hook`.
12 |
13 | _____________________________________________________________________________
14 |
15 | # ABOUT:
16 |
17 | ## Arguments:
18 |
19 | This Hook expects a `queryable` (an `Ecto.Queryable`) and
20 | `search_params` (a `Map`). The map should be in the format:
21 | `%{field_name: %{assoc: [], search_term: true, search_type: :eq}}`
22 |
23 | Details:
24 |
25 | * `field_name`: The field name to search by.
26 | * `assoc`: List of associations in the search.
27 | * `search_term`: Term to compare the `field_name` against.
28 | * `search_type`: Determines the kind of search to perform. If `:eq`, it
29 | expects the `field_name`'s value to be equal to `search_term`,
30 | If `lt`, it expects it to be less than `search_term`.
31 | To see all the `search_type`s, check
32 | `Rummage.Ecto.Services.BuildSearchQuery`
33 | * `search_expr`: This is optional. Defaults to `:where`. This is the way current
34 | search expression is appended to the existing query.
35 | To see all the `search_expr`s, check
36 | `Rummage.Ecto.Services.BuildSearchQuery`
37 |
38 |
39 | For example, if we want to search products with `available` = `true`, we would
40 | do the following:
41 |
42 | ```elixir
43 | Rummage.Ecto.Hook.Search.run(Product, %{available: %{assoc: [],
44 | search_type: :eq,
45 | search_term: true}})
46 | ```
47 |
48 | This can be used for a search with multiple fields as well. Say, we want to
49 | search for products that are `available`, but have a price less than `10.0`.
50 |
51 | ```elixir
52 | Rummage.Ecto.Hook.Search.run(Product,
53 | %{available: %{assoc: [],
54 | search_type: :eq,
55 | search_term: true},
56 | %{price: %{assoc: [],
57 | search_type: :lt,
58 | search_term: 10.0}})
59 | ```
60 |
61 | ## Assoications:
62 |
63 | Assocaitions can be given to this module's run function as a key corresponding
64 | to params associated with a field. For example, if we want to search products
65 | that belong to a category with category_name, "super", we would do the
66 | following:
67 |
68 | ```elixir
69 | category_name_params = %{assoc: [inner: :category], search_term: "super",
70 | search_type: :eq, search_expr: :where}
71 |
72 | Rummage.Ecto.Hook.Search.run(Product, %{category_name: category_name_params})
73 | ```
74 |
75 | The above operation will return an `Ecto.Query.t` struct which represents
76 | a query equivalent to:
77 |
78 | ```elixir
79 | from p0 in Product
80 | |> join(:inner, :category)
81 | |> where([p, c], c.category_name == ^"super")
82 | ```
83 |
84 | ____________________________________________________________________________
85 |
86 | # ASSUMPTIONS/NOTES:
87 |
88 | * This Hook has the default `search_type` of `:ilike`, which is
89 | case-insensitive.
90 | * This Hook has the default `search_expr` of `:where`.
91 | * This Hook assumes that the field passed is a field on the `Ecto.Schema`
92 | that corresponds to the last association in the `assoc` list or the `Ecto.Schema`
93 | that corresponds to the `from` in `queryable`, if `assoc` is an empty list.
94 |
95 | NOTE: It is adviced to not use multiple associated searches in one operation
96 | as `assoc` still has some minor bugs when used with multiple searches. If you
97 | need to use two searches with associations, I would pipe the call to another
98 | search operation:
99 |
100 | ```elixir
101 | Search.run(queryable, %{field1: %{assoc: [inner: :some_assoc]}}
102 | |> Search.run(%{field2: %{assoc: [inner: :some_assoc2]}}
103 | ```
104 |
105 | ____________________________________________________________________________
106 |
107 | # USAGE:
108 |
109 | For a regular search:
110 |
111 | This returns a `queryable` which upon running will give a list of `Parent`(s)
112 | searched by ascending `field_1`
113 |
114 | ```elixir
115 | alias Rummage.Ecto.Hook.Search
116 |
117 | searched_queryable = Search.run(Parent, %{field_1: %{assoc: [],
118 | search_type: :like, search_term: "field_!"}}})
119 |
120 | ```
121 |
122 | For a case-insensitive search:
123 |
124 | This returns a `queryable` which upon running will give a list of `Parent`(s)
125 | searched by ascending case insensitive `field_1`.
126 |
127 | Keep in mind that `case_insensitive` can only be called for `text` fields
128 |
129 | ```elixir
130 | alias Rummage.Ecto.Hook.Search
131 |
132 | searched_queryable = Search.run(Parent, %{field_1: %{assoc: [],
133 | search_type: :ilike, search_term: "field_!"}}})
134 |
135 | ```
136 |
137 | There are many other `search_types`. Check out
138 | `Rummage.Ecto.Services.BuildSearchQuery` docs to explore more `search_types`
139 |
140 | This module can be overridden with a custom module while using `Rummage.Ecto`
141 | in `Ecto` struct module:
142 |
143 | In the `Ecto` module:
144 | ```elixir
145 | Rummage.Ecto.rummage(queryable, rummage, search: CustomHook)
146 | ```
147 |
148 | OR
149 |
150 | Globally for all models in `config.exs`:
151 | ```elixir
152 | config :my_app,
153 | Rummage.Ecto,
154 | .search: CustomHook
155 | ```
156 |
157 | The `CustomHook` must use `Rummage.Ecto.Hook`. For examples of `CustomHook`,
158 | check out some `custom_hooks` that are shipped with `Rummage.Ecto`:
159 | `Rummage.Ecto.CustomHook.SimpleSearch`, `Rummage.Ecto.CustomHook.SimpleSort`,
160 | Rummage.Ecto.CustomHook.SimplePaginate
161 | """
162 |
163 | use Rummage.Ecto.Hook
164 |
165 | import Ecto.Query
166 |
167 | @expected_keys ~w{search_type assoc search_term}a
168 | @err_msg ~s{Error in params, No values given for keys: }
169 |
170 | alias Rummage.Ecto.Services.BuildSearchQuery
171 |
172 | @doc ~S"""
173 | This is the callback implementation of Rummage.Ecto.Hook.run/2.
174 |
175 | Builds a search `Ecto.Query.t` on top of a given `Ecto.Query.t` variable
176 | with given `params`.
177 |
178 | Besides an `Ecto.Query.t` an `Ecto.Schema` module can also be passed as it
179 | implements `Ecto.Queryable`
180 |
181 | Params is a `Map`, keys of which are field names which will be searched for and
182 | value corresponding to that key is a list of params for that key, which
183 | should include the keys: `#{Enum.join(@expected_keys, ", ")}`.
184 |
185 | This function expects a `search_expr`, `search_type` and a list of
186 | `associations` (empty for none). The `search_term` is what the `field`
187 | will be matched to based on the `search_type` and `search_expr`.
188 |
189 | If no `search_expr` is given, it defaults to `where`.
190 |
191 | For all `search_exprs`, refer to `Rummage.Ecto.Services.BuildSearchQuery`.
192 |
193 | For all `search_types`, refer to `Rummage.Ecto.Services.BuildSearchQuery`.
194 |
195 | If an expected key isn't given, a `Runtime Error` is raised.
196 |
197 | NOTE:This hook isn't responsible for doing type validations. That's the
198 | responsibility of the user sending `search_term` and `search_type`. Same
199 | goes for the validity of `assoc`.
200 |
201 | ## Examples
202 | When search_params are empty, it simply returns the same `queryable`:
203 |
204 | iex> alias Rummage.Ecto.Hook.Search
205 | iex> import Ecto.Query
206 | iex> Search.run(Parent, %{})
207 | Parent
208 |
209 | When a non-empty map is passed as a field `params`, but with a missing key:
210 |
211 | iex> alias Rummage.Ecto.Hook.Search
212 | iex> import Ecto.Query
213 | iex> Search.run(Parent, %{field: %{assoc: []}})
214 | ** (RuntimeError) Error in params, No values given for keys: search_type, search_term
215 |
216 | When a valid map of params is passed with an `Ecto.Schema` module:
217 |
218 | iex> alias Rummage.Ecto.Hook.Search
219 | iex> import Ecto.Query
220 | iex> search_params = %{field1: %{assoc: [],
221 | ...> search_type: :like, search_term: "field1", search_expr: :where}}
222 | iex> Search.run(Rummage.Ecto.Product, search_params)
223 | #Ecto.Query
224 |
225 | When a valid map of params is passed with an `Ecto.Query.t`:
226 |
227 | iex> alias Rummage.Ecto.Hook.Search
228 | iex> import Ecto.Query
229 | iex> search_params = %{field1: %{assoc: [],
230 | ...> search_type: :like, search_term: "field1", search_expr: :where}}
231 | iex> query = from p0 in "products"
232 | iex> Search.run(query, search_params)
233 | #Ecto.Query
234 |
235 | When a valid map of params is passed with an `Ecto.Query.t`, with `assoc`s:
236 |
237 | iex> alias Rummage.Ecto.Hook.Search
238 | iex> import Ecto.Query
239 | iex> search_params = %{field1: %{assoc: [inner: :category],
240 | ...> search_type: :like, search_term: "field1", search_expr: :or_where}}
241 | iex> query = from p0 in "products"
242 | iex> Search.run(query, search_params)
243 | #Ecto.Query
244 |
245 | When a valid map of params is passed with an `Ecto.Query.t`, with `assoc`s, with
246 | different join types:
247 |
248 | iex> alias Rummage.Ecto.Hook.Search
249 | iex> import Ecto.Query
250 | iex> search_params = %{field1: %{assoc: [inner: :category, left: :category, cross: :category],
251 | ...> search_type: :like, search_term: "field1", search_expr: :where}}
252 | iex> query = from p0 in "products"
253 | iex> Search.run(query, search_params)
254 | #Ecto.Query
255 |
256 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
257 | a boolean param
258 |
259 | iex> alias Rummage.Ecto.Hook.Search
260 | iex> import Ecto.Query
261 | iex> search_params = %{available: %{assoc: [],
262 | ...> search_type: :eq, search_term: true, search_expr: :where}}
263 | iex> query = from p0 in "products"
264 | iex> Search.run(query, search_params)
265 | #Ecto.Query
266 |
267 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
268 | a float param
269 |
270 | iex> alias Rummage.Ecto.Hook.Search
271 | iex> import Ecto.Query
272 | iex> search_params = %{price: %{assoc: [],
273 | ...> search_type: :gteq, search_term: 10.0, search_expr: :where}}
274 | iex> query = from p0 in "products"
275 | iex> Search.run(query, search_params)
276 | #Ecto.Query= ^10.0>
277 |
278 | When a valid map of params is passed with an `Ecto.Query.t`, searching on
279 | a boolean param, but with a wrong `search_type`.
280 | NOTE: This doesn't validate the search_type of search_term
281 |
282 | iex> alias Rummage.Ecto.Hook.Search
283 | iex> import Ecto.Query
284 | iex> search_params = %{available: %{assoc: [],
285 | ...> search_type: :ilike, search_term: true, search_expr: :where}}
286 | iex> query = from p0 in "products"
287 | iex> Search.run(query, search_params)
288 | ** (ArgumentError) argument error
289 |
290 | """
291 | @spec run(Ecto.Query.t(), map()) :: Ecto.Query.t()
292 | def run(q, s), do: handle_search(q, s)
293 |
294 | # Helper function which handles addition of search query on top of
295 | # the sent queryable variable, for all search fields.
296 | defp handle_search(queryable, search_params) do
297 | search_params
298 | |> Map.to_list()
299 | |> Enum.reduce(queryable, &search_queryable(&1, &2))
300 | end
301 |
302 | # Helper function which handles addition of search query on top of
303 | # the sent queryable variable, for ONE search fields.
304 | # This delegates the query building to `BuildSearchQuery` module
305 | defp search_queryable(param, queryable) do
306 | field = elem(param, 0)
307 | field_params = elem(param, 1)
308 |
309 | :ok = validate_params(field_params)
310 |
311 | assocs = Map.get(field_params, :assoc)
312 | search_type = Map.get(field_params, :search_type)
313 | search_term = Map.get(field_params, :search_term)
314 | search_expr = Map.get(field_params, :search_expr, :where)
315 | field = resolve_field(field, queryable)
316 |
317 | assocs
318 | |> Enum.reduce(from(e in subquery(queryable)), &join_by_assoc(&1, &2))
319 | |> BuildSearchQuery.run(field, {search_expr, search_type}, search_term)
320 | end
321 |
322 | # Helper function which handles associations in a query with a join
323 | # type.
324 | defp join_by_assoc({join, assoc}, query) do
325 | join(query, join, [..., p1], p2 in assoc(p1, ^assoc))
326 | end
327 |
328 | # NOTE: These functions can be used in future for multiple search fields that
329 | # are associated.
330 | # defp applied_associations(queryable) when is_atom(queryable), do: []
331 | # defp applied_associations(queryable), do: Enum.map(queryable.joins, & Atom.to_string(elem(&1.assoc, 1)))
332 |
333 | # Helper function that validates the list of params based on
334 | # @expected_keys list
335 | defp validate_params(params) do
336 | key_validations = Enum.map(@expected_keys, &Map.fetch(params, &1))
337 |
338 | case Enum.filter(key_validations, &(&1 == :error)) do
339 | [] -> :ok
340 | _ -> raise @err_msg <> missing_keys(key_validations)
341 | end
342 | end
343 |
344 | # Helper function used to build error message using missing keys
345 | defp missing_keys(key_validations) do
346 | key_validations
347 | |> Enum.with_index()
348 | |> Enum.filter(fn {v, _i} -> v == :error end)
349 | |> Enum.map_join(", ", fn {_v, i} ->
350 | @expected_keys
351 | |> Enum.at(i)
352 | |> to_string()
353 | end)
354 | end
355 |
356 | @doc """
357 | Callback implementation for Rummage.Ecto.Hook.format_params/3.
358 |
359 | This function ensures that params for each field have keys `assoc`, `search_type` and
360 | `search_expr` which are essential for running this hook module.
361 |
362 | ## Examples
363 | iex> alias Rummage.Ecto.Hook.Search
364 | iex> Search.format_params(Parent, %{field: %{}}, [])
365 | %{field: %{assoc: [], search_expr: :where, search_type: :eq}}
366 |
367 | iex> alias Rummage.Ecto.Hook.Search
368 | iex> Search.format_params(Parent, %{field: 1}, [])
369 | ** (RuntimeError) No scope `field` of type search defined in the Elixir.Parent
370 | """
371 | @spec format_params(Ecto.Query.t(), map(), keyword()) :: map()
372 | def format_params(queryable, search_params, _opts) do
373 | search_params
374 | |> Map.to_list()
375 | |> Enum.map(&put_keys(&1, queryable))
376 | |> Enum.into(%{})
377 | end
378 |
379 | defp put_keys({field, %{} = field_params}, _queryable) do
380 | field_params =
381 | field_params
382 | |> Map.put_new(:assoc, [])
383 | |> Map.put_new(:search_type, :eq)
384 | |> Map.put_new(:search_expr, :where)
385 |
386 | {field, field_params}
387 | end
388 |
389 | defp put_keys({search_scope, field_value}, queryable) do
390 | module = get_module(queryable)
391 | name = :"__rummage_search_#{search_scope}"
392 |
393 | {field, search_params} =
394 | case function_exported?(module, name, 1) do
395 | true -> apply(module, name, [field_value])
396 | _ -> raise "No scope `#{search_scope}` of type search defined in the #{module}"
397 | end
398 |
399 | put_keys({field, search_params}, queryable)
400 | end
401 | end
402 |
--------------------------------------------------------------------------------
/test/rummage_ecto_test.exs:
--------------------------------------------------------------------------------
1 | defmodule Rummage.EctoTest do
2 | use ExUnit.Case
3 | doctest Rummage.Ecto
4 |
5 | alias Rummage.Ecto.Repo
6 | alias Rummage.Ecto.Product
7 | alias Rummage.Ecto.Category
8 |
9 | setup do
10 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(Repo)
11 | end
12 |
13 | defp create_categories_and_products() do
14 | for x <- 1..4 do
15 | parent_category =
16 | %Category{name: "Parent Category #{10 - x}"}
17 | |> Repo.insert!()
18 |
19 | category =
20 | %Category{name: "Category #{x}", parent_category: parent_category}
21 | |> Repo.insert!()
22 |
23 | for y <- 1..2 do
24 | %Product{
25 | internal_code: "#{x}->#{y}",
26 | name: "Product #{y}->#{x}",
27 | price: 10.0 * x,
28 | category: category
29 | }
30 | |> Repo.insert!()
31 | end
32 | end
33 | end
34 |
35 | test "rummage call with paginate returns the correct results for Product" do
36 | create_categories_and_products()
37 |
38 | rummage = %{paginate: %{page: 2}}
39 |
40 | {queryable, rummage} = Product.rummage(rummage)
41 |
42 | products = Repo.all(queryable)
43 |
44 | # Test length
45 | assert length(products) == 1
46 |
47 | # Test rummage params
48 | assert rummage == %{
49 | paginate: %{per_page: 1, page: 2, max_page: 8, total_count: 8}
50 | }
51 | end
52 |
53 | test "rummage call with paginate returns the correct results for Category" do
54 | create_categories_and_products()
55 |
56 | rummage = %{paginate: %{page: 2}}
57 |
58 | {queryable, rummage} = Category.rummage(rummage, per_page: 3)
59 |
60 | categories = Repo.all(queryable)
61 |
62 | # Test length
63 | assert length(categories) == 3
64 |
65 | # Test rummage params
66 | assert rummage == %{
67 | paginate: %{per_page: 3, page: 2, max_page: 3, total_count: 8}
68 | }
69 | end
70 |
71 | test "rummage call with sort without assoc params returns the correct results" do
72 | create_categories_and_products()
73 |
74 | rummage = %{sort: %{field: :name, order: :asc}}
75 |
76 | {queryable, rummage} = Product.rummage(rummage)
77 |
78 | products = Repo.all(queryable)
79 |
80 | # Test length
81 | assert length(products) == 8
82 |
83 | # Test ordering
84 | assert Enum.map(products, & &1.name) == [
85 | "Product 1->1",
86 | "Product 1->2",
87 | "Product 1->3",
88 | "Product 1->4",
89 | "Product 2->1",
90 | "Product 2->2",
91 | "Product 2->3",
92 | "Product 2->4"
93 | ]
94 |
95 | # Test rummage params
96 | assert rummage == %{sort: %{assoc: [], field: :name, order: :asc}}
97 | end
98 |
99 | test "rummage call with sort and assoc params returns the correct results" do
100 | create_categories_and_products()
101 |
102 | rummage = %{
103 | "sort" => %{"assoc" => ["category"], "field" => "name.asc"}
104 | }
105 |
106 | {queryable, rummage} = Rummage.Ecto.rummage(Product, rummage)
107 |
108 | products = Repo.all(queryable)
109 |
110 | # Test length
111 | assert length(products) == 8
112 |
113 | # Test ordering
114 | [products_1, products_2, products_3, products_4] = Enum.chunk_every(products, 2)
115 |
116 | assert Enum.all?(Repo.preload(products_1, :category), &(&1.category.name == "Category 1"))
117 | assert Enum.all?(Repo.preload(products_2, :category), &(&1.category.name == "Category 2"))
118 | assert Enum.all?(Repo.preload(products_3, :category), &(&1.category.name == "Category 3"))
119 | assert Enum.all?(Repo.preload(products_4, :category), &(&1.category.name == "Category 4"))
120 |
121 | # Test rummage params
122 | assert rummage == %{
123 | "sort" => %{"assoc" => ["category"], "field" => "name.asc"}
124 | }
125 | end
126 |
127 | test "rummage call with search and search_type lteq returns the correct results" do
128 | create_categories_and_products()
129 |
130 | rummage = %{search: %{price: %{search_type: :lteq, search_term: 10}}}
131 |
132 | {queryable, rummage} = Product.rummage(rummage)
133 |
134 | products = Repo.all(queryable)
135 |
136 | # Test length
137 | assert length(products) == 2
138 |
139 | # Test prices of products
140 | assert Enum.all?(products, &(&1.price <= 10.0))
141 |
142 | # Test rummage params
143 | assert rummage == %{
144 | search: %{
145 | price: %{search_type: :lteq, search_term: 10, search_expr: :where, assoc: []}
146 | }
147 | }
148 | end
149 |
150 | test "rummage call with search and search_type eq returns the correct results" do
151 | create_categories_and_products()
152 |
153 | rummage = %{search: %{price: %{search_type: :eq, search_term: 10}}}
154 |
155 | {queryable, rummage} = Product.rummage(rummage)
156 |
157 | products = Repo.all(queryable)
158 |
159 | # Test length
160 | assert length(products) == 2
161 |
162 | # Test prices of products
163 | assert Enum.all?(products, &(&1.price <= 10.0))
164 |
165 | # Test rummage params
166 | assert rummage == %{
167 | search: %{
168 | price: %{search_type: :eq, search_term: 10, assoc: [], search_expr: :where}
169 | }
170 | }
171 | end
172 |
173 | test "rummage call with search and search_type gteq returns the correct results" do
174 | create_categories_and_products()
175 |
176 | rummage = %{
177 | search: %{price: %{search_type: :gteq, search_term: 10}}
178 | }
179 |
180 | {queryable, rummage} = Product.rummage(rummage)
181 |
182 | products = Repo.all(queryable)
183 |
184 | # Test length
185 | assert length(products) == 8
186 |
187 | # Test prices of products
188 | assert Enum.all?(products, &(&1.price >= 10.0))
189 |
190 | # Test rummage params
191 | assert rummage == %{
192 | search: %{
193 | price: %{search_type: :gteq, search_term: 10, assoc: [], search_expr: :where}
194 | }
195 | }
196 | end
197 |
198 | test "rummage call with search and assoc params returns the correct results" do
199 | create_categories_and_products()
200 |
201 | rummage = %{
202 | search: %{name: %{assoc: [inner: :category], search_type: :like, search_term: "1"}}
203 | }
204 |
205 | {queryable, rummage} = Product.rummage(rummage)
206 |
207 | products = Repo.all(queryable)
208 |
209 | # Test length
210 | assert length(products) == 2
211 |
212 | # Test prices of products
213 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name == "Category 1"))
214 |
215 | # Test rummage params
216 | assert rummage == %{
217 | search: %{
218 | name: %{
219 | assoc: [inner: :category],
220 | search_type: :like,
221 | search_term: "1",
222 | search_expr: :where
223 | }
224 | }
225 | }
226 | end
227 |
228 | test "rummage call with search, sort and paginate" do
229 | create_categories_and_products()
230 |
231 | rummage = %{
232 | paginate: %{page: 2, per_page: 2},
233 | search: %{price: %{search_type: :lteq, search_term: 10}},
234 | sort: %{field: :name, order: :asc}
235 | }
236 |
237 | {queryable, rummage} = Product.rummage(rummage)
238 |
239 | products = Repo.all(queryable)
240 |
241 | # Test length
242 | assert Enum.empty?(products)
243 |
244 | assert Enum.all?(products, &(&1.price <= 10))
245 |
246 | # Test prices of products
247 | # assert Enum.all?(Repo.preload(products, :category), & &1.category.name == "Category 1")
248 |
249 | # Test rummage params
250 | assert rummage == %{
251 | search: %{
252 | price: %{search_type: :lteq, search_term: 10, assoc: [], search_expr: :where}
253 | },
254 | sort: %{field: :name, order: :asc, assoc: []},
255 | paginate: %{per_page: 2, page: 2, max_page: 4, total_count: 8}
256 | }
257 | end
258 |
259 | test "rummage call with assocs search, assoc sort and paginate" do
260 | create_categories_and_products()
261 |
262 | rummage = %{
263 | paginate: %{page: 1, per_page: 2},
264 | search: %{name: %{assoc: [inner: :category], search_type: :like, search_term: "1"}},
265 | sort: %{assoc: [inner: :category], field: :name, order: :asc}
266 | }
267 |
268 | {queryable, rummage} = Product.rummage(rummage)
269 |
270 | products = Repo.all(queryable)
271 |
272 | # Test length
273 | assert length(products) == 2
274 |
275 | # Test search
276 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name == "Category 1"))
277 |
278 | # Test rummage params
279 | assert rummage == %{
280 | search: %{
281 | name: %{
282 | assoc: [inner: :category],
283 | search_term: "1",
284 | search_type: :like,
285 | search_expr: :where
286 | }
287 | },
288 | sort: %{field: :name, order: :asc, assoc: [inner: :category]},
289 | paginate: %{per_page: 2, page: 1, max_page: 4, total_count: 8}
290 | }
291 | end
292 |
293 | test "rummage call with multiple associations in assocs search" do
294 | create_categories_and_products()
295 |
296 | rummage = %{
297 | paginate: %{page: 1},
298 | search: %{
299 | name: %{
300 | assoc: [{:inner, :category}, {:inner, :parent_category}],
301 | search_type: :like,
302 | search_term: "Parent"
303 | }
304 | },
305 | sort: %{assoc: [{:inner, :category}], field: :name, order: :asc}
306 | }
307 |
308 | {queryable, rummage} = Product.rummage(rummage)
309 |
310 | products = Repo.all(queryable)
311 |
312 | # Test length
313 | assert length(products) == 1
314 |
315 | # Test search
316 | assert Enum.all?(
317 | Repo.preload(products, :category),
318 | &(Repo.preload(&1.category, :parent_category).parent_category.name =~
319 | "Parent Category")
320 | )
321 |
322 | # Test sort
323 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name == "Category 1"))
324 |
325 | assert Enum.all?(
326 | Repo.preload(products, :category),
327 | &(Repo.preload(&1.category, :parent_category).parent_category.name ==
328 | "Parent Category 9")
329 | )
330 |
331 | # Test rummage params
332 | assert rummage == %{
333 | search: %{
334 | name: %{
335 | assoc: [inner: :category, inner: :parent_category],
336 | search_term: "Parent",
337 | search_type: :like,
338 | search_expr: :where
339 | }
340 | },
341 | sort: %{field: :name, order: :asc, assoc: [inner: :category]},
342 | paginate: %{
343 | per_page: 1,
344 | page: 1,
345 | max_page: 8,
346 | total_count: 8
347 | }
348 | }
349 | end
350 |
351 | test "rummage call with multiple associations in assocs search and assoc sort" do
352 | create_categories_and_products()
353 |
354 | rummage = %{
355 | paginate: %{page: 1, per_page: 2},
356 | search: %{
357 | name: %{
358 | assoc: [inner: :category, inner: :parent_category],
359 | search_type: :like,
360 | search_term: "Parent"
361 | }
362 | },
363 | sort: %{assoc: [inner: :category, inner: :parent_category], field: :name, order: :asc}
364 | }
365 |
366 | {queryable, rummage} = Product.rummage(rummage)
367 |
368 | products = Repo.all(queryable)
369 |
370 | # Test length
371 | assert length(products) == 2
372 |
373 | # Test search
374 | assert Enum.all?(
375 | Repo.preload(products, :category),
376 | &(Repo.preload(&1.category, :parent_category).parent_category.name =~
377 | "Parent Category")
378 | )
379 |
380 | # Test sort
381 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name == "Category 4"))
382 |
383 | assert Enum.all?(
384 | Repo.preload(products, :category),
385 | &(Repo.preload(&1.category, :parent_category).parent_category.name ==
386 | "Parent Category 6")
387 | )
388 |
389 | # Test rummage params
390 | assert rummage == %{
391 | search: %{
392 | name: %{
393 | assoc: [inner: :category, inner: :parent_category],
394 | search_term: "Parent",
395 | search_type: :like,
396 | search_expr: :where
397 | }
398 | },
399 | sort: %{
400 | field: :name,
401 | order: :asc,
402 | assoc: [inner: :category, inner: :parent_category]
403 | },
404 | paginate: %{per_page: 2, page: 1, max_page: 4, total_count: 8}
405 | }
406 | end
407 |
408 | test "rummage call with multiple associations in assocs sort" do
409 | create_categories_and_products()
410 |
411 | rummage = %{
412 | paginate: %{page: 1},
413 | search: %{
414 | name: %{assoc: [{:inner, :category}], search_type: :like, search_term: "Category"}
415 | },
416 | sort: %{assoc: [{:inner, :category}, {:inner, :parent_category}], field: :name, order: :asc}
417 | }
418 |
419 | {queryable, rummage} = Product.rummage(rummage)
420 |
421 | products = Repo.all(queryable)
422 |
423 | # Test length
424 | assert length(products) == 1
425 |
426 | # Test search
427 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name =~ "Category"))
428 |
429 | # Test sort
430 | assert Enum.all?(Repo.preload(products, :category), &(&1.category.name =~ "Category 4"))
431 |
432 | assert Enum.all?(
433 | Repo.preload(products, :category),
434 | &(Repo.preload(&1.category, :parent_category).parent_category.name ==
435 | "Parent Category 6")
436 | )
437 |
438 | # Test rummage params
439 | assert rummage == %{
440 | search: %{
441 | name: %{
442 | assoc: [inner: :category],
443 | search_term: "Category",
444 | search_type: :like,
445 | search_expr: :where
446 | }
447 | },
448 | sort: %{
449 | field: :name,
450 | order: :asc,
451 | assoc: [inner: :category, inner: :parent_category]
452 | },
453 | paginate: %{per_page: 1, page: 1, max_page: 8, total_count: 8}
454 | }
455 | end
456 |
457 | test "rummage call with search scope" do
458 | create_categories_and_products()
459 |
460 | rummage = %{search: %{category_name: "Category 1"}, sort: %{field: :name, order: :asc}}
461 |
462 | {queryable, _rummage} = Product.rummage(rummage)
463 |
464 | products = Repo.all(queryable)
465 |
466 | assert length(products) == 2
467 |
468 | assert Enum.map(products, & &1.name) == ["Product 1->1", "Product 2->1"]
469 |
470 | rummage = %{search: %{invalid_scope: "Category 1"}}
471 |
472 | assert_raise RuntimeError, ~r/No scope `invalid_scope`/, fn ->
473 | Product.rummage(rummage)
474 | end
475 | end
476 |
477 | test "rummage call with sort scope" do
478 | create_categories_and_products()
479 |
480 | rummage = %{sort: {:category_name, :asc}}
481 |
482 | {queryable, _rummage} = Product.rummage(rummage)
483 |
484 | products = Repo.all(queryable)
485 |
486 | assert length(products) == 8
487 |
488 | assert products |> Enum.map(& &1.name) |> Enum.sort() ==
489 | Enum.sort([
490 | "Product 2->1",
491 | "Product 1->1",
492 | "Product 2->2",
493 | "Product 1->2",
494 | "Product 2->3",
495 | "Product 1->3",
496 | "Product 2->4",
497 | "Product 1->4"
498 | ])
499 |
500 | rummage = %{sort: {:invalid_scope, :asc}}
501 |
502 | assert_raise RuntimeError, ~r/No scope `invalid_scope`/, fn ->
503 | Product.rummage(rummage)
504 | end
505 | end
506 |
507 | test "rummage call with paginate scope" do
508 | create_categories_and_products()
509 |
510 | rummage = %{paginate: {:category_show, 1}}
511 |
512 | {queryable, _rummage} = Product.rummage(rummage)
513 |
514 | products = Repo.all(queryable)
515 |
516 | assert length(products) == 5
517 |
518 | assert Enum.map(products, & &1.name) == [
519 | "Product 1->1",
520 | "Product 2->1",
521 | "Product 1->2",
522 | "Product 2->2",
523 | "Product 1->3"
524 | ]
525 |
526 | rummage = %{paginate: {:invalid_scope, 5}}
527 |
528 | assert_raise RuntimeError, ~r/No scope `invalid_scope`/, fn ->
529 | Product.rummage(rummage)
530 | end
531 | end
532 |
533 | test "rummage call with custom search scope" do
534 | create_categories_and_products()
535 |
536 | rummage = %{
537 | search: %{category_quarter: Float.ceil(Date.utc_today().month / 3)},
538 | sort: %{field: :name, order: :asc}
539 | }
540 |
541 | {queryable, _rummage} = Product.rummage(rummage)
542 |
543 | products = Repo.all(queryable)
544 |
545 | assert length(products) == 8
546 |
547 | assert Enum.map(products, & &1.name) == [
548 | "Product 1->1",
549 | "Product 1->2",
550 | "Product 1->3",
551 | "Product 1->4",
552 | "Product 2->1",
553 | "Product 2->2",
554 | "Product 2->3",
555 | "Product 2->4"
556 | ]
557 |
558 | rummage = %{search: %{invalid_scope: "Category 1"}}
559 |
560 | assert_raise RuntimeError, ~r/No scope `invalid_scope`/, fn ->
561 | Product.rummage(rummage)
562 | end
563 | end
564 |
565 | test "rummage call with custom sort scope" do
566 | create_categories_and_products()
567 |
568 | rummage = %{sort: {:category_microseconds, :desc}}
569 |
570 | {queryable, _rummage} = Product.rummage(rummage)
571 |
572 | products = Repo.all(queryable)
573 |
574 | assert length(products) == 8
575 |
576 | assert products |> Enum.map(& &1.name) |> Enum.sort() ==
577 | Enum.sort([
578 | "Product 2->1",
579 | "Product 1->1",
580 | "Product 2->2",
581 | "Product 1->2",
582 | "Product 2->3",
583 | "Product 1->3",
584 | "Product 2->4",
585 | "Product 1->4"
586 | ])
587 |
588 | rummage = %{sort: {:category_milliseconds, :desc}}
589 |
590 | assert_raise RuntimeError, ~r/No scope `category_milliseconds`/, fn ->
591 | Product.rummage(rummage)
592 | end
593 | end
594 |
595 | test "rummage call with custom paginte scope" do
596 | create_categories_and_products()
597 |
598 | rummage = %{paginate: {:small_page, 1}}
599 |
600 | {queryable, _rummage} = Product.rummage(rummage)
601 |
602 | products = Repo.all(queryable)
603 |
604 | assert length(products) == 5
605 |
606 | assert Enum.map(products, & &1.name) == [
607 | "Product 1->1",
608 | "Product 2->1",
609 | "Product 1->2",
610 | "Product 2->2",
611 | "Product 1->3"
612 | ]
613 |
614 | rummage = %{paginate: {:category_milliseconds, 1}}
615 |
616 | assert_raise RuntimeError, ~r/No scope `category_milliseconds`/, fn ->
617 | Product.rummage(rummage)
618 | end
619 | end
620 |
621 | test "rummage call with rummage_field for search" do
622 | create_categories_and_products()
623 |
624 | rummage = %{
625 | search: %{created_at_year: %{search_type: :eq, search_term: Date.utc_today().year}}
626 | }
627 |
628 | {queryable, _rummage} = Product.rummage(rummage)
629 |
630 | products = Repo.all(queryable)
631 |
632 | assert length(products) == 8
633 |
634 | assert Enum.map(products, & &1.name) == [
635 | "Product 1->1",
636 | "Product 2->1",
637 | "Product 1->2",
638 | "Product 2->2",
639 | "Product 1->3",
640 | "Product 2->3",
641 | "Product 1->4",
642 | "Product 2->4"
643 | ]
644 | end
645 |
646 | test "rummage call with rummage_field for sort" do
647 | create_categories_and_products()
648 |
649 | rummage = %{sort: %{field: :created_at_year, order: :asc}}
650 |
651 | {queryable, _rummage} = Product.rummage(rummage)
652 |
653 | products = Repo.all(queryable)
654 |
655 | assert length(products) == 8
656 |
657 | assert Enum.map(products, & &1.name) == [
658 | "Product 1->1",
659 | "Product 2->1",
660 | "Product 1->2",
661 | "Product 2->2",
662 | "Product 1->3",
663 | "Product 2->3",
664 | "Product 1->4",
665 | "Product 2->4"
666 | ]
667 | end
668 | end
669 |
--------------------------------------------------------------------------------