├── 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 | https://hexdocs.pm/rummage_ecto/Rummage.Ecto.html 4 | 5 | [![Build Status](https://travis-ci.org/annkissam/rummage_ecto.svg?branch=master)](https://travis-ci.org/annkissam/rummage_ecto) 6 | [![Coverage Status](https://coveralls.io/repos/github/annkissam/rummage_ecto/badge.svg?branch=master)](https://coveralls.io/github/annkissam/rummage_ecto?branch=master) 7 | [![Hex Version](http://img.shields.io/hexpm/v/rummage_ecto.svg?style=flat)](https://hex.pm/packages/rummage_ecto) 8 | [![hex.pm downloads](https://img.shields.io/hexpm/dt/rummage_ecto.svg)](https://hex.pm/packages/rummage_ecto) 9 | [![Hex docs](http://img.shields.io/badge/hex.pm-docs-green.svg?style=flat)](https://hexdocs.pm/rummage_ecto) 10 | [![docs](https://inch-ci.org/github/annkissam/rummage_ecto.svg)](http://inch-ci.org/github/annkissam/rummage_ecto) 11 | [![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](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 | --------------------------------------------------------------------------------