├── test
├── test_helper.exs
├── support
│ ├── data_case.ex
│ └── fixtures.ex
├── phoenix_datatables_test.exs
└── phoenix_datatables
│ └── query_test.exs
├── .tool-versions
├── example
├── assets
│ ├── .babelrc
│ ├── css
│ │ └── app.css
│ ├── static
│ │ ├── favicon.ico
│ │ ├── images
│ │ │ └── phoenix.png
│ │ └── robots.txt
│ ├── js
│ │ ├── table.js
│ │ ├── app.js
│ │ └── socket.js
│ ├── package.json
│ └── webpack.config.js
├── test
│ ├── test_helper.exs
│ ├── phoenix_datatables_example_web
│ │ ├── views
│ │ │ ├── layout_view_test.exs
│ │ │ ├── page_view_test.exs
│ │ │ ├── error_view_test.exs
│ │ │ └── item_table_view_test.exs
│ │ └── controllers
│ │ │ ├── page_controller_test.exs
│ │ │ ├── item_table_controller_test.exs
│ │ │ └── item_controller_test.exs
│ ├── support
│ │ ├── channel_case.ex
│ │ ├── conn_case.ex
│ │ ├── data_case.ex
│ │ └── factory.ex
│ ├── phoenix_datatables
│ │ ├── response_test.exs
│ │ ├── request_test.exs
│ │ └── query_test.exs
│ ├── phoenix_datatables_example
│ │ └── stock
│ │ │ └── stock_test.exs
│ └── phoenix_datatables_test.exs
├── lib
│ ├── phoenix_datatables_example_web
│ │ ├── views
│ │ │ ├── item_view.ex
│ │ │ ├── page_view.ex
│ │ │ ├── layout_view.ex
│ │ │ ├── error_view.ex
│ │ │ ├── item_table_view.ex
│ │ │ ├── changeset_view.ex
│ │ │ └── error_helpers.ex
│ │ ├── templates
│ │ │ ├── item
│ │ │ │ ├── new.html.eex
│ │ │ │ ├── edit.html.eex
│ │ │ │ ├── index.html.eex
│ │ │ │ ├── show.html.eex
│ │ │ │ └── form.html.eex
│ │ │ ├── page
│ │ │ │ └── index.html.eex
│ │ │ └── layout
│ │ │ │ └── app.html.eex
│ │ ├── controllers
│ │ │ ├── page_controller.ex
│ │ │ ├── item_table_controller.ex
│ │ │ ├── fallback_controller.ex
│ │ │ └── item_controller.ex
│ │ ├── gettext.ex
│ │ ├── router.ex
│ │ ├── channels
│ │ │ └── user_socket.ex
│ │ └── endpoint.ex
│ ├── phoenix_datatables_example.ex
│ ├── phoenix_datatables_example
│ │ ├── repo.ex
│ │ ├── stock
│ │ │ ├── category.ex
│ │ │ ├── unit.ex
│ │ │ ├── item.ex
│ │ │ └── stock.ex
│ │ └── application.ex
│ └── phoenix_datatables_example_web.ex
├── priv
│ ├── repo
│ │ ├── migrations
│ │ │ ├── 20190301143837_add_nilable_field.exs
│ │ │ ├── 20170816173416_create_units.exs
│ │ │ ├── 20170811151526_create_items.exs
│ │ │ └── 20170816140847_create_categories.exs
│ │ ├── seeds.exs
│ │ ├── seeds_load.exs
│ │ └── seeds
│ │ │ ├── units.exs
│ │ │ ├── nsn.exs
│ │ │ └── files
│ │ │ └── units_of_issue.csv
│ └── gettext
│ │ ├── en
│ │ └── LC_MESSAGES
│ │ │ └── errors.po
│ │ └── errors.pot
├── .gitignore
├── README.md
├── config
│ ├── test.exs
│ ├── config.exs
│ ├── dev.exs
│ └── prod.exs
├── mix.exs
└── mix.lock
├── .formatter.exs
├── .iex.exs
├── lib
├── phoenix_datatables
│ ├── repo.ex
│ ├── response.ex
│ ├── query
│ │ ├── attribute.ex
│ │ └── macros.ex
│ ├── request.ex
│ └── query.ex
└── phoenix_datatables.ex
├── .gitignore
├── circle.yml
├── LICENSE
├── mix.exs
├── mix.lock
├── .credo.exs
└── README.md
/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
--------------------------------------------------------------------------------
/.tool-versions:
--------------------------------------------------------------------------------
1 | elixir 1.7.4-otp-21
2 | erlang 21.1.1
3 | nodejs 10.12.0
4 |
--------------------------------------------------------------------------------
/example/assets/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
--------------------------------------------------------------------------------
/example/assets/css/app.css:
--------------------------------------------------------------------------------
1 | /* This file is for your main application css. */
2 |
3 | @import "./phoenix.css";
4 |
--------------------------------------------------------------------------------
/example/assets/static/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/smartmetals/phoenix_datatables/HEAD/example/assets/static/favicon.ico
--------------------------------------------------------------------------------
/example/test/test_helper.exs:
--------------------------------------------------------------------------------
1 | ExUnit.start()
2 |
3 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixDatatablesExample.Repo, :manual)
4 |
5 |
--------------------------------------------------------------------------------
/example/assets/static/images/phoenix.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/smartmetals/phoenix_datatables/HEAD/example/assets/static/images/phoenix.png
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/item_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemView do
2 | use PhoenixDatatablesExampleWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/page_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.PageView do
2 | use PhoenixDatatablesExampleWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/layout_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.LayoutView do
2 | use PhoenixDatatablesExampleWeb, :view
3 | end
4 |
--------------------------------------------------------------------------------
/.formatter.exs:
--------------------------------------------------------------------------------
1 | [
2 | import_deps: [:ecto, :phoenix],
3 | inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"],
4 | subdirectories: ["priv/*/migrations"]
5 | ]
6 |
--------------------------------------------------------------------------------
/.iex.exs:
--------------------------------------------------------------------------------
1 | alias PhoenixDatatablesExample.Repo
2 | alias PhoenixDatatablesExample.Stock.Item
3 | alias PhoenixDatatablesExample.Stock.Category
4 | alias PhoenixDatatablesExample.Stock.Unit
5 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/views/layout_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.LayoutViewTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/views/page_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.PageViewTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase, async: true
3 | end
4 |
--------------------------------------------------------------------------------
/example/assets/static/robots.txt:
--------------------------------------------------------------------------------
1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
2 | #
3 | # To ban all spiders from the entire site uncomment the next two lines:
4 | # User-agent: *
5 | # Disallow: /
6 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/item/new.html.eex:
--------------------------------------------------------------------------------
1 |
New Item
2 |
3 | <%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :create)) %>
4 |
5 | <%= link "Back", to: Routes.item_path(@conn, :index) %>
6 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/item/edit.html.eex:
--------------------------------------------------------------------------------
1 | Edit Item
2 |
3 | <%= render "form.html", Map.put(assigns, :action, Routes.item_path(@conn, :update, @item)) %>
4 |
5 | <%= link "Back", to: Routes.item_path(@conn, :index) %>
6 |
--------------------------------------------------------------------------------
/example/priv/repo/migrations/20190301143837_add_nilable_field.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Repo.Migrations.AddNilableField do
2 | use Ecto.Migration
3 |
4 | def change do
5 | alter table(:items) do
6 | add :nilable_field, :string
7 | end
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/controllers/page_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.PageController do
2 | use PhoenixDatatablesExampleWeb, :controller
3 |
4 | def index(conn, _params) do
5 | redirect(conn, to: Routes.item_path(conn, :index))
6 | end
7 | end
8 |
--------------------------------------------------------------------------------
/example/priv/repo/seeds.exs:
--------------------------------------------------------------------------------
1 | defmodule Seeds do
2 | def run do
3 | Mix.Task.run "ecto.migrate", []
4 | Mix.Task.run "app.start", []
5 |
6 | Code.load_file("priv/repo/seeds_load.exs")
7 | if Mix.env == :dev, do: Seeds.Load.all()
8 | end
9 |
10 | end
11 |
12 | Seeds.run
13 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/controllers/page_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.PageControllerTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase
3 |
4 | test "GET /", %{conn: conn} do
5 | conn = get conn, "/"
6 | assert redirected_to(conn) == Routes.item_path(conn, :index)
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample do
2 | @moduledoc """
3 | PhoenixDatatablesExample keeps the contexts that define your domain
4 | and business logic.
5 |
6 | Contexts are also responsible for managing your data, regardless
7 | if it comes from the database, an external API or others.
8 | """
9 | end
10 |
--------------------------------------------------------------------------------
/example/priv/repo/migrations/20170816173416_create_units.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Repo.Migrations.CreateUnits do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:units) do
6 | add :ui_code, :string
7 | add :description, :string
8 |
9 | timestamps()
10 | end
11 |
12 | alter table(:items) do
13 | add :unit_id, references(:units)
14 | end
15 |
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/controllers/item_table_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemTableController do
2 | use PhoenixDatatablesExampleWeb, :controller
3 | alias PhoenixDatatablesExample.Stock
4 |
5 | action_fallback PhoenixDatatablesExampleWeb.FallbackController
6 |
7 | def index(conn, params) do
8 | render(conn, "index.json", payload: Stock.datatable_items(params))
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Repo do
2 | use Ecto.Repo,
3 | otp_app: :phoenix_datatables_example,
4 | adapter: Ecto.Adapters.Postgres
5 | use PhoenixDatatables.Repo
6 |
7 | @doc """
8 | Dynamically loads the repository url from the
9 | DATABASE_URL environment variable.
10 | """
11 | def init(_, opts) do
12 | {:ok, Keyword.put(opts, :url, System.get_env("DATABASE_URL"))}
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.DataCase do
2 | use ExUnit.CaseTemplate
3 |
4 | using do
5 | quote do
6 | import Ecto
7 | import Ecto.Query
8 | alias PhoenixDatatables.Fixtures.Factory
9 | alias PhoenixDatatables.Fixtures.Stock.Category
10 | alias PhoenixDatatables.Fixtures.Stock.Unit
11 | alias PhoenixDatatables.Fixtures.Stock.Item
12 |
13 | end
14 |
15 | end
16 |
17 | setup do
18 | :ok
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/example/priv/repo/migrations/20170811151526_create_items.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Repo.Migrations.CreateItems do
2 | use Ecto.Migration
3 |
4 | def change do
5 | create table(:items) do
6 | add :nsn, :string
7 | add :rep_office, :string
8 | add :common_name, :string
9 | add :description, :string
10 | add :price, :float
11 | add :ui, :string
12 | add :aac, :string
13 |
14 | timestamps()
15 | end
16 |
17 | end
18 | end
19 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/item/index.html.eex:
--------------------------------------------------------------------------------
1 | Listing Items
2 |
3 |
4 |
5 |
6 | Nsn
7 | Rep office
8 | Common name
9 | Description
10 | Price
11 | Ui
12 | Aac
13 | Nilable
14 |
15 |
16 |
17 |
18 | <%= link "New Item", to: Routes.item_path(@conn, :new) %>
19 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/error_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ErrorView do
2 | use PhoenixDatatablesExampleWeb, :view
3 |
4 | def render("404.html", _assigns) do
5 | "Page not found"
6 | end
7 |
8 | def render("500.html", _assigns) do
9 | "Internal server error"
10 | end
11 |
12 | # In case no render clause matches or no
13 | # template is found, let's render it as 500
14 | def template_not_found(_template, assigns) do
15 | render "500.html", assigns
16 | end
17 | end
18 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/stock/category.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Stock.Category do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias PhoenixDatatablesExample.Stock.Category
5 | alias PhoenixDatatablesExample.Stock.Item
6 |
7 |
8 | schema "categories" do
9 | field :name, :string
10 | has_many :items, Item
11 |
12 | end
13 |
14 | @doc false
15 | def changeset(%Category{} = category, attrs) do
16 | category
17 | |> cast(attrs, [:name])
18 | |> validate_required([:name])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/repo.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Repo do
2 | @moduledoc """
3 | Provides a using macro which creates the `fetch_table` function.
4 |
5 | defmodule MyApp.Repo do
6 | use PhoenixDatatables.Repo
7 | end
8 | """
9 |
10 | @doc """
11 | Creates the `Repo.fetch_datatable` function.
12 | """
13 | defmacro __using__(_) do
14 | quote do
15 | def fetch_datatable(query, params, options \\ nil) do
16 | PhoenixDatatables.execute(query, params, __MODULE__, options)
17 | end
18 | end
19 | end
20 |
21 | end
22 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/stock/unit.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Stock.Unit do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias PhoenixDatatablesExample.Stock.Unit
5 | alias PhoenixDatatablesExample.Stock.Item
6 |
7 |
8 | schema "units" do
9 | field :description, :string
10 | field :ui_code, :string
11 | has_many :items, Item
12 |
13 | timestamps()
14 | end
15 |
16 | @doc false
17 | def changeset(%Unit{} = unit, attrs) do
18 | unit
19 | |> cast(attrs, [:ui_code, :description])
20 | |> validate_required([:ui_code, :description])
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/test/phoenix_datatables_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Test do
2 | use ExUnit.Case
3 | describe "this library" do
4 | test "is also tested in the example project" do
5 | IO.puts "Running tests in example project:"
6 | {_out, rc} = System.cmd("mix", ["test"], cd: "example/", into: IO.stream(:stdio, :line))
7 | result = if rc == 0 do
8 | "Example tests succeeded."
9 | else
10 | "Example tests failed, please review earlier output."
11 | end
12 | assert result == "Example tests succeeded."
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/example/assets/js/table.js:
--------------------------------------------------------------------------------
1 | import $ from 'jquery';
2 | import dt from 'datatables.net';
3 |
4 | export default function() {
5 | $(document).ready(() => {
6 | $('[data-datatable-server]').dataTable({
7 | lengthChange: false,
8 | serverSide: true,
9 | ajax: 'datatables/items',
10 | columns: [
11 | { data: "nsn" },
12 | { data: "category_name", name: "category.name"},
13 | { data: "common_name" },
14 | { data: "description" },
15 | { data: "price" },
16 | { data: "unit_description", name: "unit.description" },
17 | { data: "aac" },
18 | { data: "nilable_field" },
19 | ]
20 | });
21 | })
22 | }
23 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/item_table_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemTableView do
2 | use PhoenixDatatablesExampleWeb, :view
3 |
4 | def render("index.json", %{payload: payload}) do
5 | PhoenixDatatables.map_payload(payload, &item_json/1)
6 | end
7 |
8 | def item_json(item) do
9 | %{
10 | nsn: item.nsn,
11 | rep_office: item.rep_office,
12 | common_name: item.common_name,
13 | description: item.description,
14 | price: item.price,
15 | ui: item.ui,
16 | aac: item.aac,
17 | unit_description: item.unit.description,
18 | category_name: item.category.name,
19 | nilable_field: item.nilable_field
20 | }
21 | end
22 |
23 | end
24 |
--------------------------------------------------------------------------------
/example/.gitignore:
--------------------------------------------------------------------------------
1 | # App artifacts
2 | /_build
3 | /db
4 | /deps
5 | /*.ez
6 |
7 | # Generated on crash by the VM
8 | erl_crash.dump
9 |
10 | # Generated on crash by NPM
11 | npm-debug.log
12 |
13 | # Static artifacts
14 | /assets/node_modules
15 |
16 | # Since we are building assets from assets/,
17 | # we ignore priv/static. You may want to comment
18 | # this depending on your deployment strategy.
19 | /priv/static/
20 |
21 | # Files matching config/*.secret.exs pattern contain sensitive
22 | # data and you should not commit them into version control.
23 | #
24 | # Alternatively, you may comment the line below and commit the
25 | # secrets files as long as you replace their contents by environment
26 | # variables.
27 | /config/*.secret.exs
28 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/changeset_view.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ChangesetView do
2 | use PhoenixDatatablesExampleWeb, :view
3 |
4 | @doc """
5 | Traverses and translates changeset errors.
6 |
7 | See `Ecto.Changeset.traverse_errors/2` and
8 | `PhoenixDatatablesExampleWeb.ErrorHelpers.translate_error/1` for more details.
9 | """
10 | def translate_errors(changeset) do
11 | Ecto.Changeset.traverse_errors(changeset, &translate_error/1)
12 | end
13 |
14 | def render("error.json", %{changeset: changeset}) do
15 | # When encoded, the changeset returns its errors
16 | # as a JSON object. So we just pass it forward.
17 | %{errors: translate_errors(changeset)}
18 | end
19 | end
20 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # App artifacts
2 | /_build
3 | /db
4 | /deps
5 | /*.ez
6 |
7 | # Generated on crash by the VM
8 | erl_crash.dump
9 |
10 | # Generated on crash by NPM
11 | npm-debug.log
12 |
13 | # Static artifacts
14 | /assets/node_modules
15 |
16 | # Since we are building assets from assets/,
17 | # we ignore priv/static. You may want to comment
18 | # this depending on your deployment strategy.
19 | /priv/static/
20 |
21 | # Files matching config/*.secret.exs pattern contain sensitive
22 | # data and you should not commit them into version control.
23 | #
24 | # Alternatively, you may comment the line below and commit the
25 | # secrets files as long as you replace their contents by environment
26 | # variables.
27 | /config/*.secret.exs
28 |
29 | doc/
30 | .elixir_ls/
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/controllers/fallback_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.FallbackController do
2 | @moduledoc """
3 | Translates controller action results into valid `Plug.Conn` responses.
4 |
5 | See `Phoenix.Controller.action_fallback/1` for more details.
6 | """
7 | use PhoenixDatatablesExampleWeb, :controller
8 |
9 | def call(conn, {:error, %Ecto.Changeset{} = changeset}) do
10 | conn
11 | |> put_status(:unprocessable_entity)
12 | |> render(PhoenixDatatablesExampleWeb.ChangesetView, "error.json", changeset: changeset)
13 | end
14 |
15 | def call(conn, {:error, :not_found}) do
16 | conn
17 | |> put_status(:not_found)
18 | |> render(PhoenixDatatablesExampleWeb.ErrorView, "404.html", [])
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/example/priv/repo/seeds_load.exs:
--------------------------------------------------------------------------------
1 | NimbleCSV.define(Seeds.CSVParser, separator: "\,", escape: "\"")
2 |
3 | defmodule Seeds.Load do
4 | @seeds_path Path.expand("priv/repo/seeds")
5 | @seeds Path.join([@seeds_path, "*.exs"])
6 |
7 | def init do
8 | for file <- Path.wildcard(@seeds) do
9 | Code.load_file(file)
10 | end
11 | end
12 |
13 | def nsn(limit \\ 12000) do
14 | Seeds.NationalStockNumber.import_from_csv(Path.join([@seeds_path, "files/nsn-extract-4-5-17.csv"]), limit)
15 | end
16 |
17 | def units(limit \\ 200) do
18 | Seeds.UnitsOfIssue.import_from_csv(Path.join([@seeds_path, "files/units_of_issue.csv"]), limit)
19 | end
20 |
21 | def all(nsn_limit \\ 12000) do
22 | units()
23 | nsn(nsn_limit)
24 | end
25 | end
26 |
27 | Seeds.Load.init()
28 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/views/error_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ErrorViewTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase, async: true
3 |
4 | # Bring render/3 and render_to_string/3 for testing custom views
5 | import Phoenix.View
6 |
7 | test "renders 404.html" do
8 | assert render_to_string(PhoenixDatatablesExampleWeb.ErrorView, "404.html", []) ==
9 | "Page not found"
10 | end
11 |
12 | test "render 500.html" do
13 | assert render_to_string(PhoenixDatatablesExampleWeb.ErrorView, "500.html", []) ==
14 | "Internal server error"
15 | end
16 |
17 | test "render any other" do
18 | assert render_to_string(PhoenixDatatablesExampleWeb.ErrorView, "505.html", []) ==
19 | "Internal server error"
20 | end
21 | end
22 |
--------------------------------------------------------------------------------
/example/README.md:
--------------------------------------------------------------------------------
1 | # PhoenixDatatablesExample
2 |
3 | To start your Phoenix server:
4 |
5 | * Install dependencies with `mix deps.get`
6 | * Create and migrate your database with `mix ecto.setup`
7 | * Install Node.js dependencies with `cd assets && npm install`
8 | * Start Phoenix endpoint with `mix phx.server`
9 |
10 | Now you can visit [`localhost:4000`](http://localhost:4000) from your browser.
11 |
12 | Ready to run in production? Please [check our deployment guides](https://hexdocs.pm/phoenix/deployment.html).
13 |
14 | ## Learn more
15 |
16 | * Official website: http://www.phoenixframework.org/
17 | * Guides: https://hexdocs.pm/phoenix/overview.html
18 | * Docs: https://hexdocs.pm/phoenix
19 | * Mailing list: http://groups.google.com/group/phoenix-talk
20 | * Source: https://github.com/phoenixframework/phoenix
21 |
--------------------------------------------------------------------------------
/example/assets/js/app.js:
--------------------------------------------------------------------------------
1 | // We need to import the CSS so that webpack will load it.
2 | // The MiniCssExtractPlugin is used to separate it out into
3 | // its own CSS file.
4 | import css from "../css/app.css"
5 | import _ from "datatables.net-dt/css/jquery.datatables.css"
6 |
7 | // webpack automatically bundles all modules in your
8 | // entry points. Those entry points can be configured
9 | // in "webpack.config.js".
10 | //
11 | // Import dependencies
12 | //
13 | // If you no longer want to use a dependency, remember
14 | // to also remove its path from "config.paths.watched".
15 | import "phoenix_html"
16 | import init_table from "./table"
17 |
18 | // Import local files
19 | //
20 | // Local files can be imported directly using relative
21 | // paths "./socket" or full ones "web/static/js/socket".
22 |
23 | // import socket from "./socket"
24 |
25 | init_table();
26 |
--------------------------------------------------------------------------------
/example/priv/repo/migrations/20170816140847_create_categories.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Repo.Migrations.CreateCategories do
2 | use Ecto.Migration
3 | alias PhoenixDatatablesExample.Repo
4 |
5 | def up do
6 | create table(:categories) do
7 | add :name, :string
8 | end
9 |
10 | alter table(:items) do
11 | add :category_id, references(:categories)
12 | end
13 |
14 | flush()
15 |
16 | Ecto.Adapters.SQL.query!(Repo, """
17 | insert into categories (name) (select distinct rep_office from items);
18 | """)
19 |
20 | Ecto.Adapters.SQL.query!(Repo, """
21 | update items set category_id=(select id from categories where name=items.rep_office);
22 | """)
23 |
24 | end
25 |
26 | def down do
27 | alter table(:items) do
28 | remove :category_id
29 | end
30 | drop table(:categories)
31 | end
32 | end
33 |
--------------------------------------------------------------------------------
/example/assets/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "repository": {},
3 | "license": "MIT",
4 | "scripts": {
5 | "deploy": "webpack --mode production",
6 | "watch": "webpack --mode development --watch"
7 | },
8 | "dependencies": {
9 | "phoenix": "file:../deps/phoenix",
10 | "phoenix_html": "file:../deps/phoenix_html",
11 | "jquery": "^3.5.0",
12 | "datatables.net": "^1.11.3",
13 | "datatables.net-dt": "^1.10.15"
14 | },
15 | "devDependencies": {
16 | "@babel/core": "^7.0.0",
17 | "@babel/preset-env": "^7.0.0",
18 | "babel-loader": "^8.0.0",
19 | "copy-webpack-plugin": "^4.5.0",
20 | "css-loader": "^6.8.1",
21 | "mini-css-extract-plugin": "^0.4.0",
22 | "optimize-css-assets-webpack-plugin": "^6.0.1",
23 | "uglifyjs-webpack-plugin": "^1.2.4",
24 | "url-loader": "^1.1.2",
25 | "webpack": "4.4.0",
26 | "webpack-cli": "^5.1.4"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/gettext.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.Gettext do
2 | @moduledoc """
3 | A module providing Internationalization with a gettext-based API.
4 |
5 | By using [Gettext](https://hexdocs.pm/gettext),
6 | your module gains a set of macros for translations, for example:
7 |
8 | import PhoenixDatatablesExampleWeb.Gettext
9 |
10 | # Simple translation
11 | gettext "Here is the string to translate"
12 |
13 | # Plural translation
14 | ngettext "Here is the string to translate",
15 | "Here are the strings to translate",
16 | 3
17 |
18 | # Domain-based translation
19 | dgettext "errors", "Here is the error message to translate"
20 |
21 | See the [Gettext Docs](https://hexdocs.pm/gettext) for detailed usage.
22 | """
23 | use Gettext, otp_app: :phoenix_datatables_example
24 | end
25 |
--------------------------------------------------------------------------------
/example/config/test.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # We don't run a server during test. If one is required,
4 | # you can enable the server option below.
5 | config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
6 | http: [port: 4001],
7 | server: false
8 |
9 | # Print only warnings and errors during test unless verbose
10 | if System.get_env("VERBOSE") == "true" do
11 | config :logger, :console, format: "[$level] $message\n"
12 | else
13 | config :logger, level: :warn
14 | end
15 |
16 |
17 | # Configure your database
18 | config :phoenix_datatables_example, PhoenixDatatablesExample.Repo,
19 | adapter: Ecto.Adapters.Postgres,
20 | username: System.get_env("POSTGRES_USER") ||
21 | "whoami" |> System.cmd([]) |> elem(0) |> String.trim(),
22 | password: System.get_env("POSTGRES_PASSWORD"),
23 |
24 | database: "phoenix_datatables_example_test",
25 | hostname: "localhost",
26 | pool: Ecto.Adapters.SQL.Sandbox
27 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/router.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.Router do
2 | use PhoenixDatatablesExampleWeb, :router
3 |
4 | pipeline :browser do
5 | plug :accepts, ["html", "json"]
6 | plug :fetch_session
7 | plug :fetch_flash
8 | plug :protect_from_forgery
9 | plug :put_secure_browser_headers
10 | end
11 |
12 | pipeline :api do
13 | plug :accepts, ["json"]
14 | end
15 |
16 | scope "/", PhoenixDatatablesExampleWeb do
17 | pipe_through :browser # Use the default browser stack
18 |
19 | get "/", PageController, :index
20 | resources "/items", ItemController
21 | end
22 |
23 | scope "/datatables", PhoenixDatatablesExampleWeb do
24 | pipe_through :browser
25 |
26 | get "/items", ItemTableController, :index
27 | end
28 |
29 | # Other scopes may use custom stacks.
30 | # scope "/api", PhoenixDatatablesExampleWeb do
31 | # pipe_through :api
32 | # end
33 | end
34 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/stock/item.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Stock.Item do
2 | use Ecto.Schema
3 | import Ecto.Changeset
4 | alias PhoenixDatatablesExample.Stock.Item
5 | alias PhoenixDatatablesExample.Stock.Category
6 | alias PhoenixDatatablesExample.Stock.Unit
7 |
8 |
9 | schema "items" do
10 | field :aac, :string
11 | field :common_name, :string
12 | field :description, :string
13 | field :nsn, :string
14 | field :price, :float
15 | field :rep_office, :string
16 | field :ui, :string
17 | field :nilable_field, :string
18 | belongs_to :category, Category
19 | belongs_to :unit, Unit
20 |
21 | timestamps()
22 | end
23 |
24 | @doc false
25 | def changeset(%Item{} = item, attrs) do
26 | item
27 | |> cast(attrs, [:nsn, :rep_office, :common_name, :description, :price, :ui, :aac, :category_id, :nilable_field])
28 | |> validate_required([:nsn, :rep_office, :common_name, :description, :price, :ui, :aac])
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/item/show.html.eex:
--------------------------------------------------------------------------------
1 | Show Item
2 |
3 |
4 |
5 |
6 | Nsn:
7 | <%= @item.nsn %>
8 |
9 |
10 |
11 | Rep office:
12 | <%= @item.rep_office %>
13 |
14 |
15 |
16 | Common name:
17 | <%= @item.common_name %>
18 |
19 |
20 |
21 | Description:
22 | <%= @item.description %>
23 |
24 |
25 |
26 | Price:
27 | <%= @item.price %>
28 |
29 |
30 |
31 | Ui:
32 | <%= @item.ui %>
33 |
34 |
35 |
36 | Aac:
37 | <%= @item.aac %>
38 |
39 |
40 |
41 | Nilable:
42 | <%= @item.nilable_field %>
43 |
44 |
45 |
46 |
47 | <%= link "Edit", to: Routes.item_path(@conn, :edit, @item) %>
48 | <%= link "Back", to: Routes.item_path(@conn, :index) %>
49 |
--------------------------------------------------------------------------------
/example/priv/repo/seeds/units.exs:
--------------------------------------------------------------------------------
1 | defmodule Seeds.UnitsOfIssue do
2 | alias PhoenixDatatablesExample.Repo
3 | alias PhoenixDatatablesExample.Stock.Unit
4 | alias Ecto.Adapters.SQL
5 |
6 | @doc "Imports standard NSN units of issue table from the given CSV to the database"
7 | def import_from_csv(csv_path, limit) do
8 |
9 | for table_name <- tables_to_truncate() do
10 | SQL.query!(Repo, "TRUNCATE TABLE #{table_name} CASCADE")
11 | end
12 |
13 | File.stream!(Path.expand(csv_path))
14 | |> Seeds.CSVParser.parse_stream
15 | |> Stream.take(limit)
16 | |> Stream.each(fn [ui_code, description] ->
17 | process_csv_row(
18 | %{ui_code: ui_code,
19 | description: description
20 | }
21 | )
22 | end)
23 | |> Stream.run
24 |
25 | end
26 |
27 | defp process_csv_row(row) do
28 | %Unit{}
29 | |> Unit.changeset(row)
30 | |> Repo.insert
31 | end
32 |
33 | defp tables_to_truncate do
34 | ~w(
35 | units
36 | )
37 | end
38 |
39 | end
40 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/views/item_table_view_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemTableViewTest do
2 | use ExUnit.Case
3 |
4 | describe "render" do
5 | test "sets draw parameter based on request" do
6 | #TODO: these tests are broken and probably going to be removed; pending
7 | # our decision on API we may not prefer views in our example.
8 |
9 | # response = ItemTableView.render("index.json", %{
10 | # items: @items, draw: 5
11 | # })
12 | # assert response.draw == 5
13 | end
14 |
15 | test "includes items in response" do
16 | # response = ItemTableView.render("index.json", %{
17 | # items: @items, draw: 1
18 | # })
19 | # assert response.data |> Enum.count == 1
20 | end
21 |
22 | test "response can be encoded to json" do
23 | # response = ItemTableView.render("index.json", %{
24 | # items: @items, draw: 1
25 | # })
26 | # {result, _} = Poison.encode(response)
27 | # assert result == :ok
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/circle.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | jobs:
3 | build:
4 | docker:
5 | - image: circleci/elixir:latest
6 | environment:
7 | CI: true
8 | POSTGRES_USER: postgres
9 | POSTGRES_PASSWORD: postgres
10 | PHANTOMJS_VERSION: 2.1.1
11 | - image: postgres:9.6.2
12 | environment:
13 | POSTGRES_USER: postgres
14 | POSTGRES_PASSWORD: postgres
15 | steps:
16 | - checkout
17 | - restore_cache:
18 | key: dependency-cache-{{ arch }}-{{checksum "mix.lock"}}
19 | - run: mix local.hex --force
20 | - run: mix local.rebar --force
21 | - run: mix deps.get
22 | - run:
23 | command: mix deps.get
24 | working_directory: ~/project/example
25 | - run: mix test
26 | - run: mix dialyzer --halt-exit-status
27 | - save_cache:
28 | key: dependency-cache-{{ arch }}-{{checksum "mix.lock"}}
29 | paths:
30 | - deps
31 | - _build
32 | experimental:
33 | notify:
34 | branches:
35 | only:
36 | - master
37 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2017 Smart Metals Recycling
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
13 | all 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
21 | THE SOFTWARE.
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/page/index.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
<%= gettext "Welcome to %{name}!", name: "Phoenix" %>
3 |
A productive web framework that does not compromise speed and maintainability.
4 |
5 |
6 |
37 |
--------------------------------------------------------------------------------
/example/test/support/channel_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ChannelCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | channel tests.
5 |
6 | Such tests rely on `Phoenix.ChannelTest` and also
7 | import other functionality to make it easier
8 | to build common datastructures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with channels
21 | use Phoenix.ChannelTest
22 |
23 | # The default endpoint for testing
24 | @endpoint PhoenixDatatablesExampleWeb.Endpoint
25 | end
26 | end
27 |
28 |
29 | setup tags do
30 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixDatatablesExample.Repo)
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixDatatablesExample.Repo, {:shared, self()})
33 | end
34 | :ok
35 | end
36 |
37 | end
38 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/controllers/item_table_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemTableControllerTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase
3 | alias PhoenixDatatablesExample.Factory
4 | alias PhoenixDatatablesExample.Stock
5 | alias PhoenixDatatablesExample.Repo
6 | alias Ecto.Adapters.SQL
7 |
8 | setup %{conn: conn} do
9 | Seeds.Load.units()
10 | Stock.create_item(Factory.item)
11 | SQL.query!(Repo, """
12 | insert into categories (name) (select distinct rep_office from items);
13 | """)
14 |
15 | SQL.query!(Repo, """
16 | update items set category_id=(select id from categories where name=items.rep_office);
17 | """)
18 |
19 | SQL.query!(Repo, """
20 | update items set unit_id=(select id from units where ui_code=items.ui);
21 | """)
22 | {:ok, conn: put_req_header(conn, "accept", "application/json")}
23 | end
24 |
25 | describe "index" do
26 | test "lists all items_tables", %{conn: conn} do
27 | conn = get conn, Routes.item_table_path(conn, :index), Factory.raw_request
28 | assert json_response(conn, 200)["data"] |> List.first |> Map.get("nsn") == "NSN1"
29 | end
30 | end
31 |
32 | end
33 |
--------------------------------------------------------------------------------
/example/config/config.exs:
--------------------------------------------------------------------------------
1 | # This file is responsible for configuring your application
2 | # and its dependencies with the aid of the Mix.Config module.
3 | #
4 | # This configuration file is loaded before any dependency and
5 | # is restricted to this project.
6 | use Mix.Config
7 |
8 | # General application configuration
9 | config :phoenix_datatables_example,
10 | ecto_repos: [PhoenixDatatablesExample.Repo]
11 |
12 | # Configures the endpoint
13 | config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
14 | url: [host: "localhost"],
15 | secret_key_base: "AHiXn515jzW37Qp0hqSoPkvEcp+1r/AUNxpBTlODXIw0/OzdkWQwO2BrFdjXWdC6",
16 | render_errors: [view: PhoenixDatatablesExampleWeb.ErrorView, accepts: ~w(html json)],
17 | pubsub: [name: PhoenixDatatablesExample.PubSub,
18 | adapter: Phoenix.PubSub.PG2]
19 |
20 | # Configures Elixir's Logger
21 | config :logger, :console,
22 | format: "$time $metadata[$level] $message\n",
23 | metadata: [:request_id]
24 |
25 | # Use Jason for JSON parsing in Phoenix
26 | config :phoenix, :json_library, Jason
27 |
28 | # Import environment specific config. This must remain at the bottom
29 | # of this file so it overrides the configuration defined above.
30 | import_config "#{Mix.env}.exs"
31 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/channels/user_socket.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.UserSocket do
2 | use Phoenix.Socket
3 |
4 | ## Channels
5 | # channel "room:*", PhoenixDatatablesExampleWeb.RoomChannel
6 |
7 | # Socket params are passed from the client and can
8 | # be used to verify and authenticate a user. After
9 | # verification, you can put default assigns into
10 | # the socket that will be set for all channels, ie
11 | #
12 | # {:ok, assign(socket, :user_id, verified_user_id)}
13 | #
14 | # To deny connection, return `:error`.
15 | #
16 | # See `Phoenix.Token` documentation for examples in
17 | # performing token verification on connect.
18 | def connect(_params, socket) do
19 | {:ok, socket}
20 | end
21 |
22 | # Socket id's are topics that allow you to identify all sockets for a given user:
23 | #
24 | # def id(socket), do: "user_socket:#{socket.assigns.user_id}"
25 | #
26 | # Would allow you to broadcast a "disconnect" event and terminate
27 | # all active sockets and channels for a given user:
28 | #
29 | # PhoenixDatatablesExampleWeb.Endpoint.broadcast("user_socket:#{user.id}", "disconnect", %{})
30 | #
31 | # Returning `nil` makes this socket anonymous.
32 | def id(_socket), do: nil
33 | end
34 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/application.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Application do
2 | use Application
3 |
4 | # See https://hexdocs.pm/elixir/Application.html
5 | # for more information on OTP Applications
6 | def start(_type, _args) do
7 | import Supervisor.Spec
8 |
9 | # Define workers and child supervisors to be supervised
10 | children = [
11 | # Start the Ecto repository
12 | supervisor(PhoenixDatatablesExample.Repo, []),
13 | # Start the endpoint when the application starts
14 | supervisor(PhoenixDatatablesExampleWeb.Endpoint, []),
15 | # Start your own worker by calling: PhoenixDatatablesExample.Worker.start_link(arg1, arg2, arg3)
16 | # worker(PhoenixDatatablesExample.Worker, [arg1, arg2, arg3]),
17 | ]
18 |
19 | # See https://hexdocs.pm/elixir/Supervisor.html
20 | # for other strategies and supported options
21 | opts = [strategy: :one_for_one, name: PhoenixDatatablesExample.Supervisor]
22 | Supervisor.start_link(children, opts)
23 | end
24 |
25 | # Tell Phoenix to update the endpoint configuration
26 | # whenever the application is updated.
27 | def config_change(changed, _new, removed) do
28 | PhoenixDatatablesExampleWeb.Endpoint.config_change(changed, removed)
29 | :ok
30 | end
31 | end
32 |
--------------------------------------------------------------------------------
/example/test/support/conn_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ConnCase do
2 | @moduledoc """
3 | This module defines the test case to be used by
4 | tests that require setting up a connection.
5 |
6 | Such tests rely on `Phoenix.ConnTest` and also
7 | import other functionality to make it easier
8 | to build common datastructures and query the data layer.
9 |
10 | Finally, if the test case interacts with the database,
11 | it cannot be async. For this reason, every test runs
12 | inside a transaction which is reset at the beginning
13 | of the test unless the test case is marked as async.
14 | """
15 |
16 | use ExUnit.CaseTemplate
17 |
18 | using do
19 | quote do
20 | # Import conveniences for testing with connections
21 | use Phoenix.ConnTest
22 | alias PhoenixDatatablesExampleWeb.Router.Helpers, as: Routes
23 |
24 | # The default endpoint for testing
25 | @endpoint PhoenixDatatablesExampleWeb.Endpoint
26 | end
27 | end
28 |
29 |
30 | setup tags do
31 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixDatatablesExample.Repo)
32 | unless tags[:async] do
33 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixDatatablesExample.Repo, {:shared, self()})
34 | end
35 | {:ok, conn: Phoenix.ConnTest.build_conn()}
36 | end
37 |
38 | end
39 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/layout/app.html.eex:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 | Hello PhoenixDatatablesExample!
11 | ">
12 |
13 |
14 |
15 |
16 |
24 |
25 |
<%= get_flash(@conn, :info) %>
26 |
<%= get_flash(@conn, :error) %>
27 |
28 |
29 | <%= render @view_module, @view_template, assigns %>
30 |
31 |
32 |
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/example/assets/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 | const glob = require('glob');
3 | const MiniCssExtractPlugin = require('mini-css-extract-plugin');
4 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
5 | const OptimizeCSSAssetsPlugin = require('optimize-css-assets-webpack-plugin');
6 | const CopyWebpackPlugin = require('copy-webpack-plugin');
7 |
8 | module.exports = (env, options) => ({
9 | optimization: {
10 | minimizer: [
11 | new UglifyJsPlugin({ cache: true, parallel: true, sourceMap: false }),
12 | new OptimizeCSSAssetsPlugin({})
13 | ]
14 | },
15 | entry: {
16 | './js/app.js': ['./js/app.js'].concat(glob.sync('./vendor/**/*.js'))
17 | },
18 | output: {
19 | filename: 'app.js',
20 | path: path.resolve(__dirname, '../priv/static/js')
21 | },
22 | module: {
23 | rules: [
24 | { test: /\.png$/, loader: "url-loader?mimetype=image/png" },
25 | {
26 | test: /\.js$/,
27 | exclude: /node_modules/,
28 | use: {
29 | loader: 'babel-loader'
30 | }
31 | },
32 | {
33 | test: /\.css$/,
34 | use: [MiniCssExtractPlugin.loader, 'css-loader']
35 | }
36 | ]
37 | },
38 | plugins: [
39 | new MiniCssExtractPlugin({ filename: '../css/app.css' }),
40 | new CopyWebpackPlugin([{ from: 'static/', to: '../' }])
41 | ]
42 | });
43 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/response.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Response.Payload do
2 | @moduledoc """
3 | A struct which serialies with `Jason` to the json response expected by the
4 | Datatables client library.
5 | """
6 | @derive {Jason.Encoder, only: [:draw, :recordsTotal, :recordsFiltered, :data, :error]}
7 | defstruct draw: 0,
8 | recordsTotal: 0,
9 | recordsFiltered: 0,
10 | data: [%{}],
11 | error: nil
12 |
13 | @type t :: %__MODULE__{}
14 | end
15 |
16 | defmodule PhoenixDatatables.Response do
17 | @moduledoc """
18 | Provides a Payload constructor.
19 | """
20 | alias PhoenixDatatables.Response.Payload
21 |
22 | @doc """
23 | Construct the `PhoenixDatatables.Response.Payload` based on the constituent data:
24 |
25 | * `:data` - A list of maps representing the query results.
26 | * `:recordsTotal` - The number of records available before client's requested filters are applied.
27 | * `:recordsFiltered` - The number of records available after client's requested filters are applied.
28 | * `:draw` - The draw counter received from the client is echoed back to distinguish multiple responses.
29 |
30 | """
31 | def new(data, draw, total_entries, filtered_entries) do
32 | %Payload {
33 | draw: draw,
34 | recordsTotal: total_entries,
35 | recordsFiltered: filtered_entries,
36 | data: data,
37 | error: nil
38 | }
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/views/error_helpers.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ErrorHelpers do
2 | @moduledoc """
3 | Conveniences for translating and building error messages.
4 | """
5 |
6 | use Phoenix.HTML
7 |
8 | @doc """
9 | Generates tag for inlined form input errors.
10 | """
11 | def error_tag(form, field) do
12 | Enum.map(Keyword.get_values(form.errors, field), fn (error) ->
13 | content_tag :span, translate_error(error), class: "help-block"
14 | end)
15 | end
16 |
17 | @doc """
18 | Translates an error message using gettext.
19 | """
20 | def translate_error({msg, opts}) do
21 | # Because error messages were defined within Ecto, we must
22 | # call the Gettext module passing our Gettext backend. We
23 | # also use the "errors" domain as translations are placed
24 | # in the errors.po file.
25 | # Ecto will pass the :count keyword if the error message is
26 | # meant to be pluralized.
27 | # On your own code and templates, depending on whether you
28 | # need the message to be pluralized or not, this could be
29 | # written simply as:
30 | #
31 | # dngettext "errors", "1 file", "%{count} files", count
32 | # dgettext "errors", "is invalid"
33 | #
34 | if count = opts[:count] do
35 | Gettext.dngettext(PhoenixDatatablesExampleWeb.Gettext, "errors", msg, msg, count, opts)
36 | else
37 | Gettext.dgettext(PhoenixDatatablesExampleWeb.Gettext, "errors", msg, opts)
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/query/attribute.ex:
--------------------------------------------------------------------------------
1 | # from: https://github.com/valyukov/ex_sieve
2 | # Copyright (c) 2016 Vlad Alyukov
3 |
4 | defmodule PhoenixDatatables.Query.Attribute do
5 | @moduledoc false
6 |
7 | defstruct name: nil, parent: nil
8 |
9 | @type t :: %__MODULE__{}
10 |
11 | @spec extract(String.t, atom) :: t | {:error, :attribute_not_found}
12 | def extract(key, module) do
13 | case get_name(module, key) || get_assoc_name(module, key) do
14 | nil -> {:error, :attribute_not_found}
15 | {_assoc, nil} -> {:error, :attribute_not_found}
16 | {assoc, name} -> %__MODULE__{parent: assoc, name: name}
17 | name -> %__MODULE__{parent: nil, name: name}
18 | end
19 | end
20 |
21 | defp get_assoc_name(module, key) do
22 | case get_assoc(module, key) do
23 | nil ->
24 | nil
25 | assoc ->
26 | key = String.replace_prefix(key, "#{assoc}.", "")
27 | {assoc, get_name(module.__schema__(:association, assoc), key)}
28 | end
29 | end
30 |
31 | defp get_assoc(module, key) do
32 | :associations
33 | |> module.__schema__
34 | |> find_field(key)
35 | end
36 |
37 | defp get_name(%{related: module}, key) do
38 | get_name(module, key)
39 | end
40 | defp get_name(module, key) do
41 | :fields
42 | |> module.__schema__
43 | |> find_field(key)
44 | end
45 |
46 | defp find_field(fields, key) do
47 | fields
48 | |> Enum.sort_by(&String.length(to_string(&1)), &>=/2)
49 | |> Enum.find(&String.starts_with?(key, to_string(&1)))
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/example/priv/repo/seeds/nsn.exs:
--------------------------------------------------------------------------------
1 | defmodule Seeds.NationalStockNumber do
2 | alias PhoenixDatatablesExample.Repo
3 | alias PhoenixDatatablesExample.Stock.Item
4 | alias Ecto.Adapters.SQL
5 |
6 | @doc "Imports departments and communes from the given CSV to the database"
7 | def import_from_csv(csv_path, limit) do
8 |
9 | for table_name <- tables_to_truncate() do
10 | SQL.query!(Repo, "TRUNCATE TABLE #{table_name} CASCADE")
11 | end
12 |
13 | File.stream!(Path.expand(csv_path))
14 | |> Seeds.CSVParser.parse_stream
15 | |> Stream.take(limit)
16 | |> Stream.each(fn [nsn, rep_office, common_name, description, price, ui, aac] ->
17 | process_csv_row(
18 | %{aac: aac,
19 | common_name: common_name,
20 | description: description,
21 | nsn: nsn,
22 | price: price,
23 | rep_office: rep_office,
24 | ui: ui
25 | }
26 | )
27 | end)
28 | |> Stream.run
29 |
30 | SQL.query!(Repo, """
31 | update items set unit_id=(select id from units where ui_code=items.ui);
32 | """)
33 |
34 | SQL.query!(Repo, """
35 | insert into categories (name) (select distinct rep_office from items);
36 | """)
37 |
38 | SQL.query!(Repo, """
39 | update items set category_id=(select id from categories where name=items.rep_office);
40 | """)
41 | end
42 |
43 | defp process_csv_row(row) do
44 | %Item{}
45 | |> Item.changeset(row)
46 | |> Repo.insert
47 | end
48 |
49 | defp tables_to_truncate do
50 | ~w(
51 | items
52 | categories
53 | )
54 | end
55 |
56 | end
57 |
--------------------------------------------------------------------------------
/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :phoenix_datatables,
7 | version: "0.4.4",
8 | elixir: "~> 1.5",
9 | elixirc_paths: elixirc_paths(Mix.env),
10 | deps: deps(),
11 | docs: [main: "readme", extras: ["README.md"]],
12 | package: package(),
13 | description: description()
14 | ]
15 | end
16 |
17 | defp elixirc_paths(:test), do: ["lib", "test/support"]
18 | defp elixirc_paths(_), do: ["lib"]
19 |
20 | # Configuration for the OTP application.
21 | #
22 | # Type `mix help compile.app` for more information.
23 | def application do
24 | []
25 | end
26 |
27 | # Specifies your project dependencies.
28 | #
29 | # Type `mix help deps` for examples and options.
30 | defp deps do
31 | [
32 | {:ecto_sql, "~> 3.0"},
33 | {:ex_doc, "~> 0.16", only: :dev},
34 | {:plug, "~> 1.4", only: :dev}, #only used in specs/docs
35 | {:dialyxir, "~> 1.0.0-rc.6", only: :dev, runtime: false},
36 | {:jason, "~> 1.1"},
37 | {:credo, "~> 0.8", only: [:dev, :test], runtime: false}
38 | ]
39 | end
40 |
41 | defp description do
42 | """
43 | Implements a server-side API for the jQuery Datatables library. Provides
44 | sort, search and pagination based on parameters received in client request.
45 | """
46 | end
47 |
48 | defp package do
49 | [files: ["lib", "mix.exs", "README.md", "LICENSE"],
50 | maintainers: ["Jeremy Huffman"],
51 | licenses: ["MIT"],
52 | links: %{"GitHub" => "https://github.com/smartmetals/phoenix_datatables"}]
53 | end
54 | end
55 |
--------------------------------------------------------------------------------
/example/test/support/data_case.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.DataCase do
2 | @moduledoc """
3 | This module defines the setup for tests requiring
4 | access to the application's data layer.
5 |
6 | You may define functions here to be used as helpers in
7 | your tests.
8 |
9 | Finally, if the test case interacts with the database,
10 | it cannot be async. For this reason, every test runs
11 | inside a transaction which is reset at the beginning
12 | of the test unless the test case is marked as async.
13 | """
14 |
15 | use ExUnit.CaseTemplate
16 |
17 | using do
18 | quote do
19 | alias PhoenixDatatablesExample.Repo
20 |
21 | import Ecto
22 | import Ecto.Changeset
23 | import Ecto.Query
24 | import PhoenixDatatablesExample.DataCase
25 | end
26 | end
27 |
28 | setup tags do
29 | :ok = Ecto.Adapters.SQL.Sandbox.checkout(PhoenixDatatablesExample.Repo)
30 |
31 | unless tags[:async] do
32 | Ecto.Adapters.SQL.Sandbox.mode(PhoenixDatatablesExample.Repo, {:shared, self()})
33 | end
34 |
35 | :ok
36 | end
37 |
38 | @doc """
39 | A helper that transform changeset errors to a map of messages.
40 |
41 | assert {:error, changeset} = Accounts.create_user(%{password: "short"})
42 | assert "password is too short" in errors_on(changeset).password
43 | assert %{password: ["password is too short"]} = errors_on(changeset)
44 |
45 | """
46 | def errors_on(changeset) do
47 | Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
48 | Enum.reduce(opts, message, fn {key, value}, acc ->
49 | String.replace(acc, "%{#{key}}", to_string(value))
50 | end)
51 | end)
52 | end
53 |
54 | def load_test_seeds(nsn_limit \\ 10) do
55 | unless Code.ensure_loaded?(Seeds.Load) do
56 | Code.load_file("priv/repo/seeds_load.exs")
57 | end
58 | Seeds.Load.all(nsn_limit)
59 | end
60 |
61 | end
62 |
--------------------------------------------------------------------------------
/test/phoenix_datatables/query_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.QueryTest do
2 | use PhoenixDatatables.DataCase
3 |
4 | alias PhoenixDatatables.Request
5 | alias PhoenixDatatables.Query
6 | alias PhoenixDatatables.QueryException
7 |
8 | @join_request Factory.join_request
9 |
10 | describe "search" do
11 | test "raises an exception if dot-notation used in column name for simple queryable" do
12 | query = Item
13 |
14 | params =
15 | Map.put(
16 | @join_request,
17 | "search",
18 | %{"regex" => "false", "value" => "1NSN"}
19 | ) |> Request.receive
20 |
21 | assert_raise QueryException, fn ->
22 | Query.search(query, params)
23 | end
24 | end
25 |
26 | test "raises an exception when no columns accompany a query with subqueries" do
27 |
28 | query = from item in subquery(Item),
29 | join: category in assoc(item, :category),
30 | join: unit in assoc(item, :units)
31 |
32 | params =
33 | Map.put(
34 | @join_request,
35 | "search",
36 | %{"regex" => "false", "value" => "1NSN"}
37 | ) |> Request.receive
38 |
39 | assert_raise QueryException, fn ->
40 | Query.search(query, params)
41 | end
42 | end
43 |
44 | test "raises an exception when no columns accompany join without assoc" do
45 |
46 | query = from item in Item,
47 | join: subitem in subquery(Item), on: subitem.id == item.id,
48 | join: category in assoc(item, :category),
49 | join: unit in assoc(item, :units)
50 |
51 | params =
52 | Map.put(
53 | @join_request,
54 | "search",
55 | %{"regex" => "false", "value" => "1NSN"}
56 | ) |> Request.receive
57 |
58 | assert_raise QueryException, fn ->
59 | Query.search(query, params)
60 | end
61 | end
62 |
63 | end
64 |
65 | end
66 |
67 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/controllers/item_controller.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemController do
2 | use PhoenixDatatablesExampleWeb, :controller
3 |
4 | alias PhoenixDatatablesExample.Stock
5 | alias PhoenixDatatablesExample.Stock.Item
6 |
7 | def index(conn, _params) do
8 | render(conn, "index.html")
9 | end
10 |
11 | def new(conn, _params) do
12 | changeset = Stock.change_item(%Item{})
13 | render(conn, "new.html", changeset: changeset)
14 | end
15 |
16 | def create(conn, %{"item" => item_params}) do
17 | case Stock.create_item(item_params) do
18 | {:ok, item} ->
19 | conn
20 | |> put_flash(:info, "Item created successfully.")
21 | |> redirect(to: Routes.item_path(conn, :show, item))
22 | {:error, %Ecto.Changeset{} = changeset} ->
23 | render(conn, "new.html", changeset: changeset)
24 | end
25 | end
26 |
27 | def show(conn, %{"id" => id}) do
28 | item = Stock.get_item!(id)
29 | render(conn, "show.html", item: item)
30 | end
31 |
32 | def edit(conn, %{"id" => id}) do
33 | item = Stock.get_item!(id)
34 | changeset = Stock.change_item(item)
35 | render(conn, "edit.html", item: item, changeset: changeset)
36 | end
37 |
38 | def update(conn, %{"id" => id, "item" => item_params}) do
39 | item = Stock.get_item!(id)
40 |
41 | case Stock.update_item(item, item_params) do
42 | {:ok, item} ->
43 | conn
44 | |> put_flash(:info, "Item updated successfully.")
45 | |> redirect(to: Routes.item_path(conn, :show, item))
46 | {:error, %Ecto.Changeset{} = changeset} ->
47 | render(conn, "edit.html", item: item, changeset: changeset)
48 | end
49 | end
50 |
51 | def delete(conn, %{"id" => id}) do
52 | item = Stock.get_item!(id)
53 | {:ok, _item} = Stock.delete_item(item)
54 |
55 | conn
56 | |> put_flash(:info, "Item deleted successfully.")
57 | |> redirect(to: Routes.item_path(conn, :index))
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/example/priv/repo/seeds/files/units_of_issue.csv:
--------------------------------------------------------------------------------
1 | AM,Ampoule
2 | AT,Assortment
3 | AY,Assembly
4 | BA,Ball
5 | BD,Bundle
6 | BE,Bale
7 | BF,Board Foot
8 | BG,Bag
9 | BK,Book
10 | BL,Barrel
11 | BO,Bolt
12 | BQ,Briquet
13 | BR,Bar
14 | BT,Bottle
15 | BX,Box
16 | CA,Cartridge
17 | CB,Carboy
18 | CC,Cubic Centimeter
19 | CD,Cubic Yard
20 | CE,Cone
21 | CF,Cubic Foot
22 | CG,Centigram
23 | CI,Cubic Inch
24 | CK,Cake
25 | CL,Coil
26 | CM,Centimeter
27 | CN,Can
28 | CO,Container
29 | CU,Curie
30 | CY,Cylinder
31 | RA,Ration
32 | CZ,Cubic Meter
33 | DC,Decagram
34 | DE,Decimeter
35 | DG,Decigram
36 | DL,Deciliter
37 | DM,Dram
38 | DR,Drum
39 | DW,Pennyweight
40 | DZ,Dozen
41 | EA,Each
42 | EX,Exposure
43 | FD,Fold
44 | FR,Frame
45 | FT,Foot
46 | FV,Five
47 | FY,Fifty
48 | GG,Great Gross
49 | GI,Gill
50 | GL,Gallon
51 | GM,Gram
52 | GN,Grain
53 | GP,Group
54 | GR,Gross
55 | HD,Hundred
56 | HF,Hundred Feet
57 | HK,Hank
58 | HP,Hundred Pounds
59 | HS,Hundred Square
60 | HW,Hundred Weight
61 | HY,Hundred Yards
62 | IN,Inch
63 | JR,Jar
64 | KG,Kilogram
65 | KM,Kilometer
66 | KR,Carat
67 | KT,Kit
68 | LB,Pound
69 | LF,Linear Foot
70 | LG,Length
71 | LI,Liter
72 | LI,Liter
73 | MC,Thousand Cubic
74 | MC,Thousand
75 | ME,Meal
76 | MF,Thousand Feet
77 | MG,Milligram
78 | MI,Mile
79 | ML,Milliliter
80 | MM,Millimeter
81 | MR,Meter
82 | MX,Thousand
83 | OT,Outfit
84 | OZ,Ounce
85 | PD,Pad
86 | PG,Package
87 | PI,Pillow
88 | PM,Plate
89 | PR,Pair
90 | PT,Pint
91 | PX,Pellet
92 | PZ,Packet
93 | QT,Quart
94 | RD,Round
95 | RL,Reel
96 | RM,Ream
97 | RO,Roll
98 | RX,Thousand Rounds
99 | SD,Skid
100 | SE,Set
101 | SF,Square Foot
102 | SH,Sheet
103 | SI,Square Inch
104 | SK,Skein
105 | SL,Spool
106 | SM,Square Meter
107 | SO,Shot
108 | SP,Strip
109 | SQ,Square
110 | SX,Stick
111 | SY,Square Yard
112 | TD,Twenty-four
113 | TE,Ten
114 | TF,Twenty-five
115 | TN,"Ton (2,000 lb)"
116 | TO,Troy Ounce
117 | TS,Thirty-six
118 | TT,Tablet
119 | TU,Tube
120 | US,U.S.P. Unit
121 | VI,Vial
122 | YD,Yard
123 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/templates/item/form.html.eex:
--------------------------------------------------------------------------------
1 | <%= form_for @changeset, @action, fn f -> %>
2 | <%= if @changeset.action do %>
3 |
4 |
Oops, something went wrong! Please check the errors below.
5 |
6 | <% end %>
7 |
8 |
9 | <%= label f, :nsn, class: "control-label" %>
10 | <%= text_input f, :nsn, class: "form-control" %>
11 | <%= error_tag f, :nsn %>
12 |
13 |
14 |
15 | <%= label f, :rep_office, class: "control-label" %>
16 | <%= text_input f, :rep_office, class: "form-control" %>
17 | <%= error_tag f, :rep_office %>
18 |
19 |
20 |
21 | <%= label f, :common_name, class: "control-label" %>
22 | <%= text_input f, :common_name, class: "form-control" %>
23 | <%= error_tag f, :common_name %>
24 |
25 |
26 |
27 | <%= label f, :description, class: "control-label" %>
28 | <%= text_input f, :description, class: "form-control" %>
29 | <%= error_tag f, :description %>
30 |
31 |
32 |
33 | <%= label f, :price, class: "control-label" %>
34 | <%= number_input f, :price, step: "any", class: "form-control" %>
35 | <%= error_tag f, :price %>
36 |
37 |
38 |
39 | <%= label f, :ui, class: "control-label" %>
40 | <%= text_input f, :ui, class: "form-control" %>
41 | <%= error_tag f, :ui %>
42 |
43 |
44 |
45 | <%= label f, :aac, class: "control-label" %>
46 | <%= text_input f, :aac, class: "form-control" %>
47 | <%= error_tag f, :aac %>
48 |
49 |
50 |
51 | <%= label f, :nilable_field, class: "control-label" %>
52 | <%= text_input f, :nilable_field, class: "form-control" %>
53 | <%= error_tag f, :nilable_field %>
54 |
55 |
56 |
57 | <%= submit "Submit", class: "btn btn-primary" %>
58 |
59 | <% end %>
60 |
--------------------------------------------------------------------------------
/example/mix.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Mixfile do
2 | use Mix.Project
3 |
4 | def project do
5 | [
6 | app: :phoenix_datatables_example,
7 | version: "0.0.1",
8 | elixir: "~> 1.5",
9 | elixirc_paths: elixirc_paths(Mix.env),
10 | compilers: [:phoenix, :gettext] ++ Mix.compilers,
11 | start_permanent: Mix.env == :prod,
12 | aliases: aliases(),
13 | deps: deps()
14 | ]
15 | end
16 |
17 | # Configuration for the OTP application.
18 | #
19 | # Type `mix help compile.app` for more information.
20 | def application do
21 | [
22 | mod: {PhoenixDatatablesExample.Application, []},
23 | extra_applications: [:logger, :runtime_tools]
24 | ]
25 | end
26 |
27 | # Specifies which paths to compile per environment.
28 | defp elixirc_paths(:test), do: ["lib", "test/support"]
29 | defp elixirc_paths(_), do: ["lib"]
30 |
31 | # Specifies your project dependencies.
32 | #
33 | # Type `mix help deps` for examples and options.
34 | defp deps do
35 | [
36 | {:phoenix_datatables, path: ".."},
37 | {:phoenix, "~> 1.4.0"},
38 | {:phoenix_pubsub, "~> 1.1"},
39 | {:phoenix_ecto, "~> 4.0"},
40 | {:ecto_sql, "~> 3.0"},
41 | {:postgrex, ">= 0.0.0"},
42 | {:phoenix_html, "~> 2.11"},
43 | {:phoenix_live_reload, "~> 1.2", only: :dev},
44 | {:gettext, "~> 0.11"},
45 | {:jason, "~> 1.0"},
46 | {:plug_cowboy, "~> 2.0"},
47 | {:nimble_csv, "~> 0.3"},
48 | {:dialyxir, "~> 0.5", only: :dev, runtime: false},
49 | ]
50 | end
51 |
52 | # Aliases are shortcuts or tasks specific to the current project.
53 | # For example, to create, migrate and run the seeds file at once:
54 | #
55 | # $ mix ecto.setup
56 | #
57 | # See the documentation for `Mix` for more info on aliases.
58 | defp aliases do
59 | [
60 | "ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
61 | "ecto.reset": ["ecto.drop", "ecto.setup"],
62 | test: ["ecto.create --quiet", "ecto.migrate", "test"]
63 | ]
64 | end
65 | end
66 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web/endpoint.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.Endpoint do
2 | use Phoenix.Endpoint, otp_app: :phoenix_datatables_example
3 |
4 | socket "/socket", PhoenixDatatablesExampleWeb.UserSocket,
5 | websocket: true,
6 | longpoll: false
7 |
8 | # Serve at "/" the static files from "priv/static" directory.
9 | #
10 | # You should set gzip to true if you are running phoenix.digest
11 | # when deploying your static files in production.
12 | plug Plug.Static,
13 | at: "/", from: :phoenix_datatables_example, gzip: false,
14 | only: ~w(css fonts images js favicon.ico robots.txt)
15 |
16 | # Code reloading can be explicitly enabled under the
17 | # :code_reloader configuration of your endpoint.
18 | if code_reloading? do
19 | socket "/phoenix/live_reload/socket", Phoenix.LiveReloader.Socket
20 | plug Phoenix.LiveReloader
21 | plug Phoenix.CodeReloader
22 | end
23 |
24 | plug Plug.RequestId
25 | plug Plug.Logger
26 |
27 | plug Plug.Parsers,
28 | parsers: [:urlencoded, :multipart, :json],
29 | pass: ["*/*"],
30 | json_decoder: Phoenix.json_library()
31 |
32 | plug Plug.MethodOverride
33 | plug Plug.Head
34 |
35 | # The session will be stored in the cookie and signed,
36 | # this means its contents can be read but not tampered with.
37 | # Set :encryption_salt if you would also like to encrypt it.
38 | plug Plug.Session,
39 | store: :cookie,
40 | key: "_phoenix_datatables_example_key",
41 | signing_salt: "CG1Vyg0+"
42 |
43 | plug PhoenixDatatablesExampleWeb.Router
44 |
45 | @doc """
46 | Callback invoked for dynamically configuring the endpoint.
47 |
48 | It receives the endpoint configuration and checks if
49 | configuration should be loaded from the system environment.
50 | """
51 | def init(_key, config) do
52 | if config[:load_from_system_env] do
53 | port = System.get_env("PORT") || raise "expected the PORT environment variable to be set"
54 | {:ok, Keyword.put(config, :http, [:inet6, port: port])}
55 | else
56 | {:ok, config}
57 | end
58 | end
59 | end
60 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example_web.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb do
2 | @moduledoc """
3 | The entrypoint for defining your web interface, such
4 | as controllers, views, channels and so on.
5 |
6 | This can be used in your application as:
7 |
8 | use PhoenixDatatablesExampleWeb, :controller
9 | use PhoenixDatatablesExampleWeb, :view
10 |
11 | The definitions below will be executed for every view,
12 | controller, etc, so keep them short and clean, focused
13 | on imports, uses and aliases.
14 |
15 | Do NOT define functions inside the quoted expressions
16 | below. Instead, define any helper function in modules
17 | and import those modules here.
18 | """
19 |
20 | def controller do
21 | quote do
22 | use Phoenix.Controller, namespace: PhoenixDatatablesExampleWeb
23 | import Plug.Conn
24 |
25 | import PhoenixDatatablesExampleWeb.Gettext
26 | alias PhoenixDatatablesExampleWeb.Router.Helpers, as: Routes
27 | end
28 | end
29 |
30 | def view do
31 | quote do
32 | use Phoenix.View, root: "lib/phoenix_datatables_example_web/templates",
33 | namespace: PhoenixDatatablesExampleWeb
34 |
35 | # Import convenience functions from controllers
36 | import Phoenix.Controller, only: [get_flash: 2, view_module: 1]
37 |
38 | # Use all HTML functionality (forms, tags, etc)
39 | use Phoenix.HTML
40 |
41 | import PhoenixDatatablesExampleWeb.ErrorHelpers
42 | import PhoenixDatatablesExampleWeb.Gettext
43 | alias PhoenixDatatablesExampleWeb.Router.Helpers, as: Routes
44 | end
45 | end
46 |
47 | def router do
48 | quote do
49 | use Phoenix.Router
50 | import Plug.Conn
51 | import Phoenix.Controller
52 | end
53 | end
54 |
55 | def channel do
56 | quote do
57 | use Phoenix.Channel
58 | import PhoenixDatatablesExampleWeb.Gettext
59 | end
60 | end
61 |
62 | @doc """
63 | When used, dispatch to the appropriate controller/view/etc.
64 | """
65 | defmacro __using__(which) when is_atom(which) do
66 | apply(__MODULE__, which, [])
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables/response_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.ResponseTest do
2 | use PhoenixDatatablesExample.DataCase
3 | alias PhoenixDatatables.Response
4 | alias PhoenixDatatables.Request
5 | alias PhoenixDatatables.Query
6 | alias PhoenixDatatablesExample.Repo
7 | alias PhoenixDatatablesExample.Stock.Item
8 | alias PhoenixDatatablesExample.Stock.Category
9 | alias PhoenixDatatablesExample.Factory
10 |
11 | describe "new" do
12 | test "returns queried data in correct format" do
13 | add_items()
14 | query =
15 | (from item in Item,
16 | join: category in assoc(item, :category),
17 | select: %{id: item.id, category_name: category.name, nsn: item.nsn})
18 | request =
19 | Map.put(
20 | Factory.raw_request,
21 | "search",
22 | %{"regex" => "false", "value" => "1NSN"}
23 | )
24 | |> Request.receive
25 | search_results = Query.search(query, request)
26 |
27 | payload =
28 | search_results
29 | |> Repo.all
30 | |> Response.new(request.draw,
31 | Query.total_entries(Item, Repo),
32 | Query.total_entries(search_results, Repo))
33 |
34 | assert payload.draw == request.draw
35 | assert payload.recordsFiltered == length(Repo.all(search_results))
36 | assert payload.recordsTotal == length(Repo.all(Item))
37 | assert payload.data == Repo.all(search_results)
38 | end
39 | end
40 |
41 | def add_items do
42 | category_a = insert_category!("A")
43 | category_b = insert_category!("B")
44 | item = Map.put(Factory.item, :category_id, category_b.id)
45 | item2 = Map.put(Factory.item, :category_id, category_a.id)
46 | item2 = %{item2 | nsn: "1NSN"}
47 | one = insert_item! item
48 | two = insert_item! item2
49 | [one, two]
50 | end
51 |
52 | def insert_item!(item) do
53 | cs = Item.changeset(%Item{}, item)
54 | Repo.insert!(cs)
55 | end
56 |
57 | def insert_category!(category) do
58 | cs = Category.changeset(%Category{}, %{name: category})
59 | Repo.insert!(cs)
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/example/test/support/factory.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Factory do
2 | def item do
3 | %{
4 | nsn: "NSN1",
5 | rep_office: "office1",
6 | common_name: "pots",
7 | description: "you know - pots",
8 | price: 12.65,
9 | ui: "EA",
10 | aac: "H",
11 | nilable_field: nil
12 | }
13 | end
14 |
15 | def raw_request do
16 | %{
17 | "_" => "1502482464715",
18 | "columns" =>
19 | %{
20 | "0" => %{"data" => "nsn", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
21 | "1" => %{"data" => "rep_office", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
22 | "2" => %{"data" => "common_name", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
23 | "3" => %{"data" => "description", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
24 | "4" => %{"data" => "price", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
25 | "5" => %{"data" => "ui", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
26 | "6" => %{"data" => "aac", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
27 | "7" => %{"data" => "category.name", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
28 | "8" => %{"data" => "unit.description", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
29 | "9" => %{"data" => "nilable_field", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"}
30 | },
31 | "draw" => "1",
32 | "length" => "10",
33 | "order" => %{"0" => %{"column" => "0", "dir" => "asc"}},
34 | "search" => %{"regex" => "false", "value" => ""},
35 | "start" => "0"
36 | }
37 | end
38 | end
39 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/request.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Request.Params do
2 | @moduledoc false
3 | defstruct [
4 | :draw,
5 | :start,
6 | :length,
7 | :search,
8 | :order,
9 | :columns
10 | ]
11 | @type t :: %__MODULE__{}
12 | end
13 |
14 | defmodule PhoenixDatatables.Request.Search do
15 | @moduledoc false
16 | defstruct [
17 | :value,
18 | :regex
19 | ]
20 | end
21 |
22 | defmodule PhoenixDatatables.Request.Order do
23 | @moduledoc false
24 | defstruct [
25 | :column,
26 | :dir
27 | ]
28 | end
29 |
30 | defmodule PhoenixDatatables.Request.Column do
31 | @moduledoc false
32 | defstruct [
33 | :data,
34 | :searchable,
35 | :orderable,
36 | :search
37 | ]
38 | end
39 |
40 | defmodule PhoenixDatatables.Request do
41 | @moduledoc """
42 | For processing and validating the HTTP request sent from the client.
43 | """
44 | alias PhoenixDatatables.Request.Params
45 | alias PhoenixDatatables.Request.Search
46 | alias PhoenixDatatables.Request.Order
47 | alias PhoenixDatatables.Request.Column
48 |
49 | @doc """
50 | Validates and structures a `Plug.Conn.params` object based on a request
51 | sent from the Datatables client.
52 | """
53 | def receive(params) do
54 | orders =
55 | for {_key, val} <- params["order"] || [] do
56 | %Order{column: val["column"], dir: val["dir"]}
57 | end
58 | columns =
59 | Map.new (for {key, val} <- params["columns"] do
60 | data = case val["name"] do
61 | "" -> val["data"]
62 | name when is_binary(name) -> name
63 | _ -> val["data"]
64 | end
65 | {key, %Column{
66 | data: data,
67 | searchable: val["searchable"] == "true",
68 | orderable: val["orderable"] == "true",
69 | search: %Search{value: val["search"]["value"], regex: val["search"]["regex"]}
70 | }}
71 | end)
72 | search =
73 | %Search {
74 | value: params["search"]["value"],
75 | regex: params["search"]["regex"]
76 | }
77 |
78 | %Params {
79 | draw: params["draw"],
80 | order: orders,
81 | search: search,
82 | columns: columns,
83 | start: params["start"] || 0,
84 | length: params["length"] || 10
85 | }
86 | end
87 | end
88 |
--------------------------------------------------------------------------------
/example/config/dev.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For development, we disable any cache and enable
4 | # debugging and code reloading.
5 | #
6 | # The watchers configuration can be used to run external
7 | # watchers to your application. For example, we use it
8 | # with brunch.io to recompile .js and .css sources.
9 | config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
10 | http: [port: System.get_env("PORT") || 4000],
11 | debug_errors: true,
12 | code_reloader: true,
13 | check_origin: false,
14 | watchers: [
15 | node: [
16 | "node_modules/webpack/bin/webpack.js",
17 | "--mode",
18 | "development",
19 | "--watch-stdin",
20 | cd: Path.expand("../assets", __DIR__)
21 | ]
22 | ]
23 |
24 | # ## SSL Support
25 | #
26 | # In order to use HTTPS in development, a self-signed
27 | # certificate can be generated by running the following
28 | # command from your terminal:
29 | #
30 | # openssl req -new -newkey rsa:4096 -days 365 -nodes -x509 -subj "/C=US/ST=Denial/L=Springfield/O=Dis/CN=www.example.com" -keyout priv/server.key -out priv/server.pem
31 | #
32 | # The `http:` config above can be replaced with:
33 | #
34 | # https: [port: 4000, keyfile: "priv/server.key", certfile: "priv/server.pem"],
35 | #
36 | # If desired, both `http:` and `https:` keys can be
37 | # configured to run both http and https servers on
38 | # different ports.
39 |
40 | # Watch static and templates for browser reloading.
41 | config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
42 | live_reload: [
43 | patterns: [
44 | ~r{priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$},
45 | ~r{priv/gettext/.*(po)$},
46 | ~r{lib/phoenix_datatables_example_web/views/.*(ex)$},
47 | ~r{lib/phoenix_datatables_example_web/templates/.*(eex)$}
48 | ]
49 | ]
50 |
51 | # Do not include metadata nor timestamps in development logs
52 | config :logger, :console, format: "[$level] $message\n"
53 |
54 | # Set a higher stacktrace during development. Avoid configuring such
55 | # in production as building large stacktraces may be expensive.
56 | config :phoenix, :stacktrace_depth, 20
57 |
58 | # Configure your database
59 | config :phoenix_datatables_example, PhoenixDatatablesExample.Repo,
60 | adapter: Ecto.Adapters.Postgres,
61 | database: "phoenix_datatables_example_dev",
62 | hostname: "localhost",
63 | pool_size: 10
64 |
--------------------------------------------------------------------------------
/example/assets/js/socket.js:
--------------------------------------------------------------------------------
1 | // NOTE: The contents of this file will only be executed if
2 | // you uncomment its entry in "assets/js/app.js".
3 |
4 | // To use Phoenix channels, the first step is to import Socket
5 | // and connect at the socket path in "lib/web/endpoint.ex":
6 | import {Socket} from "phoenix"
7 |
8 | let socket = new Socket("/socket", {params: {token: window.userToken}})
9 |
10 | // When you connect, you'll often need to authenticate the client.
11 | // For example, imagine you have an authentication plug, `MyAuth`,
12 | // which authenticates the session and assigns a `:current_user`.
13 | // If the current user exists you can assign the user's token in
14 | // the connection for use in the layout.
15 | //
16 | // In your "lib/web/router.ex":
17 | //
18 | // pipeline :browser do
19 | // ...
20 | // plug MyAuth
21 | // plug :put_user_token
22 | // end
23 | //
24 | // defp put_user_token(conn, _) do
25 | // if current_user = conn.assigns[:current_user] do
26 | // token = Phoenix.Token.sign(conn, "user socket", current_user.id)
27 | // assign(conn, :user_token, token)
28 | // else
29 | // conn
30 | // end
31 | // end
32 | //
33 | // Now you need to pass this token to JavaScript. You can do so
34 | // inside a script tag in "lib/web/templates/layout/app.html.eex":
35 | //
36 | //
37 | //
38 | // You will need to verify the user token in the "connect/2" function
39 | // in "lib/web/channels/user_socket.ex":
40 | //
41 | // def connect(%{"token" => token}, socket) do
42 | // # max_age: 1209600 is equivalent to two weeks in seconds
43 | // case Phoenix.Token.verify(socket, "user socket", token, max_age: 1209600) do
44 | // {:ok, user_id} ->
45 | // {:ok, assign(socket, :user, user_id)}
46 | // {:error, reason} ->
47 | // :error
48 | // end
49 | // end
50 | //
51 | // Finally, pass the token on connect as below. Or remove it
52 | // from connect if you don't care about authentication.
53 |
54 | socket.connect()
55 |
56 | // Now that you are connected, you can join channels with a topic:
57 | let channel = socket.channel("topic:subtopic", {})
58 | channel.join()
59 | .receive("ok", resp => { console.log("Joined successfully", resp) })
60 | .receive("error", resp => { console.log("Unable to join", resp) })
61 |
62 | export default socket
63 |
--------------------------------------------------------------------------------
/example/config/prod.exs:
--------------------------------------------------------------------------------
1 | use Mix.Config
2 |
3 | # For production, we often load configuration from external
4 | # sources, such as your system environment. For this reason,
5 | # you won't find the :http configuration below, but set inside
6 | # PhoenixDatatablesExampleWeb.Endpoint.init/2 when load_from_system_env is
7 | # true. Any dynamic configuration should be done there.
8 | #
9 | # Don't forget to configure the url host to something meaningful,
10 | # Phoenix uses this information when generating URLs.
11 | #
12 | # Finally, we also include the path to a cache manifest
13 | # containing the digested version of static files. This
14 | # manifest is generated by the mix phx.digest task
15 | # which you typically run after static files are built.
16 | config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
17 | load_from_system_env: true,
18 | url: [host: "example.com", port: 80],
19 | cache_static_manifest: "priv/static/cache_manifest.json"
20 |
21 | # Do not print debug messages in production
22 | config :logger, level: :info
23 |
24 | # ## SSL Support
25 | #
26 | # To get SSL working, you will need to add the `https` key
27 | # to the previous section and set your `:url` port to 443:
28 | #
29 | # config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
30 | # ...
31 | # url: [host: "example.com", port: 443],
32 | # https: [:inet6,
33 | # port: 443,
34 | # keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
35 | # certfile: System.get_env("SOME_APP_SSL_CERT_PATH")]
36 | #
37 | # Where those two env variables return an absolute path to
38 | # the key and cert in disk or a relative path inside priv,
39 | # for example "priv/ssl/server.key".
40 | #
41 | # We also recommend setting `force_ssl`, ensuring no data is
42 | # ever sent via http, always redirecting to https:
43 | #
44 | # config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint,
45 | # force_ssl: [hsts: true]
46 | #
47 | # Check `Plug.SSL` for all available options in `force_ssl`.
48 |
49 | # ## Using releases
50 | #
51 | # If you are doing OTP releases, you need to instruct Phoenix
52 | # to start the server for all endpoints:
53 | #
54 | # config :phoenix, :serve_endpoints, true
55 | #
56 | # Alternatively, you can configure exactly which server to
57 | # start per endpoint:
58 | #
59 | # config :phoenix_datatables_example, PhoenixDatatablesExampleWeb.Endpoint, server: true
60 | #
61 |
62 | # Finally import the config/prod.secret.exs
63 | # which should be versioned separately.
64 | import_config "prod.secret.exs"
65 |
--------------------------------------------------------------------------------
/example/priv/gettext/en/LC_MESSAGES/errors.po:
--------------------------------------------------------------------------------
1 | ## `msgid`s in this file come from POT (.pot) files.
2 | ##
3 | ## Do not add, change, or remove `msgid`s manually here as
4 | ## they're tied to the ones in the corresponding POT file
5 | ## (with the same domain).
6 | ##
7 | ## Use `mix gettext.extract --merge` or `mix gettext.merge`
8 | ## to merge POT files into PO files.
9 | msgid ""
10 | msgstr ""
11 | "Language: en\n"
12 |
13 | ## From Ecto.Changeset.cast/4
14 | msgid "can't be blank"
15 | msgstr ""
16 |
17 | ## From Ecto.Changeset.unique_constraint/3
18 | msgid "has already been taken"
19 | msgstr ""
20 |
21 | ## From Ecto.Changeset.put_change/3
22 | msgid "is invalid"
23 | msgstr ""
24 |
25 | ## From Ecto.Changeset.validate_acceptance/3
26 | msgid "must be accepted"
27 | msgstr ""
28 |
29 | ## From Ecto.Changeset.validate_format/3
30 | msgid "has invalid format"
31 | msgstr ""
32 |
33 | ## From Ecto.Changeset.validate_subset/3
34 | msgid "has an invalid entry"
35 | msgstr ""
36 |
37 | ## From Ecto.Changeset.validate_exclusion/3
38 | msgid "is reserved"
39 | msgstr ""
40 |
41 | ## From Ecto.Changeset.validate_confirmation/3
42 | msgid "does not match confirmation"
43 | msgstr ""
44 |
45 | ## From Ecto.Changeset.no_assoc_constraint/3
46 | msgid "is still associated with this entry"
47 | msgstr ""
48 |
49 | msgid "are still associated with this entry"
50 | msgstr ""
51 |
52 | ## From Ecto.Changeset.validate_length/3
53 | msgid "should be %{count} character(s)"
54 | msgid_plural "should be %{count} character(s)"
55 | msgstr[0] ""
56 | msgstr[1] ""
57 |
58 | msgid "should have %{count} item(s)"
59 | msgid_plural "should have %{count} item(s)"
60 | msgstr[0] ""
61 | msgstr[1] ""
62 |
63 | msgid "should be at least %{count} character(s)"
64 | msgid_plural "should be at least %{count} character(s)"
65 | msgstr[0] ""
66 | msgstr[1] ""
67 |
68 | msgid "should have at least %{count} item(s)"
69 | msgid_plural "should have at least %{count} item(s)"
70 | msgstr[0] ""
71 | msgstr[1] ""
72 |
73 | msgid "should be at most %{count} character(s)"
74 | msgid_plural "should be at most %{count} character(s)"
75 | msgstr[0] ""
76 | msgstr[1] ""
77 |
78 | msgid "should have at most %{count} item(s)"
79 | msgid_plural "should have at most %{count} item(s)"
80 | msgstr[0] ""
81 | msgstr[1] ""
82 |
83 | ## From Ecto.Changeset.validate_number/3
84 | msgid "must be less than %{number}"
85 | msgstr ""
86 |
87 | msgid "must be greater than %{number}"
88 | msgstr ""
89 |
90 | msgid "must be less than or equal to %{number}"
91 | msgstr ""
92 |
93 | msgid "must be greater than or equal to %{number}"
94 | msgstr ""
95 |
96 | msgid "must be equal to %{number}"
97 | msgstr ""
98 |
--------------------------------------------------------------------------------
/example/lib/phoenix_datatables_example/stock/stock.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.Stock do
2 | @moduledoc """
3 | The Stock context.
4 | """
5 |
6 | import Ecto.Query, warn: false
7 | alias PhoenixDatatablesExample.Repo
8 | alias PhoenixDatatablesExample.Stock.Item
9 |
10 | def datatable_items(params) do
11 | query =
12 | from item in Item,
13 | join: category in assoc(item, :category),
14 | join: unit in assoc(item, :unit),
15 | preload: [category: category, unit: unit]
16 | Repo.fetch_datatable(query, params, nulls_last: true)
17 | end
18 |
19 | @doc """
20 | Returns the list of items.
21 |
22 | ## Examples
23 |
24 | iex> list_items()
25 | [%Item{}, ...]
26 |
27 | """
28 | def list_items do
29 | Item
30 | |> limit(25)
31 | |> Repo.all
32 | end
33 |
34 | @doc """
35 | Gets a single item.
36 |
37 | Raises `Ecto.NoResultsError` if the Item does not exist.
38 |
39 | ## Examples
40 |
41 | iex> get_item!(123)
42 | %Item{}
43 |
44 | iex> get_item!(456)
45 | ** (Ecto.NoResultsError)
46 |
47 | """
48 | def get_item!(id), do: Repo.get!(Item, id)
49 |
50 | @doc """
51 | Creates a item.
52 |
53 | ## Examples
54 |
55 | iex> create_item(%{field: value})
56 | {:ok, %Item{}}
57 |
58 | iex> create_item(%{field: bad_value})
59 | {:error, %Ecto.Changeset{}}
60 |
61 | """
62 | def create_item(attrs \\ %{}) do
63 | %Item{}
64 | |> Item.changeset(attrs)
65 | |> Repo.insert()
66 | end
67 |
68 | @doc """
69 | Updates a item.
70 |
71 | ## Examples
72 |
73 | iex> update_item(item, %{field: new_value})
74 | {:ok, %Item{}}
75 |
76 | iex> update_item(item, %{field: bad_value})
77 | {:error, %Ecto.Changeset{}}
78 |
79 | """
80 | def update_item(%Item{} = item, attrs) do
81 | item
82 | |> Item.changeset(attrs)
83 | |> Repo.update()
84 | end
85 |
86 | @doc """
87 | Deletes a Item.
88 |
89 | ## Examples
90 |
91 | iex> delete_item(item)
92 | {:ok, %Item{}}
93 |
94 | iex> delete_item(item)
95 | {:error, %Ecto.Changeset{}}
96 |
97 | """
98 | def delete_item(%Item{} = item) do
99 | Repo.delete(item)
100 | end
101 |
102 | @doc """
103 | Returns an `%Ecto.Changeset{}` for tracking item changes.
104 |
105 | ## Examples
106 |
107 | iex> change_item(item)
108 | %Ecto.Changeset{source: %Item{}}
109 |
110 | """
111 | def change_item(%Item{} = item) do
112 | Item.changeset(item, %{})
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/example/priv/gettext/errors.pot:
--------------------------------------------------------------------------------
1 | ## This file is a PO Template file.
2 | ##
3 | ## `msgid`s here are often extracted from source code.
4 | ## Add new translations manually only if they're dynamic
5 | ## translations that can't be statically extracted.
6 | ##
7 | ## Run `mix gettext.extract` to bring this file up to
8 | ## date. Leave `msgstr`s empty as changing them here as no
9 | ## effect: edit them in PO (`.po`) files instead.
10 |
11 | ## From Ecto.Changeset.cast/4
12 | msgid "can't be blank"
13 | msgstr ""
14 |
15 | ## From Ecto.Changeset.unique_constraint/3
16 | msgid "has already been taken"
17 | msgstr ""
18 |
19 | ## From Ecto.Changeset.put_change/3
20 | msgid "is invalid"
21 | msgstr ""
22 |
23 | ## From Ecto.Changeset.validate_acceptance/3
24 | msgid "must be accepted"
25 | msgstr ""
26 |
27 | ## From Ecto.Changeset.validate_format/3
28 | msgid "has invalid format"
29 | msgstr ""
30 |
31 | ## From Ecto.Changeset.validate_subset/3
32 | msgid "has an invalid entry"
33 | msgstr ""
34 |
35 | ## From Ecto.Changeset.validate_exclusion/3
36 | msgid "is reserved"
37 | msgstr ""
38 |
39 | ## From Ecto.Changeset.validate_confirmation/3
40 | msgid "does not match confirmation"
41 | msgstr ""
42 |
43 | ## From Ecto.Changeset.no_assoc_constraint/3
44 | msgid "is still associated with this entry"
45 | msgstr ""
46 |
47 | msgid "are still associated with this entry"
48 | msgstr ""
49 |
50 | ## From Ecto.Changeset.validate_length/3
51 | msgid "should be %{count} character(s)"
52 | msgid_plural "should be %{count} character(s)"
53 | msgstr[0] ""
54 | msgstr[1] ""
55 |
56 | msgid "should have %{count} item(s)"
57 | msgid_plural "should have %{count} item(s)"
58 | msgstr[0] ""
59 | msgstr[1] ""
60 |
61 | msgid "should be at least %{count} character(s)"
62 | msgid_plural "should be at least %{count} character(s)"
63 | msgstr[0] ""
64 | msgstr[1] ""
65 |
66 | msgid "should have at least %{count} item(s)"
67 | msgid_plural "should have at least %{count} item(s)"
68 | msgstr[0] ""
69 | msgstr[1] ""
70 |
71 | msgid "should be at most %{count} character(s)"
72 | msgid_plural "should be at most %{count} character(s)"
73 | msgstr[0] ""
74 | msgstr[1] ""
75 |
76 | msgid "should have at most %{count} item(s)"
77 | msgid_plural "should have at most %{count} item(s)"
78 | msgstr[0] ""
79 | msgstr[1] ""
80 |
81 | ## From Ecto.Changeset.validate_number/3
82 | msgid "must be less than %{number}"
83 | msgstr ""
84 |
85 | msgid "must be greater than %{number}"
86 | msgstr ""
87 |
88 | msgid "must be less than or equal to %{number}"
89 | msgstr ""
90 |
91 | msgid "must be greater than or equal to %{number}"
92 | msgstr ""
93 |
94 | msgid "must be equal to %{number}"
95 | msgstr ""
96 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/query/macros.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Query.Macros do
2 | @moduledoc false
3 |
4 | # make a simple AST representing blank Ecto table bindings so that
5 | # 'name' is bound to num(th) position (0 base)
6 | # e.g. bind_number(3, :t) = [_, _, _, t]
7 | defp bind_number(num, name \\ :t) do
8 | blanks =
9 | for _ <- 0..num do
10 | {:_, [], Elixir}
11 | end
12 |
13 | Enum.drop(blanks, 1) ++ [{name, [], Elixir}]
14 | end
15 |
16 | defp def_order_relation(num) do
17 | bindings = bind_number(num)
18 |
19 | quote do
20 | defp order_relation(queryable, unquote(num), dir, column, nil) do
21 | order_by(queryable, unquote(bindings), [{^dir, field(t, ^column)}])
22 | end
23 |
24 | defp order_relation(queryable, unquote(num), dir, column, options) when is_list(options) do
25 | if dir == :desc && options[:nulls_last] do
26 | order_by(queryable, unquote(bindings), [
27 | fragment("? DESC NULLS LAST", field(t, ^column))
28 | ])
29 | else
30 | order_relation(queryable, unquote(num), dir, column, nil)
31 | end
32 | end
33 | end
34 | end
35 |
36 | defp def_search_relation(num) do
37 | bindings = bind_number(num)
38 |
39 | quote do
40 | defp search_relation(dynamic, unquote(num), attribute, search_term) do
41 | dynamic(
42 | unquote(bindings),
43 | fragment("CAST(? AS TEXT) ILIKE ?", field(t, ^attribute), ^search_term) or ^dynamic
44 | )
45 | end
46 | end
47 | end
48 |
49 | defp def_search_relation_and(num) do
50 | bindings = bind_number(num)
51 |
52 | quote do
53 | defp search_relation_and(dynamic, unquote(num), attribute, search_term) do
54 | dynamic(
55 | unquote(bindings),
56 | fragment("CAST(? AS TEXT) ILIKE ?", field(t, ^attribute), ^search_term) and ^dynamic
57 | )
58 | end
59 | end
60 | end
61 |
62 | defmacro __using__(arg) do
63 | defines_count =
64 | case arg do
65 | [] ->
66 | 25
67 |
68 | num when is_integer(num) ->
69 | num
70 |
71 | arg ->
72 | raise """
73 | unknown args #{inspect(arg)} for
74 | PhoenixDatatables.Query.Macros.__using__,
75 | provide a number or nothing"
76 | """
77 | end
78 |
79 | order_relations = Enum.map(0..defines_count, &def_order_relation/1)
80 | search_relations = Enum.map(0..defines_count, &def_search_relation/1)
81 | search_relations_and = Enum.map(0..defines_count, &def_search_relation_and/1)
82 |
83 | quote do
84 | unquote(order_relations)
85 | defp search_relation(queryable, nil, _, _), do: queryable
86 | unquote(search_relations)
87 | unquote(search_relations_and)
88 | end
89 | end
90 | end
91 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables/request_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.RequestTest do
2 | use ExUnit.Case
3 | use PhoenixDatatablesExampleWeb.ConnCase
4 | alias PhoenixDatatables.Request
5 |
6 | describe "request" do
7 | test "receive/1 converts json params to struct form" do
8 | received_params = %{
9 | "_" => "1502482464715",
10 | "columns" =>
11 | %{
12 | "0" => %{"data" => "0", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
13 | "1" => %{"data" => "1", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
14 | "2" => %{"data" => "2", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
15 | "3" => %{"data" => "3", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
16 | "4" => %{"data" => "4", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
17 | "5" => %{"data" => "5", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
18 | "6" => %{"data" => "6", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
19 | "7" => %{"data" => "7", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"}
20 | },
21 | "draw" => "1",
22 | "length" => "10",
23 | "order" => %{"0" => %{"column" => "0", "dir" => "asc"}},
24 | "search" => %{"regex" => "false", "value" => ""},
25 | "start" => "0"
26 | }
27 |
28 | assert %Request.Params{
29 | draw: "1",
30 | start: "0",
31 | length: "10",
32 | search: %Request.Search{regex: "false", value: ""},
33 | order: [%Request.Order{column: "0", dir: "asc"}],
34 | columns: %{
35 | "0" => %Request.Column{data: "0", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
36 | "1" => %Request.Column{data: "1", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
37 | "2" => %Request.Column{data: "2", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
38 | "3" => %Request.Column{data: "3", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
39 | "4" => %Request.Column{data: "4", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
40 | "5" => %Request.Column{data: "5", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
41 | "6" => %Request.Column{data: "6", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true},
42 | "7" => %Request.Column{data: "7", orderable: true, search: %Request.Search{regex: "false", value: ""}, searchable: true}
43 | }
44 | } = Request.receive(received_params)
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example_web/controllers/item_controller_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExampleWeb.ItemControllerTest do
2 | use PhoenixDatatablesExampleWeb.ConnCase
3 |
4 | alias PhoenixDatatablesExample.Stock
5 |
6 | @create_attrs %{aac: "some aac", common_name: "some common_name", description: "some description", nsn: "some nsn", price: 120.5, rep_office: "some rep_office", ui: "some ui"}
7 | @update_attrs %{aac: "some updated aac", common_name: "some updated common_name", description: "some updated description", nsn: "some updated nsn", price: 456.7, rep_office: "some updated rep_office", ui: "some updated ui"}
8 | @invalid_attrs %{aac: nil, common_name: nil, description: nil, nsn: nil, price: nil, rep_office: nil, ui: nil}
9 |
10 | def fixture(:item) do
11 | {:ok, item} = Stock.create_item(@create_attrs)
12 | item
13 | end
14 |
15 | describe "index" do
16 | test "lists all items", %{conn: conn} do
17 | conn = get conn, Routes.item_path(conn, :index)
18 | assert html_response(conn, 200) =~ "Listing Items"
19 | end
20 | end
21 |
22 | describe "new item" do
23 | test "renders form", %{conn: conn} do
24 | conn = get conn, Routes.item_path(conn, :new)
25 | assert html_response(conn, 200) =~ "New Item"
26 | end
27 | end
28 |
29 | describe "create item" do
30 | test "redirects to show when data is valid", %{conn: conn} do
31 | conn = post conn, Routes.item_path(conn, :create), item: @create_attrs
32 |
33 | assert %{id: id} = redirected_params(conn)
34 | assert redirected_to(conn) == Routes.item_path(conn, :show, id)
35 |
36 | conn = get conn, Routes.item_path(conn, :show, id)
37 | assert html_response(conn, 200) =~ "Show Item"
38 | end
39 |
40 | test "renders errors when data is invalid", %{conn: conn} do
41 | conn = post conn, Routes.item_path(conn, :create), item: @invalid_attrs
42 | assert html_response(conn, 200) =~ "New Item"
43 | end
44 | end
45 |
46 | describe "edit item" do
47 | setup [:create_item]
48 |
49 | test "renders form for editing chosen item", %{conn: conn, item: item} do
50 | conn = get conn, Routes.item_path(conn, :edit, item)
51 | assert html_response(conn, 200) =~ "Edit Item"
52 | end
53 | end
54 |
55 | describe "update item" do
56 | setup [:create_item]
57 |
58 | test "redirects when data is valid", %{conn: conn, item: item} do
59 | conn = put conn, Routes.item_path(conn, :update, item), item: @update_attrs
60 | assert redirected_to(conn) == Routes.item_path(conn, :show, item)
61 |
62 | conn = get conn, Routes.item_path(conn, :show, item)
63 | assert html_response(conn, 200) =~ "some updated aac"
64 | end
65 |
66 | test "renders errors when data is invalid", %{conn: conn, item: item} do
67 | conn = put conn, Routes.item_path(conn, :update, item), item: @invalid_attrs
68 | assert html_response(conn, 200) =~ "Edit Item"
69 | end
70 | end
71 |
72 | describe "delete item" do
73 | setup [:create_item]
74 |
75 | test "deletes chosen item", %{conn: conn, item: item} do
76 | conn = delete conn, Routes.item_path(conn, :delete, item)
77 | assert redirected_to(conn) == Routes.item_path(conn, :index)
78 | assert_error_sent 404, fn ->
79 | get conn, Routes.item_path(conn, :show, item)
80 | end
81 | end
82 | end
83 |
84 | defp create_item(_) do
85 | item = fixture(:item)
86 | {:ok, item: item}
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_example/stock/stock_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesExample.StockTest do
2 | use PhoenixDatatablesExample.DataCase
3 |
4 | alias PhoenixDatatablesExample.Stock.Item
5 | alias PhoenixDatatablesExample.Stock
6 |
7 | describe "seeds" do
8 | test "loads specified count of nsn seeds" do
9 | load_test_seeds(100)
10 | assert Repo.all(Item) |> Enum.count == 100
11 | end
12 |
13 | test "loads and links items fk tables" do
14 | load_test_seeds(10)
15 |
16 | items = Repo.all(from item in Item,
17 | join: category in assoc(item, :category),
18 | join: unit in assoc(item, :unit),
19 | select: %{id: item.id,
20 | category_name: category.name,
21 | unit_description: unit.description
22 | })
23 | assert Enum.count(items) == 10
24 | assert List.first(items).unit_description == "Each"
25 | end
26 | end
27 |
28 | describe "items" do
29 | alias PhoenixDatatablesExample.Stock.Item
30 |
31 | @valid_attrs %{aac: "some aac", common_name: "some common_name", description: "some description", nsn: "some nsn", price: 120.5, rep_office: "some rep_office", ui: "some ui"}
32 | @update_attrs %{aac: "some updated aac", common_name: "some updated common_name", description: "some updated description", nsn: "some updated nsn", price: 456.7, rep_office: "some updated rep_office", ui: "some updated ui"}
33 | @invalid_attrs %{aac: nil, common_name: nil, description: nil, nsn: nil, price: nil, rep_office: nil, ui: nil}
34 |
35 | def item_fixture(attrs \\ %{}) do
36 | {:ok, item} =
37 | attrs
38 | |> Enum.into(@valid_attrs)
39 | |> Stock.create_item()
40 |
41 | item
42 | end
43 |
44 | test "list_items/0 returns all items" do
45 | item = item_fixture()
46 | assert Stock.list_items() == [item]
47 | end
48 |
49 | test "get_item!/1 returns the item with given id" do
50 | item = item_fixture()
51 | assert Stock.get_item!(item.id) == item
52 | end
53 |
54 | test "create_item/1 with valid data creates a item" do
55 | assert {:ok, %Item{} = item} = Stock.create_item(@valid_attrs)
56 | assert item.aac == "some aac"
57 | assert item.common_name == "some common_name"
58 | assert item.description == "some description"
59 | assert item.nsn == "some nsn"
60 | assert item.price == 120.5
61 | assert item.rep_office == "some rep_office"
62 | assert item.ui == "some ui"
63 | end
64 |
65 | test "create_item/1 with invalid data returns error changeset" do
66 | assert {:error, %Ecto.Changeset{}} = Stock.create_item(@invalid_attrs)
67 | end
68 |
69 | test "update_item/2 with valid data updates the item" do
70 | item = item_fixture()
71 | assert {:ok, item} = Stock.update_item(item, @update_attrs)
72 | assert %Item{} = item
73 | assert item.aac == "some updated aac"
74 | assert item.common_name == "some updated common_name"
75 | assert item.description == "some updated description"
76 | assert item.nsn == "some updated nsn"
77 | assert item.price == 456.7
78 | assert item.rep_office == "some updated rep_office"
79 | assert item.ui == "some updated ui"
80 | end
81 |
82 | test "update_item/2 with invalid data returns error changeset" do
83 | item = item_fixture()
84 | assert {:error, %Ecto.Changeset{}} = Stock.update_item(item, @invalid_attrs)
85 | assert item == Stock.get_item!(item.id)
86 | end
87 |
88 | test "delete_item/1 deletes the item" do
89 | item = item_fixture()
90 | assert {:ok, %Item{}} = Stock.delete_item(item)
91 | assert_raise Ecto.NoResultsError, fn -> Stock.get_item!(item.id) end
92 | end
93 |
94 | test "change_item/1 returns a item changeset" do
95 | item = item_fixture()
96 | assert %Ecto.Changeset{} = Stock.change_item(item)
97 | end
98 | end
99 | end
100 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatablesTest do
2 | use PhoenixDatatablesExample.DataCase
3 | alias PhoenixDatatables
4 | alias PhoenixDatatables.Response.Payload
5 | alias PhoenixDatatablesExample.Repo
6 | alias PhoenixDatatablesExample.Stock.Item
7 | alias PhoenixDatatablesExample.Stock.Category
8 | alias PhoenixDatatablesExample.Factory
9 |
10 | @sortable [columns: [:nsn, :common_name]]
11 | @sortable_join [columns: [nsn: 0, common_name: 0, category: [name: 1]]]
12 |
13 | describe "execute" do
14 | test "do all of the things in phoenix datatables" do
15 | { request, query } = create_request_and_query()
16 | assert %Payload{
17 | data: _data,
18 | draw: draw,
19 | error: _error,
20 | recordsFiltered: _recordsFiltered,
21 | recordsTotal: _recordsTotal
22 | } = PhoenixDatatables.execute(query, request, Repo)
23 | assert draw == request["draw"]
24 | end
25 |
26 | test "do all of the things in phoenix datatables with @sortable" do
27 | { request, query } = create_request_and_query()
28 | assert %Payload{
29 | data: _data,
30 | draw: draw,
31 | error: _error,
32 | recordsFiltered: _recordsFiltered,
33 | recordsTotal: _recordsTotal
34 | } = PhoenixDatatables.execute(query, request, Repo, @sortable)
35 | assert draw == request["draw"]
36 | end
37 |
38 | test "do all of the things in phoenix datatables with @sortable_join" do
39 | { request, query } = create_request_and_query()
40 | assert %Payload{
41 | data: _data,
42 | draw: draw,
43 | error: _error,
44 | recordsFiltered: _recordsFiltered,
45 | recordsTotal: _recordsTotal
46 | } = PhoenixDatatables.execute(query, request, Repo, @sortable_join)
47 | assert draw == request["draw"]
48 | end
49 |
50 | test "will override records total with total_entries option" do
51 | { request, query } = create_request_and_query()
52 | assert %Payload{
53 | data: _data,
54 | draw: draw,
55 | error: _error,
56 | recordsFiltered: _recordsFiltered,
57 | recordsTotal: 25
58 | } = PhoenixDatatables.execute(query, request, Repo, [total_entries: 25])
59 | assert draw == request["draw"]
60 | end
61 |
62 | test "do all of the things in phoenix datatables to search columns" do
63 | { request, query } = create_request_and_query()
64 | request = request
65 | |> update_in(["columns", "0", "search"], &(Map.put(&1, "value", "1NSN")))
66 | |> Map.put("search", %{"regex" => "false", "value" => ""})
67 |
68 | assert %Payload{
69 | data: data,
70 | draw: draw,
71 | error: _error,
72 | recordsFiltered: recordsFiltered,
73 | recordsTotal: recordsTotal
74 | } = PhoenixDatatables.execute(query, request, Repo, [columns: [id: 0, common_name: 0, nsn: 0]])
75 |
76 | assert draw == request["draw"]
77 | assert Enum.count(data) == 1
78 | assert recordsTotal == 2
79 | assert recordsFiltered == 1
80 | end
81 | end
82 |
83 | def create_request_and_query do
84 | add_items()
85 | request = Factory.raw_request
86 | |> Map.put("order", %{"0" => %{"column" => "2", "dir" => "asc"}})
87 | |> Map.put("search", %{"regex" => "false", "value" => "1NSN"})
88 | query =
89 | (from item in Item,
90 | join: category in assoc(item, :category),
91 | select: %{id: item.id, category_name: category.name, nsn: item.nsn})
92 | {request, query}
93 | end
94 |
95 | def add_items do
96 | category_a = insert_category!("A")
97 | category_b = insert_category!("B")
98 | item = Map.put(Factory.item, :category_id, category_b.id)
99 | item2 = Map.put(Factory.item, :category_id, category_a.id)
100 | item2 = %{item2 | nsn: "1NSN"}
101 | one = insert_item! item
102 | two = insert_item! item2
103 | [one, two]
104 | end
105 |
106 | def insert_item!(item) do
107 | cs = Item.changeset(%Item{}, item)
108 | Repo.insert!(cs)
109 | end
110 |
111 | def insert_category!(category) do
112 | cs = Category.changeset(%Category{}, %{name: category})
113 | Repo.insert!(cs)
114 | end
115 | end
116 |
--------------------------------------------------------------------------------
/test/support/fixtures.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Fixtures.Stock.Item do
2 | use Ecto.Schema
3 | alias PhoenixDatatables.Fixtures.Stock.Category
4 | alias PhoenixDatatables.Fixtures.Stock.Unit
5 |
6 | schema "items" do
7 | field :aac, :string
8 | field :common_name, :string
9 | field :description, :string
10 | field :nsn, :string
11 | field :price, :float
12 | field :rep_office, :string
13 | field :ui, :string
14 | field :nilable_field, :string
15 | belongs_to :category, Category
16 | belongs_to :unit, Unit
17 | end
18 | end
19 |
20 | defmodule PhoenixDatatables.Fixtures.Stock.Category do
21 | use Ecto.Schema
22 | alias PhoenixDatatables.Fixtures.Stock.Item
23 |
24 | schema "categories" do
25 | field :name, :string
26 | has_many :items, Item
27 | end
28 | end
29 |
30 | defmodule PhoenixDatatables.Fixtures.Stock.Unit do
31 | use Ecto.Schema
32 | alias PhoenixDatatables.Fixtures.Stock.Item
33 |
34 | schema "units" do
35 | field :description, :string
36 | field :ui_code, :string
37 | has_many :items, Item
38 | end
39 | end
40 |
41 | defmodule PhoenixDatatables.Fixtures.Factory do
42 | def join_request do
43 | %{
44 | "_" => "1502482464715",
45 | "columns" =>
46 | %{
47 | "0" => %{"data" => "nsn", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
48 | "1" => %{"data" => "rep_office", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
49 | "2" => %{"data" => "common_name", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
50 | "3" => %{"data" => "description", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
51 | "4" => %{"data" => "price", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
52 | "5" => %{"data" => "ui", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
53 | "6" => %{"data" => "aac", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
54 | "7" => %{"data" => "category.name", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
55 | "8" => %{"data" => "unit.description", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
56 | "9" => %{"data" => "nilable_field", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"}
57 | },
58 | "draw" => "1",
59 | "length" => "10",
60 | "order" => %{"0" => %{"column" => "0", "dir" => "asc"}},
61 | "search" => %{"regex" => "false", "value" => ""},
62 | "start" => "0"
63 | }
64 | end
65 |
66 | def simple_request do
67 | %{
68 | "_" => "1502482464715",
69 | "columns" =>
70 | %{
71 | "0" => %{"data" => "nsn", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
72 | "1" => %{"data" => "rep_office", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
73 | "2" => %{"data" => "common_name", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
74 | "3" => %{"data" => "description", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
75 | "4" => %{"data" => "price", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
76 | "5" => %{"data" => "ui", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
77 | "6" => %{"data" => "aac", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
78 | "7" => %{"data" => "category", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
79 | "8" => %{"data" => "unit", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"},
80 | "9" => %{"data" => "nilable_field", "name" => "", "orderable" => "true", "search" => %{"regex" => "false", "value" => ""}, "searchable" => "true"}
81 | },
82 | "draw" => "1",
83 | "length" => "10",
84 | "order" => %{"0" => %{"column" => "0", "dir" => "asc"}},
85 | "search" => %{"regex" => "false", "value" => ""},
86 | "start" => "0"
87 | }
88 | end
89 | end
90 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables do
2 | @moduledoc """
3 | Provides the `execute` function which is the primary entry-point to the library, used
4 | by the `Repo.fetch_datatable` function and directly by client applications.
5 | """
6 |
7 | alias PhoenixDatatables.Query
8 | alias PhoenixDatatables.Request
9 | alias PhoenixDatatables.Response
10 | alias PhoenixDatatables.Response.Payload
11 |
12 | alias Plug.Conn
13 |
14 | @doc """
15 | Prepare and execute a provided query, modified based on the params map. with the results returned in a `Payload` which
16 | can be encoded to json by Phoenix / Poison and consumed by the DataTables client.
17 |
18 | ## Options
19 |
20 | * `:columns` - If columns are not provided, the list of
21 | valid columns to use for filtering and ordering is determined by introspection of the
22 | Ecto query, and the attributes and associations defined in the Schemas used in that
23 | query. This will not always work - Ecto queries may contain subqueries or schema-less queries.
24 | Such queryables will need to be accompanied by `:columns` options.
25 |
26 |
27 |
28 | Even if the queryable uses only schemas and joins built with `assoc` there are security reasons to
29 | provide a `:columns` option.
30 |
31 | The client will provide columns
32 | to use for filterng and searching in its request, but client input cannot be trusted. A denial of service
33 | attack could be constructed by requesting search against un-indexed fields on a large table for example.
34 | To harden your server you could limit the on the server-side the sorting and filtering possiblities
35 | by specifying the columns that should be available.
36 |
37 |
38 |
39 | A list of valid columns that are eligibile to be used for sorting and filtering can be passed in
40 | a nested keyword list, where the first keyword is the table name, and second is
41 | the column name and query binding order.
42 |
43 |
44 |
45 | In the below example, the query is a simple join using assoc and could be introspected. `:columns` are
46 | optional.
47 |
48 | In the example, `columns` is bound to such a list. Here the 0 means the nsn column belongs to the `from` table,
49 | and there is a `category.name` field, which is the first join table in the query. In the client datatables options,
50 | the column :data attribute should be set to `nsn` for the first column and `category.name` for the second.
51 |
52 |
53 |
54 | * `:total_entries` - Provides a way for the application to use cached values for total_entries; when this
55 | is provided, `phoenix_datatables` won't do a query to get the total record count, instead using
56 | the provided value in the response. The mechanism for cacheing is left up to the application.
57 |
58 | ```
59 | query =
60 | (from item in Item,
61 | join: category in assoc(item, :category),
62 | select: %{id: item.id, item.nsn, category_name: category.name})
63 |
64 | options = [columns: [nsn: 0, category: [name: 1]], total_entries: 25]
65 |
66 | Repo.fetch_datatable(query, params, options)
67 | ```
68 |
69 |
70 | * `:nulls_last` - When `true`, results will be sorted with NULL fields sorted last. This option is only
71 | valid with PostgreSQL.
72 | """
73 | @spec execute(Ecto.Queryable.t,
74 | Conn.params,
75 | Ecto.Repo.t,
76 | Keyword.t | nil) :: Payload.t
77 | def execute(query, params, repo, options \\ []) do
78 | params = Request.receive(params)
79 | total_entries = options[:total_entries] || Query.total_entries(query, repo)
80 | filtered_query =
81 | query
82 | |> Query.sort(params, options)
83 | |> Query.search(params, options)
84 | |> Query.search_columns(params, options)
85 | |> Query.paginate(params)
86 |
87 | filtered_entries =
88 | if params.search.value == "" and not Query.has_column_search?(params.columns) do
89 | total_entries
90 | else
91 | Query.total_entries(filtered_query, repo)
92 | end
93 |
94 | filtered_query
95 | |> repo.all()
96 | |> Response.new(params.draw, total_entries, filtered_entries)
97 | end
98 |
99 | @doc """
100 | Use the provided function to transform the records embeded in the
101 | Payload, often used in a json view for example
102 | to convert an Ecto schema to a plain map so it can be serialized by Poison.
103 |
104 | query
105 | |> Repo.fetch_datatable(params)
106 | |> PhoenixDatatables.map_payload(fn item -> %{
107 | nsn: item.nsn,
108 | category_name: item.category.name}
109 | end)
110 | """
111 | @spec map_payload(Payload.t, (any -> any)) :: Payload.t
112 | def map_payload(%Payload{} = payload, fun) when is_function(fun) do
113 | %Payload {payload |
114 | data: Enum.map(payload.data, fun)
115 | }
116 | end
117 | end
118 |
--------------------------------------------------------------------------------
/mix.lock:
--------------------------------------------------------------------------------
1 | %{
2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm"},
3 | "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
4 | "credo": {:hex, :credo, "0.10.2", "03ad3a1eff79a16664ed42fc2975b5e5d0ce243d69318060c626c34720a49512", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm"},
5 | "db_connection": {:hex, :db_connection, "2.0.3", "b4e8aa43c100e16f122ccd6798cd51c48c79fd391c39d411f42b3cd765daccb0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
6 | "decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"},
7 | "dialyxir": {:hex, :dialyxir, "1.0.0-rc.6", "78e97d9c0ff1b5521dd68041193891aebebce52fc3b93463c0a6806874557d7d", [:mix], [{:erlex, "~> 0.2.1", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm"},
8 | "earmark": {:hex, :earmark, "1.3.1", "73812f447f7a42358d3ba79283cfa3075a7580a3a2ed457616d6517ac3738cb9", [:mix], [], "hexpm"},
9 | "ecto": {:hex, :ecto, "3.0.5", "bf9329b56f781a67fdb19e92e6d9ed79c5c8b31d41653b79dafb7ceddfbe87e0", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
10 | "ecto_sql": {:hex, :ecto_sql, "3.0.3", "dd17f2401a69bb2ec91d5564bd259ad0bc63ee32c2cb2e616d04f1559801dba6", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.2.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
11 | "erlex": {:hex, :erlex, "0.2.1", "cee02918660807cbba9a7229cae9b42d1c6143b768c781fa6cee1eaf03ad860b", [:mix], [], "hexpm"},
12 | "ex_doc": {:hex, :ex_doc, "0.19.2", "6f4081ccd9ed081b6dc0bd5af97a41e87f5554de469e7d76025fba535180565f", [:mix], [{:earmark, "~> 1.2", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.10", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm"},
13 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
14 | "makeup": {:hex, :makeup, "0.6.0", "e0fd985525e8d42352782bd76253105fbab0a783ac298708ca9020636c9568af", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm"},
15 | "makeup_elixir": {:hex, :makeup_elixir, "0.11.0", "aa3446f67356afa5801618867587a8863f176f9c632fb62b20f49bd1ea335e8a", [:mix], [{:makeup, "~> 0.6", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm"},
16 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
17 | "nimble_csv": {:hex, :nimble_csv, "0.1.1", "15b20120cba14f25c33a85a2ae41413e4aa9d2f6cf24b09e9befb9fc2a8a8013", [], [], "hexpm"},
18 | "nimble_parsec": {:hex, :nimble_parsec, "0.5.0", "90e2eca3d0266e5c53f8fbe0079694740b9c91b6747f2b7e3c5d21966bba8300", [:mix], [], "hexpm"},
19 | "phoenix": {:hex, :phoenix, "1.3.0", "1c01124caa1b4a7af46f2050ff11b267baa3edb441b45dbf243e979cd4c5891b", [], [{:cowboy, "~> 1.0", [hex: :cowboy, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.3.3 or ~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
20 | "phoenix_ecto": {:hex, :phoenix_ecto, "3.2.3", "450c749876ff1de4a78fdb305a142a76817c77a1cd79aeca29e5fc9a6c630b26", [], [{:ecto, "~> 2.1", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
21 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.0.2", "bfa7fd52788b5eaa09cb51ff9fcad1d9edfeb68251add458523f839392f034c1", [], [], "hexpm"},
22 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
23 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
24 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [], [], "hexpm"},
25 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
26 | "postgrex": {:hex, :postgrex, "0.13.3", "c277cfb2a9c5034d445a722494c13359e361d344ef6f25d604c2353185682bfc", [], [{: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"},
27 | "telemetry": {:hex, :telemetry, "0.2.0", "5b40caa3efe4deb30fb12d7cd8ed4f556f6d6bd15c374c2366772161311ce377", [:mix], [], "hexpm"},
28 | }
29 |
--------------------------------------------------------------------------------
/example/mix.lock:
--------------------------------------------------------------------------------
1 | %{"connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"},
2 | "cowboy": {:hex, :cowboy, "2.6.1", "f2e06f757c337b3b311f9437e6e072b678fcd71545a7b2865bdaa154d078593f", [:rebar3], [{:cowlib, "~> 2.7.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm"},
3 | "cowlib": {:hex, :cowlib, "2.7.0", "3ef16e77562f9855a2605900cedb15c1462d76fb1be6a32fc3ae91973ee543d2", [:rebar3], [], "hexpm"},
4 | "csv": {:hex, :csv, "2.0.0", "c66fea89ba7862b94901baf0871285e9b73cad89c5fdb57a6386d2adcf29593e", [:mix], [{:parallel_stream, "~> 1.0.4", [hex: :parallel_stream, repo: "hexpm", optional: false]}], "hexpm"},
5 | "db_connection": {:hex, :db_connection, "2.0.3", "b4e8aa43c100e16f122ccd6798cd51c48c79fd391c39d411f42b3cd765daccb0", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"},
6 | "decimal": {:hex, :decimal, "1.6.0", "bfd84d90ff966e1f5d4370bdd3943432d8f65f07d3bab48001aebd7030590dcc", [:mix], [], "hexpm"},
7 | "dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [:mix], [], "hexpm"},
8 | "ecto": {:hex, :ecto, "3.0.5", "bf9329b56f781a67fdb19e92e6d9ed79c5c8b31d41653b79dafb7ceddfbe87e0", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:poison, "~> 2.2 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: true]}], "hexpm"},
9 | "ecto_sql": {:hex, :ecto_sql, "3.0.3", "dd17f2401a69bb2ec91d5564bd259ad0bc63ee32c2cb2e616d04f1559801dba6", [:mix], [{:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.0.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:mariaex, "~> 0.9.1", [hex: :mariaex, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.14.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.2.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"},
10 | "file_system": {:hex, :file_system, "0.2.6", "fd4dc3af89b9ab1dc8ccbcc214a0e60c41f34be251d9307920748a14bf41f1d3", [:mix], [], "hexpm"},
11 | "gettext": {:hex, :gettext, "0.16.1", "e2130b25eebcbe02bb343b119a07ae2c7e28bd4b146c4a154da2ffb2b3507af2", [:mix], [], "hexpm"},
12 | "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"},
13 | "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"},
14 | "nimble_csv": {:hex, :nimble_csv, "0.5.0", "616dfbcd5954102b02f000b1c77cd211d0cd4eed08e1ea675c25b1f9ff689dd0", [:mix], [], "hexpm"},
15 | "parallel_stream": {:hex, :parallel_stream, "1.0.5", "4c78d3e675f9eff885cbe252c89a8fc1d2fb803c0d03a914281e587834e09431", [:mix], [], "hexpm"},
16 | "phoenix": {:hex, :phoenix, "1.4.0", "56fe9a809e0e735f3e3b9b31c1b749d4b436e466d8da627b8d82f90eaae714d2", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}], "hexpm"},
17 | "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
18 | "phoenix_html": {:hex, :phoenix_html, "2.13.0", "3bad10de5efb6c590f7aa5b316ad0d3faa054715414c9b562c410de4ffb885c5", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
19 | "phoenix_live_reload": {:hex, :phoenix_live_reload, "1.2.0", "3bb31a9fbd40ffe8652e60c8660dffd72dd231efcdf49b744fb75b9ef7db5dd2", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm"},
20 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "1.1.1", "6668d787e602981f24f17a5fbb69cc98f8ab085114ebfac6cc36e10a90c8e93c", [:mix], [], "hexpm"},
21 | "plug": {:hex, :plug, "1.7.1", "8516d565fb84a6a8b2ca722e74e2cd25ca0fc9d64f364ec9dbec09d33eb78ccd", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}], "hexpm"},
22 | "plug_cowboy": {:hex, :plug_cowboy, "2.0.1", "d798f8ee5acc86b7d42dbe4450b8b0dadf665ce588236eb0a751a132417a980e", [:mix], [{:cowboy, "~> 2.5", [hex: :cowboy, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"},
23 | "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"},
24 | "poison": {:hex, :poison, "3.1.0", "d9eb636610e096f86f25d9a46f35a9facac35609a7591b3be3326e99a0484665", [:mix], [], "hexpm"},
25 | "poolboy": {:hex, :poolboy, "1.5.1", "6b46163901cfd0a1b43d692657ed9d7e599853b3b21b95ae5ae0a777cf9b6ca8", [:rebar], [], "hexpm"},
26 | "postgrex": {:hex, :postgrex, "0.14.1", "63247d4a5ad6b9de57a0bac5d807e1c32d41e39c04b8a4156a26c63bcd8a2e49", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"},
27 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"},
28 | "telemetry": {:hex, :telemetry, "0.2.0", "5b40caa3efe4deb30fb12d7cd8ed4f556f6d6bd15c374c2366772161311ce377", [:mix], [], "hexpm"}}
29 |
--------------------------------------------------------------------------------
/.credo.exs:
--------------------------------------------------------------------------------
1 | # This file contains the configuration for Credo and you are probably reading
2 | # this after creating it with `mix credo.gen.config`.
3 | #
4 | # If you find anything wrong or unclear in this file, please report an
5 | # issue on GitHub: https://github.com/rrrene/credo/issues
6 | #
7 | %{
8 | #
9 | # You can have as many configs as you like in the `configs:` field.
10 | configs: [
11 | %{
12 | #
13 | # Run any exec using `mix credo -C `. If no exec name is given
14 | # "default" is used.
15 | #
16 | name: "default",
17 | #
18 | # These are the files included in the analysis:
19 | files: %{
20 | #
21 | # You can give explicit globs or simply directories.
22 | # In the latter case `**/*.{ex,exs}` will be used.
23 | #
24 | included: ["lib/", "src/", "web/", "apps/"],
25 | excluded: [~r"/_build/", ~r"/deps/"]
26 | },
27 | #
28 | # If you create your own checks, you must specify the source files for
29 | # them here, so they can be loaded by Credo before running the analysis.
30 | #
31 | requires: [],
32 | #
33 | # If you want to enforce a style guide and need a more traditional linting
34 | # experience, you can change `strict` to `true` below:
35 | #
36 | strict: true,
37 | #
38 | # If you want to use uncolored output by default, you can change `color`
39 | # to `false` below:
40 | #
41 | color: true,
42 | #
43 | # You can customize the parameters of any check by adding a second element
44 | # to the tuple.
45 | #
46 | # To disable a check put `false` as second element:
47 | #
48 | # {Credo.Check.Design.DuplicatedCode, false}
49 | #
50 | checks: [
51 | {Credo.Check.Consistency.ExceptionNames},
52 | {Credo.Check.Consistency.LineEndings},
53 | {Credo.Check.Consistency.ParameterPatternMatching},
54 | {Credo.Check.Consistency.SpaceAroundOperators},
55 | {Credo.Check.Consistency.SpaceInParentheses},
56 | {Credo.Check.Consistency.TabsOrSpaces},
57 |
58 | # For some checks, like AliasUsage, you can only customize the priority
59 | # Priority values are: `low, normal, high, higher`
60 | #
61 | {Credo.Check.Design.AliasUsage, priority: :low},
62 |
63 | # For others you can set parameters
64 |
65 | # If you don't want the `setup` and `test` macro calls in ExUnit tests
66 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just
67 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`.
68 | #
69 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []},
70 |
71 | # You can also customize the exit_status of each check.
72 | # If you don't want TODO comments to cause `mix credo` to fail, just
73 | # set this value to 0 (zero).
74 | #
75 | {Credo.Check.Design.TagTODO, exit_status: 2},
76 | {Credo.Check.Design.TagFIXME},
77 |
78 | {Credo.Check.Readability.FunctionNames},
79 | {Credo.Check.Readability.LargeNumbers},
80 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80},
81 | {Credo.Check.Readability.ModuleAttributeNames},
82 | {Credo.Check.Readability.ModuleDoc},
83 | {Credo.Check.Readability.ModuleNames},
84 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs},
85 | {Credo.Check.Readability.ParenthesesInCondition},
86 | {Credo.Check.Readability.PredicateFunctionNames},
87 | {Credo.Check.Readability.PreferImplicitTry},
88 | {Credo.Check.Readability.RedundantBlankLines},
89 | {Credo.Check.Readability.StringSigils},
90 | {Credo.Check.Readability.TrailingBlankLine},
91 | {Credo.Check.Readability.TrailingWhiteSpace},
92 | {Credo.Check.Readability.VariableNames},
93 | {Credo.Check.Readability.Semicolons},
94 | {Credo.Check.Readability.SpaceAfterCommas},
95 |
96 | {Credo.Check.Refactor.DoubleBooleanNegation},
97 | {Credo.Check.Refactor.CondStatements},
98 | {Credo.Check.Refactor.CyclomaticComplexity},
99 | {Credo.Check.Refactor.FunctionArity},
100 | {Credo.Check.Refactor.LongQuoteBlocks},
101 | {Credo.Check.Refactor.MatchInCondition},
102 | {Credo.Check.Refactor.NegatedConditionsInUnless},
103 | {Credo.Check.Refactor.NegatedConditionsWithElse},
104 | {Credo.Check.Refactor.Nesting},
105 | {Credo.Check.Refactor.PipeChainStart},
106 | {Credo.Check.Refactor.UnlessWithElse},
107 |
108 | {Credo.Check.Warning.BoolOperationOnSameValues},
109 | {Credo.Check.Warning.IExPry},
110 | {Credo.Check.Warning.IoInspect},
111 | {Credo.Check.Warning.LazyLogging},
112 | {Credo.Check.Warning.OperationOnSameValues},
113 | {Credo.Check.Warning.OperationWithConstantResult},
114 | {Credo.Check.Warning.UnusedEnumOperation},
115 | {Credo.Check.Warning.UnusedFileOperation},
116 | {Credo.Check.Warning.UnusedKeywordOperation},
117 | {Credo.Check.Warning.UnusedListOperation},
118 | {Credo.Check.Warning.UnusedPathOperation},
119 | {Credo.Check.Warning.UnusedRegexOperation},
120 | {Credo.Check.Warning.UnusedStringOperation},
121 | {Credo.Check.Warning.UnusedTupleOperation},
122 | {Credo.Check.Warning.RaiseInsideRescue},
123 |
124 | # Controversial and experimental checks (opt-in, just remove `, false`)
125 | #
126 | {Credo.Check.Refactor.ABCSize, false},
127 | {Credo.Check.Refactor.AppendSingleItem, false},
128 | {Credo.Check.Refactor.VariableRebinding, false},
129 | {Credo.Check.Warning.MapGetUnsafePass, false},
130 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false},
131 |
132 | # Deprecated checks (these will be deleted after a grace period)
133 | #
134 | {Credo.Check.Readability.Specs, false},
135 | {Credo.Check.Warning.NameRedeclarationByAssignment, false},
136 | {Credo.Check.Warning.NameRedeclarationByCase, false},
137 | {Credo.Check.Warning.NameRedeclarationByDef, false},
138 | {Credo.Check.Warning.NameRedeclarationByFn, false},
139 |
140 | # Custom checks can be created using `mix credo.gen.check`.
141 | #
142 | ]
143 | }
144 | ]
145 | }
146 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Phoenix Datatables
2 |
3 | Phoenix Datatables provides support to quickly build an implementation
4 | of the [DataTables](https://datatables.net/) server-side API in your [Phoenix Framework](http://phoenixframework.org/) application.
5 |
6 | Add this to your `mix.exs` dependency list:
7 |
8 | ```elixir
9 | {:phoenix_datatables, "~> 0.4.0"}
10 | ```
11 |
12 | There is a complete [example](https://github.com/smartmetals/phoenix_datatables/tree/master/example).
13 |
14 | A full solution would typically use or include the following components:
15 |
16 | * A repository helper function to execute queries with client params
17 | * An Ecto query (Schema or other queryable object)
18 | * A Context module function which takes the request parameters from the
19 | browser and invokes the repository helper along with the query to fetch the results
20 | and build a response structure
21 | * A controller module and approprate route entries which will
22 | receive the `json` API requests from the browser.
23 | * A view function to format the JSON data according to the requirements of the client.
24 | * HTML and Javascript on the client configured per the Datatables library requirements.
25 |
26 | The below example is one way to compose your application but there are others; while
27 | most needs can probably be met just using the `Repo.fetch_datatable` function,
28 | public functions are documented which can be used to retain more control in your application.
29 |
30 | ## Repository
31 |
32 | You can optionally `use` `PhoenixDatatables.Repo`. This creates a helper function
33 | `Repo.fetch_datatable`.
34 |
35 | ```elixir
36 | defmodule PhoenixDatatablesExample.Repo do
37 | use Ecto.Repo, otp_app: :phoenix_datatables_example
38 | use PhoenixDatatables.Repo
39 | end
40 | ```
41 |
42 | ## Context
43 |
44 | It is recommended to follow Phoenix 1.3 conventions and place the query and repository
45 | invocations in a function in an application context module.
46 |
47 | ```elixir
48 | def PhoenixDatatablesExample.Stock do
49 | import Ecto.Query, warn: false
50 | alias PhoenixDatatablesExample.Repo
51 | alias PhoenixDatatablesExample.Stock.Item
52 |
53 | def datatable_items(params) do
54 | Repo.fetch_datatable(Item, params)
55 | end
56 | end
57 | ```
58 |
59 | ## Controller
60 |
61 | The controller is like any other Phoenix json controller - the raw params request
62 | from Datatables needs to be passed to the datatables context function
63 | and the output sent to the view for rendering as json.
64 | Typically the routing entry would be setup under the :api scope.
65 |
66 | ```elixir
67 | defmodule PhoenixDatatablesExampleWeb.ItemTableController do
68 | use PhoenixDatatablesExampleWeb, :controller
69 | alias PhoenixDatatablesExample.Stock
70 |
71 | action_fallback PhoenixDatatablesExampleWeb.FallbackController
72 |
73 | def index(conn, params) do
74 | render(conn, :index, payload: Stock.datatable_items(params))
75 | end
76 | end
77 |
78 | #router.ex
79 |
80 | scope "/api", PhoenixDatatablesExampleWeb do
81 | pipe_through :api
82 |
83 | get "/items", ItemTableController, :index
84 | end
85 | ```
86 |
87 | ## View
88 |
89 | As with any Phoenix json method, a loaded Ecto schema cannot be serialized directly
90 | to json by `Poison`. There are two solutions: Either the Ecto query needs to use a select to return
91 | a plain map, e.g.
92 |
93 |
94 | ```elixir
95 | from item in Item,
96 | select: %{
97 | nsn: item.nsn,
98 | rep_office: item.rep_office,
99 | ...
100 | }
101 | ```
102 |
103 |
104 | Or a map function is required to transform the results in the view. This is preferred if other
105 | transformations are also required.
106 |
107 | ```elixir
108 | def render("index.json", %{payload: payload}) do
109 | PhoenixDatatables.map_payload(payload, &item_json/1)
110 | end
111 |
112 | def item_json(item) do
113 | %{
114 | nsn: item.nsn,
115 | rep_office: item.rep_office,
116 | common_name: item.common_name,
117 | description: item.description,
118 | price: item.price,
119 | ui: item.ui,
120 | aac: item.aac
121 | }
122 | end
123 | ```
124 |
125 | ## Client
126 |
127 | The client uses jQuery and datatables.net packages; those need to be in your `package.json`.
128 |
129 | A very basic client implementation might look something like the below - what is most important
130 | is that `serverSide: true` is set and the `ajax: ` option is set to the correct route based on your entry in `router.ex`.
131 |
132 | There are many, many options that can be set and various hooks into the request/response lifecycle
133 | that can be used to customize rendering and enable various features - please refer to the
134 | excellent manual, references and community content available throught the DataTables
135 | [website](https://datatables.net/manual/server-side).
136 |
137 | `package.json`
138 |
139 | ```json
140 |
141 | "dependencies": {
142 | "jquery": "^3.2.1",
143 | "datatables.net": "^1.10.15",
144 | "datatables.net-dt": "^1.10.15"
145 | },
146 |
147 | "devDependencies": {
148 | "copycat-brunch": "^1.1.0"
149 | }
150 | ```
151 |
152 | `index.html.eex`
153 |
154 | ```html
155 |
156 |
157 |
158 | Nsn
159 | Rep office
160 | Common name
161 | Description
162 | Price
163 | Ui
164 | Aac
165 |
166 |
167 |
168 | ```
169 |
170 | `app.js`
171 |
172 | ```javascript
173 | import $ from 'jquery';
174 | import dt from 'datatables.net';
175 |
176 | $(document).ready(() => {
177 | dt();
178 | $('[data-datatable-server]').dataTable({
179 | serverSide: true,
180 | ajax: 'api/items',
181 | columns: [
182 | { data: "nsn" },
183 | { data: "rep_office" },
184 | { data: "common_name" },
185 | { data: "description" },
186 | { data: "price" },
187 | { data: "ui" },
188 | { data: "aac" }
189 | ]
190 | };
191 | };
192 | ```
193 |
194 | ## Joins
195 |
196 | Ecto queryables using joins are supported with automatic introspection - meaning columns used in the DataTable will be sortable and searchable if they are specified appropriately in the client-side configuration. The `example/` project in the source repo works this way.
197 |
198 | Assuming an Ecto queryable that looks like:
199 |
200 | ```elixir
201 | query =
202 | from item in Item,
203 | join: category in assoc(item, :category),
204 | join: unit in assoc(item, :unit),
205 | preload: [category: category, unit: unit]
206 | ```
207 |
208 | And a view transformation that looks like:
209 |
210 | ```elixir
211 | def item_json(item) do
212 | %{
213 | nsn: item.nsn,
214 | rep_office: item.rep_office,
215 | common_name: item.common_name,
216 | description: item.description,
217 | price: item.price,
218 | ui: item.ui,
219 | aac: item.aac,
220 | unit_description: item.unit.description,
221 | category_name: item.category.name,
222 | }
223 | end
224 | ```
225 |
226 | Could be used with a client-side configuration that looks like:
227 | ```javascript
228 | columns: [
229 | { data: "nsn" },
230 | { data: "category_name", name: "category.name"},
231 | { data: "common_name" },
232 | { data: "description" },
233 | { data: "price" },
234 | { data: "unit_description", name: "unit.description" },
235 | { data: "aac" },
236 | ]
237 | });
238 | ```
239 |
240 | You'll notice this differs from the basic configuration in that a `name` attribute is specified with the qualified column name. You could alternatively use the value `unit.description` in the `data` attribute and not supply a name attribute, but then the DataTables client library will expect to find a nested structure in the response message, so your view would have to nest it e.g.:
241 |
242 | ```elixir
243 | def item_json(item) do
244 | %{
245 | nsn: item.nsn,
246 | rep_office: item.rep_office,
247 | common_name: item.common_name,
248 | description: item.description,
249 | price: item.price,
250 | ui: item.ui,
251 | aac: item.aac,
252 | unit: %{
253 | description: item.unit.description
254 | },
255 | category: %{
256 | name: item.category.name
257 | },
258 | }
259 | end
260 | ```
261 |
262 | The important thing to understand is that when a `name` attribute is supplied, the server uses that to identify the field to search / sort - regardless of the value of the `data` attribute. The *client* library always uses `data` to identify the data path to map the response into the generated HTML. The client uses the `name` attribute only to make it easier to refer to columns by name in scripts using the client API.
263 |
264 | ## Credits
265 |
266 | Libraries which provided inspiration and some code include:
267 |
268 | * `scrivener_ecto`: https://github.com/drewolson/scrivener_ecto
269 |
270 | * `ex_sieve`: https://github.com/valyukov/ex_sieve
271 |
--------------------------------------------------------------------------------
/lib/phoenix_datatables/query.ex:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.Query do
2 | @moduledoc """
3 | Functions for updating an `Ecto.Query` based on Datatables request parameters.
4 | """
5 | import Ecto.Query
6 | use PhoenixDatatables.Query.Macros
7 | alias Ecto.Query.JoinExpr
8 | alias PhoenixDatatables.Query.Attribute
9 | alias PhoenixDatatables.QueryException
10 | alias PhoenixDatatables.Request.Column
11 | alias PhoenixDatatables.Request.Params
12 | alias PhoenixDatatables.Request.Search
13 |
14 | @doc """
15 | Add order_by clauses to the provided queryable based on the "order" params provided
16 | in the Datatables request.
17 | For some queries, `:columns` need to be passed - see documentation for `PhoenixDatatables.execute`
18 | for details.
19 | """
20 | def sort(queryable, params, options \\ nil) do
21 | sorts =
22 | if options && Keyword.has_key?(options, :columns) do
23 | build_column_sorts(params, options[:columns])
24 | else
25 | build_schema_sorts(queryable, params)
26 | end
27 |
28 | do_sorts(queryable, sorts, options)
29 | end
30 |
31 | defp build_column_sorts(%Params{order: orders} = params, columns) do
32 | for order <- orders do
33 | with dir when is_atom(dir) <- cast_dir(order.dir),
34 | %Column{} = column <- params.columns[order.column],
35 | true <- column.orderable,
36 | {column, join_index} when is_number(join_index) <-
37 | cast_column(column.data, columns) do
38 | {dir, column, join_index}
39 | end
40 | end
41 | end
42 |
43 | defp build_schema_sorts(queryable, %Params{order: orders} = params) do
44 | schema = schema(queryable)
45 |
46 | for order <- orders do
47 | with dir when is_atom(dir) <- cast_dir(order.dir),
48 | %Column{} = column <- params.columns[order.column],
49 | true <- column.orderable,
50 | %Attribute{} = attribute <- Attribute.extract(column.data, schema),
51 | join_index when is_number(join_index) <-
52 | join_order(queryable, attribute.parent) do
53 | {dir, attribute.name, join_index}
54 | end
55 | end
56 | end
57 |
58 | defp do_sorts(queryable, sorts, options) do
59 | Enum.reduce(sorts, queryable, fn {dir, column, join_index}, queryable ->
60 | order_relation(queryable, join_index, dir, column, options)
61 | end)
62 | end
63 |
64 | @doc false
65 | def join_order(_, nil), do: 0
66 |
67 | def join_order(%Ecto.Query{} = queryable, parent) do
68 | case Enum.find_index(queryable.joins, &(join_relation(&1) == parent)) do
69 | nil -> nil
70 | number when is_number(number) -> number + 1
71 | end
72 | end
73 |
74 | def join_order(queryable, parent) do
75 | QueryException.raise(:join_order, """
76 |
77 | An attempt was made to interrogate the join structure of #{inspect(queryable)}
78 | This is not an %Ecto.Query{}. The most likely cause for this error is using
79 | dot-notation(e.g. 'category.name') in the column name defined in the datatables
80 | client config but a simple Schema (no join) is used as the underlying queryable.
81 |
82 | Please check the client config for the fields belonging to #{inspect(parent)}. If
83 | the required field does belong to a different parent schema, that schema needs to
84 | be joined in the Ecto query.
85 |
86 | """)
87 | end
88 |
89 | defp join_relation(%JoinExpr{assoc: {_, relation}}), do: relation
90 |
91 | defp join_relation(_) do
92 | QueryException.raise(:join_relation, """
93 |
94 | PhoenixDatatables queryables with non-assoc joins must be accompanied by :columns
95 | options to define sortable column names and join orders.
96 |
97 | See docs for PhoenixDatatables.execute for more information.
98 |
99 | """)
100 | end
101 |
102 | defp schema(%Ecto.Query{} = query), do: query.from.source |> check_from() |> elem(1)
103 | defp schema(schema) when is_atom(schema), do: schema
104 |
105 | defp check_from(%Ecto.SubQuery{}) do
106 | QueryException.raise(:schema, """
107 |
108 | PhoenixDatatables queryables containing subqueries must be accompanied by :columns
109 | options to define sortable column names and join orders.
110 |
111 | See docs for PhoenixDatatables.execute for more information.
112 |
113 | """)
114 | end
115 |
116 | defp check_from(from), do: from
117 |
118 | defp cast_column(column_name, sortable)
119 | # Keyword
120 | when is_list(sortable) and
121 | is_tuple(hd(sortable)) and
122 | is_atom(elem(hd(sortable), 0)) do
123 | [parent | child] = String.split(column_name, ".")
124 |
125 | if parent in Enum.map(Keyword.keys(sortable), &Atom.to_string/1) do
126 | member = Keyword.fetch!(sortable, String.to_atom(parent))
127 |
128 | case member do
129 | children when is_list(children) ->
130 | with [child] <- child,
131 | [child] <-
132 | Enum.filter(
133 | Keyword.keys(children),
134 | &(Atom.to_string(&1) == child)
135 | ),
136 | {:ok, order} when is_number(order) <-
137 | Keyword.fetch(children, child) do
138 | {child, order}
139 | else
140 | _ -> {:error, "#{column_name} is not a sortable column."}
141 | end
142 |
143 | order when is_number(order) ->
144 | {String.to_atom(parent), order}
145 | end
146 | else
147 | {:error, "#{column_name} is not a sortable column."}
148 | end
149 | end
150 |
151 | defp cast_column(column_name, sortable) do
152 | if column_name in Enum.map(sortable, &Atom.to_string/1) do
153 | {String.to_atom(column_name), 0}
154 | end
155 | end
156 |
157 | defp cast_dir("asc"), do: :asc
158 | defp cast_dir("desc"), do: :desc
159 | defp cast_dir(wrong), do: {:error, "#{wrong} is not a valid sort order."}
160 |
161 | @doc """
162 | Add offset and limit clauses to the provided queryable based on the "length" and
163 | "start" parameters passed in the Datatables request.
164 | """
165 | def paginate(queryable, params) do
166 | length = convert_to_number_if_string(params.length)
167 | start = convert_to_number_if_string(params.start)
168 |
169 | if length == -1 do
170 | queryable
171 | else
172 | queryable
173 | |> limit(^length)
174 | |> offset(^start)
175 | end
176 | end
177 |
178 | defp convert_to_number_if_string(num) do
179 | case is_binary(num) do
180 | true ->
181 | {num, _} = Integer.parse(num)
182 | num
183 |
184 | false ->
185 | num
186 | end
187 | end
188 |
189 | @doc """
190 | Add AND where clause to the provided queryable based on the "search" parameter passed
191 | in the Datatables request.
192 | For some queries, `:columns` need to be passed - see documentation for `PhoenixDatatables.execute`
193 | for details.
194 | """
195 | def search(queryable, params, options \\ []) do
196 | columns = options[:columns]
197 | do_search(queryable, params, columns)
198 | end
199 |
200 | defp do_search(queryable, %Params{search: %Search{value: ""}}, _), do: queryable
201 |
202 | defp do_search(queryable, %Params{} = params, searchable) when is_list(searchable) do
203 | search_term = "%#{params.search.value}%"
204 | dynamic = dynamic([], false)
205 |
206 | dynamic =
207 | Enum.reduce(params.columns, dynamic, fn {_, v}, acc_dynamic ->
208 | with {column, join_index} when is_number(join_index) <-
209 | v.data |> cast_column(searchable),
210 | true <- v.searchable do
211 | acc_dynamic
212 | |> search_relation(
213 | join_index,
214 | column,
215 | search_term
216 | )
217 | else
218 | _ -> acc_dynamic
219 | end
220 | end)
221 |
222 | where(queryable, [], ^dynamic)
223 | end
224 |
225 | defp do_search(queryable, %Params{search: search, columns: columns}, _searchable) do
226 | search_term = "%#{search.value}%"
227 | schema = schema(queryable)
228 | dynamic = dynamic([], false)
229 |
230 | dynamic =
231 | Enum.reduce(columns, dynamic, fn {_, v}, acc_dynamic ->
232 | with %Attribute{} = attribute <- v.data |> Attribute.extract(schema),
233 | true <- v.searchable do
234 | acc_dynamic
235 | |> search_relation(
236 | join_order(queryable, attribute.parent),
237 | attribute.name,
238 | search_term
239 | )
240 | else
241 | _ -> acc_dynamic
242 | end
243 | end)
244 |
245 | where(queryable, [], ^dynamic)
246 | end
247 |
248 | def search_columns(queryable, params, options \\ []) do
249 | if has_column_search?(params.columns) do
250 | columns = options[:columns] || []
251 | do_search_columns(queryable, params, columns)
252 | else
253 | queryable
254 | end
255 | end
256 |
257 | def has_column_search?(columns) when is_map(columns) do
258 | columns = Map.values(columns)
259 | Enum.any?(columns, &(&1.search.value != ""))
260 | end
261 |
262 | def has_column_search?(_), do: false
263 |
264 | defp do_search_columns(queryable, params, columns) do
265 | dynamic = dynamic([], true)
266 |
267 | dynamic =
268 | Enum.reduce(params.columns, dynamic, fn {_, v}, acc_dynamic ->
269 | with {column, join_index} when is_number(join_index) <-
270 | cast_column(v.data, columns),
271 | true <- v.searchable,
272 | true <- v.search.value != "" do
273 | acc_dynamic
274 | |> search_relation_and(
275 | join_index,
276 | column,
277 | "%#{v.search.value}%"
278 | )
279 | else
280 | _ -> acc_dynamic
281 | end
282 | end)
283 |
284 | where(queryable, [], ^dynamic)
285 | end
286 |
287 | # credo:disable-for-lines:2
288 | # credit to scrivener library:
289 | # https://github.com/drewolson/scrivener_ecto/blob/master/lib/scrivener/paginater/ecto/query.ex
290 | # Copyright (c) 2016 Andrew Olson
291 | @doc """
292 | Calculate the number of records that will retrieved with the provided queryable.
293 | """
294 | def total_entries(queryable, repo) do
295 | total_entries =
296 | queryable
297 | |> exclude(:preload)
298 | |> exclude(:select)
299 | |> exclude(:order_by)
300 | |> exclude(:limit)
301 | |> exclude(:offset)
302 | |> subquery
303 | |> select(count("*"))
304 | |> repo.one
305 |
306 | total_entries || 0
307 | end
308 | end
309 |
310 | defmodule PhoenixDatatables.QueryException do
311 | defexception [:message, :operation]
312 |
313 | # yes we know it raises
314 | @dialyzer {:no_return, raise: 1}
315 |
316 | def raise(operation, message \\ "") do
317 | Kernel.raise(__MODULE__, operation: operation, message: message)
318 | end
319 | end
320 |
--------------------------------------------------------------------------------
/example/test/phoenix_datatables/query_test.exs:
--------------------------------------------------------------------------------
1 | defmodule PhoenixDatatables.QueryTest do
2 | use PhoenixDatatablesExample.DataCase
3 | alias PhoenixDatatables.Request
4 | alias PhoenixDatatables.Query
5 | alias PhoenixDatatables.Query.Attribute
6 | alias PhoenixDatatablesExample.Repo
7 | alias PhoenixDatatablesExample.Stock.Item
8 | alias PhoenixDatatablesExample.Stock.Category
9 | alias PhoenixDatatablesExample.Factory
10 |
11 | @sortable [:nsn, :common_name]
12 | @sortable_join [nsn: 0, category: [name: 1]]
13 | @sortable_nulls_last [nulls_last: true]
14 | @sortable_no_nulls_last [nulls_last: false]
15 |
16 | describe "sort" do
17 | test "appends order-by clause to a single-table query specifying sortable fields" do
18 | [item1, item2] = add_items()
19 | assert item1.id != nil
20 | assert item2.id != nil
21 | assert item2.nsn < item1.nsn
22 |
23 | params = Factory.raw_request |> Request.receive
24 | query = Query.sort(Item, params, @sortable)
25 |
26 | [ritem2, ritem1] = query |> Repo.all
27 | assert item1.id == ritem1.id
28 | assert item2.id == ritem2.id
29 | end
30 |
31 | test "appends order-by clause to a single-table, single order_by queryable request" do
32 | [item1, item2] = add_items()
33 | assert item1.id != nil
34 | assert item2.id != nil
35 | assert item2.nsn < item1.nsn
36 |
37 | params = Factory.raw_request |> Request.receive
38 | query = Query.sort(Item, params)
39 |
40 | [ritem2, ritem1] = query |> Repo.all
41 | assert item1.id == ritem1.id
42 | assert item2.id == ritem2.id
43 | end
44 |
45 | test "appends order-by clause to a join query specifying sortable fields & orders" do
46 | [item1, item2] = add_items()
47 |
48 | query =
49 | (from item in Item,
50 | join: category in assoc(item, :category),
51 | select: %{id: item.id, category_name: category.name})
52 |
53 | do_test = fn request ->
54 | params = request |> Request.receive
55 | query = Query.sort(query, params, @sortable_join)
56 |
57 | [ritem2, ritem1] = query |> Repo.all
58 | assert item1.id == ritem1.id
59 | assert item2.id == ritem2.id
60 | end
61 |
62 | do_test.(Factory.raw_request)
63 | do_test.(%{Factory.raw_request | "order" => %{"0" => %{"column" => "7", "dir" => "asc"}}})
64 | end
65 |
66 | test "appends order-by clause to a joined table" do
67 | [item1, item2] = add_items()
68 |
69 | request = %{Factory.raw_request | "order" => %{"0" => %{"column" => "7", "dir" => "asc"}}}
70 | query =
71 | (from item in Item,
72 | join: category in assoc(item, :category),
73 | select: %{id: item.id, category_name: category.name})
74 |
75 | params = request |> Request.receive
76 | query = Query.sort(query, params)
77 |
78 | [ritem2, ritem1] = query |> Repo.all
79 | assert item1.id == ritem1.id
80 | assert item2.id == ritem2.id
81 |
82 | end
83 |
84 | test "appends order-by clause to a joined table with name attribute" do
85 | [item1, item2] = add_items()
86 |
87 | request = %{Factory.raw_request | "order" => %{"0" => %{"column" => "7", "dir" => "asc"}}}
88 | request = put_in(request, ["columns", "7", "data"], "category_name")
89 | request = put_in(request, ["columns", "7", "name"], "category.name")
90 | query =
91 | (from item in Item,
92 | join: category in assoc(item, :category),
93 | select: %{id: item.id, category_name: category.name})
94 |
95 | params = request |> Request.receive
96 | query = Query.sort(query, params)
97 |
98 | [ritem2, ritem1] = query |> Repo.all
99 | assert item1.id == ritem1.id
100 | assert item2.id == ritem2.id
101 | end
102 |
103 | test "appends multiple order-by clause to a table" do
104 | orderings = %{"0" => %{"column" => "1", "dir" => "asc"},
105 | "1" => %{"column" => "2", "dir" => "asc"}}
106 | request = %{Factory.raw_request | "order" => orderings}
107 |
108 | params = request |> Request.receive
109 | query = Query.sort(Item, params)
110 |
111 | assert query.order_bys |> Enum.count == 2
112 | end
113 |
114 | test "appends multiple order-by clause to a joined query" do
115 | load_test_seeds(10)
116 |
117 | orderings = %{"0" => %{"column" => "7", "dir" => "asc"},
118 | "1" => %{"column" => "8", "dir" => "asc"}}
119 |
120 | request = %{Factory.raw_request | "order" => orderings}
121 | query =
122 | (from item in Item,
123 | join: category in assoc(item, :category),
124 | join: unit in assoc(item, :unit),
125 | select: %{id: item.id,
126 | category_name: category.name,
127 | unit_description: unit.description
128 | })
129 |
130 | params = request |> Request.receive
131 | query = Query.sort(query, params)
132 |
133 | assert query.order_bys |> Enum.count == 2
134 |
135 | [ritem1, ritem2, ritem3 | _] = query |> Repo.all
136 | assert ritem1.unit_description == "Dozen"
137 | assert ritem2.unit_description == "Dozen"
138 | assert ritem3.unit_description == "Each"
139 | end
140 |
141 | test "with nulls last: true" do
142 | original_items = add_items_with_nils()
143 | [item1, item2] = original_items
144 | assert item1.nilable_field == nil
145 | assert item2.nilable_field != nil
146 |
147 | request = %{Factory.raw_request | "order" => %{"0" => %{"column" => "9", "dir" => "desc"}}}
148 | query =
149 | (from item in Item,
150 | select: %{id: item.id, nilable_field: item.nilable_field})
151 | params = request |> Request.receive
152 | query = Query.sort(query, params)
153 | [ritem1, ritem2] = query |> Repo.all
154 | assert ritem1.nilable_field == nil
155 | assert ritem2.nilable_field != nil
156 |
157 | query =
158 | (from item in Item,
159 | select: %{id: item.id, nilable_field: item.nilable_field})
160 | params = request |> Request.receive
161 | query = Query.sort(query, params, @sortable_nulls_last)
162 | [ritem1, ritem2] = query |> Repo.all
163 | assert ritem1.nilable_field != nil
164 | assert ritem2.nilable_field == nil
165 | end
166 |
167 | test "with nulls last: false" do
168 | original_items = add_items_with_nils()
169 | [item1, item2] = original_items
170 | assert item1.nilable_field == nil
171 | assert item2.nilable_field != nil
172 |
173 | request = %{Factory.raw_request | "order" => %{"0" => %{"column" => "9", "dir" => "desc"}}}
174 | query =
175 | (from item in Item,
176 | select: %{id: item.id, nilable_field: item.nilable_field})
177 | params = request |> Request.receive
178 | query = Query.sort(query, params, @sortable_no_nulls_last)
179 | [ritem1, ritem2] = query |> Repo.all
180 | assert ritem1.nilable_field == nil
181 | assert ritem2.nilable_field != nil
182 | end
183 | end
184 |
185 | describe "paginate" do
186 | test "appends appropriate paginate clauses to a single-table queryable request" do
187 | received_params = Factory.raw_request
188 | query = Query.paginate(Item, Request.receive(received_params))
189 | [{length, _}] = query.limit.params
190 | assert length == String.to_integer(received_params["length"])
191 | [{offset, _}] = query.offset.params
192 | assert offset == String.to_integer(received_params["start"])
193 | end
194 | end
195 |
196 | describe "attributes" do
197 | test "can find string attributes of a an Ecto schema" do
198 | %Attribute{name: name} = Attribute.extract("nsn", Item)
199 | assert name == :nsn
200 | end
201 |
202 | test "can find string attributes of a related schema" do
203 | %Attribute{name: name, parent: parent} = Attribute.extract("category.name", Item)
204 | assert name == :name
205 | assert parent == :category
206 | end
207 | end
208 |
209 | describe "join_order" do
210 | test "finds index of matching parent relation" do
211 | query =
212 | (from item in Item,
213 | join: category in assoc(item, :category),
214 | join: unit in assoc(item, :unit),
215 | select: %{id: item.id, category_name: category.name})
216 | assert Query.join_order(query, nil) == 0
217 | assert Query.join_order(query, :category) == 1
218 | assert Query.join_order(query, :unit) == 2
219 | end
220 | end
221 |
222 | describe "search" do
223 |
224 | test "returns 1 result when 1 match found" do
225 | add_items()
226 | query =
227 | (from item in Item,
228 | join: category in assoc(item, :category),
229 | select: %{id: item.id, category_name: category.name})
230 | params =
231 | Map.put(
232 | Factory.raw_request,
233 | "search",
234 | %{"regex" => "false", "value" => "1NSN"}
235 | ) |> Request.receive
236 | results = Query.search(query, params)
237 | |> Repo.all
238 | assert Enum.count(results) == 1
239 | end
240 |
241 | test "will apply a where clause as AND condition with search param" do
242 | add_items()
243 | query =
244 | (from item in Item,
245 | join: category in assoc(item, :category),
246 | select: %{id: item.id, category_name: category.name},
247 | where: category.name == "B")
248 | params =
249 | Map.put(
250 | Factory.raw_request,
251 | "search",
252 | %{"regex" => "false", "value" => "1NSN"}
253 | )
254 | |> Request.receive
255 | results =
256 | query
257 | |> Query.search(params)
258 | |> Repo.all
259 | assert Enum.count(results) == 0
260 | end
261 |
262 | test "will only search in searchable fields when those are specified" do
263 | add_items()
264 | query =
265 | (from item in Item,
266 | join: category in assoc(item, :category),
267 | select: %{id: item.id, category_name: category.name})
268 | params =
269 | Map.put(
270 | Factory.raw_request,
271 | "search",
272 | %{"regex" => "false", "value" => "1NSN"}
273 | ) |> Request.receive
274 | results =
275 | Query.search(query, params, columns: [:frogs])
276 | |> Repo.all
277 |
278 | assert Enum.count(results) == 0
279 | end
280 |
281 | end
282 |
283 | describe "search_columns" do
284 | test "returns 1 result when 1 column matches" do
285 | add_items()
286 |
287 | query =
288 | (from item in Item,
289 | join: category in assoc(item, :category),
290 | select: %{id: item.id, category_name: category.name, nsn: item.nsn})
291 |
292 | request = Factory.raw_request()
293 | params = update_in(request, ["columns", "0", "search"], &(Map.put(&1, "value", "1NSN")))
294 | |> Request.receive()
295 |
296 | results = Query.search_columns(query, params, [columns: [id: 0, category_name: 0, nsn: 0]])
297 | |> Repo.all()
298 |
299 | assert Enum.count(results) == 1
300 | end
301 |
302 | test "returns both results when they all match column searches" do
303 | add_items()
304 |
305 | query =
306 | (from item in Item,
307 | join: category in assoc(item, :category),
308 | select: %{id: item.id, category_name: category.name, nsn: item.nsn})
309 |
310 | request = Factory.raw_request()
311 | params = update_in(request, ["columns", "0", "search"], &(Map.put(&1, "value", "NSN")))
312 | |> Request.receive()
313 |
314 | results = Query.search_columns(query, params, [columns: [id: 0, category_name: 0, nsn: 0]])
315 | |> Repo.all()
316 |
317 | assert Enum.count(results) == 2
318 | end
319 |
320 | test "returns no results when not all columns match" do
321 | add_items()
322 |
323 | query =
324 | (from item in Item,
325 | join: category in assoc(item, :category),
326 | select: %{id: item.id, category_name: category.name, nsn: item.nsn, aac: item.aac})
327 |
328 | request = Factory.raw_request()
329 | params = request
330 | |> update_in(["columns", "0", "search"], &(Map.put(&1, "value", "NSN")))
331 | |> update_in(["columns", "6", "search"], &(Map.put(&1, "value", "no match")))
332 | |> Request.receive()
333 |
334 | results = Query.search_columns(query, params, [columns: [id: 0, category_name: 0, nsn: 0, aac: 0]])
335 | |> Repo.all()
336 |
337 | assert Enum.empty?(results)
338 | end
339 | end
340 |
341 | describe "total_entries" do
342 | test "returns number of results in specified schema" do
343 | add_items()
344 | assert Query.total_entries(Item, Repo) == length(Repo.all(Item))
345 | end
346 |
347 | test "returns number of results in a query" do
348 | add_items()
349 | query =
350 | (from item in Item,
351 | join: category in assoc(item, :category),
352 | select: %{id: item.id}
353 | )
354 | request =
355 | Map.put(
356 | Factory.raw_request,
357 | "search",
358 | %{"regex" => "false", "value" => "1NSN"}
359 | )
360 | |> Request.receive
361 | search_results = Query.search(query, request)
362 | assert Query.total_entries(search_results, Repo) == 1
363 | end
364 | end
365 |
366 | def add_items do
367 | category_a = insert_category!("A")
368 | category_b = insert_category!("B")
369 | item = Map.put(Factory.item, :category_id, category_b.id)
370 | item2 = Map.put(Factory.item, :category_id, category_a.id)
371 | item2 = %{item2 | nsn: "1NSN"}
372 | one = insert_item! item
373 | two = insert_item! item2
374 | [one, two]
375 | end
376 |
377 | def add_items_with_nils do
378 | category_a = insert_category!("A")
379 | category_b = insert_category!("B")
380 | item = Map.put(Factory.item, :category_id, category_a.id)
381 | item2 = Map.put(Factory.item, :category_id, category_b.id)
382 | item2 = %{item2 | nilable_field: "not nil"}
383 | one = insert_item! item
384 | two = insert_item! item2
385 | [one, two]
386 | end
387 |
388 | def insert_item!(item) do
389 | cs = Item.changeset(%Item{}, item)
390 | Repo.insert!(cs)
391 | end
392 |
393 | def insert_category!(category) do
394 | cs = Category.changeset(%Category{}, %{name: category})
395 | Repo.insert!(cs)
396 | end
397 | end
398 |
--------------------------------------------------------------------------------