Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ Phoenix support `turbo_html`, check [this](https://github.com/zven21/turbo_html)

* [Getting started](#getting-started)
* [Examples](#examples)
* [JSON/Map Params](#json-map-params)
* [Search Matchers](#search-matchers)
* [Features](#features)
* [Contributing](#contributing)
Expand All @@ -36,6 +37,51 @@ def deps do
end
```

## JSON/Map Params

Turbo.Ecto supports both classic flat params (`q/filter`, `s/sort`, `page/per_page`) and structured JSON/map params for better frontend-backend collaboration and validation.

- Filters (recommended):

```elixir
# single
%{"filters" => %{"field" => "name", "op" => "like", "value" => "elixir"}}

# multiple; association fields supported via dot notation
%{
"filters" => [
%{"field" => "name", "op" => "like", "value" => "elixir"},
%{"field" => "category.name", "op" => "eq", "value" => "tech"}
]
}
```

This can be converted to the legacy `filter` style, e.g. `%{"filter" => %{"name_like" => "elixir", "category_name_eq" => "tech"}}`.

- Sorts:

```elixir
# single
%{"sorts" => %{"field" => "updated_at", "direction" => "asc"}}

# multiple
%{"sorts" => [
%{"field" => "updated_at", "dir" => "desc"},
%{"field" => "inserted_at", "dir" => "asc"}
]}
```

- Paginate:

```elixir
%{"paginate" => %{"page" => 2, "per_page" => 5}}
```

Notes:
- Use `op` for operators and `field` (or `attribute`) for field names.
- Sort direction accepts `dir` or `direction`; fields can be association paths like `category.name`.
- Legacy `q/filter`, `s/sort`, `page/per_page` remain supported.

Add the Repo of your app and the desired per_page to the `:turbo_ecto` configuration in `config.exs`:

```elixir
Expand Down
6 changes: 3 additions & 3 deletions config/config.exs
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
use Mix.Config
import Config

config :logger, :console, level: :error
config :logger, :console, level: :warning

config :turbo_ecto, Turbo.Ecto,
repo: Turbo.Ecto.TestRepo,
per_page: 10

config :turbo_ecto, ecto_repos: [Turbo.Ecto.TestRepo]

import_config "#{Mix.env()}.exs"
import_config "#{config_env()}.exs"
2 changes: 1 addition & 1 deletion config/dev.exs
Original file line number Diff line number Diff line change
@@ -1 +1 @@
use Mix.Config
import Config
4 changes: 2 additions & 2 deletions config/test.exs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use Mix.Config
import Config

config :turbo_ecto, Turbo.Ecto.TestRepo,
username: "postgres",
Expand All @@ -7,4 +7,4 @@ config :turbo_ecto, Turbo.Ecto.TestRepo,
hostname: "localhost",
pool: Ecto.Adapters.SQL.Sandbox

config :logger, level: :warn
config :logger, level: :warning
12 changes: 3 additions & 9 deletions lib/turbo_ecto.ex
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,7 @@ defmodule Turbo.Ecto do
Expect output:

```elixir
iex> params = %{"q" => %{"post_name_or_content_like" => "elixir"}}

iex> Turbo.Ecto.turboq(Turbo.Ecto.Schemas.Reply, params)
iex> Turbo.Ecto.turboq(Turbo.Ecto.Schemas.Reply, %{"q" => %{"post_name_or_content_like" => "elixir"}})
#Ecto.Query<from r0 in Turbo.Ecto.Schemas.Reply, join: p1 in assoc(r0, :post), where: like(p1.name, \"%elixir%\") or like(r0.content, \"%elixir%\"), limit: 10, offset: 0>
```

Expand All @@ -56,9 +54,7 @@ defmodule Turbo.Ecto do

## Example

iex> params = %{"q" => %{"name_or_replies_content_like" => "elixir", "price_eq" => "1"}, "s" => "updated_at+asc", "per_page" => 5, "page" => 1}

iex> Turbo.Ecto.turbo(Turbo.Ecto.Schemas.Post, params)
iex> Turbo.Ecto.turbo(Turbo.Ecto.Schemas.Post, %{"q" => %{"name_or_replies_content_like" => "elixir", "price_eq" => "1"}, "s" => "updated_at+asc", "per_page" => 5, "page" => 1})
%{
paginate: %{current_page: 1, per_page: 5, next_page: nil, prev_page: nil, total_count: 0, total_pages: 0},
datas: []
Expand Down Expand Up @@ -109,9 +105,7 @@ defmodule Turbo.Ecto do

## Example

iex> params = %{"q" => %{"name_or_body_like" => "elixir", "a_eq" => ""}, "s" => "updated_at+asc", "per_page" => 5, "page" => 1}

iex> Turbo.Ecto.turboq(Turbo.Ecto.Schemas.Post, params)
iex> Turbo.Ecto.turboq(Turbo.Ecto.Schemas.Post, %{"q" => %{"name_or_body_like" => "elixir", "a_eq" => ""}, "s" => "updated_at+asc", "per_page" => 5, "page" => 1})
#Ecto.Query<from p0 in Turbo.Ecto.Schemas.Post, where: like(p0.name, \"%elixir%\") or like(p0.body, \"%elixir%\"), order_by: [asc: p0.updated_at], limit: 5, offset: 0>

"""
Expand Down
9 changes: 9 additions & 0 deletions lib/turbo_ecto/builder.ex
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,15 @@ defmodule Turbo.Ecto.Builder do
relations = build_relations(searches, sorts)
binding = relations |> build_binding()

# ensure root named binding using quoted to avoid compile var issues
queryable =
quote do
import Ecto.Query
from(q in unquote(Macro.escape(queryable)), as: :query)
end
|> Code.eval_quoted([], __ENV__)
|> elem(0)

queryable
|> join(relations)
|> where(searches, binding)
Expand Down
37 changes: 13 additions & 24 deletions lib/turbo_ecto/builder/join.ex
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
defmodule Turbo.Ecto.Builder.Join do
@moduledoc false

alias Ecto.Query.Builder.Join, as: Join

@doc """
Builds a quoted join expression.

Expand All @@ -11,7 +9,7 @@ defmodule Turbo.Ecto.Builder.Join do
iex> query = Turbo.Ecto.Schemas.Post
iex> relations = [:category, :repleis]
iex> Turbo.Ecto.Builder.Join.build(query, relations)
#Ecto.Query<from p0 in Turbo.Ecto.Schemas.Post, join: c1 in assoc(p0, :category), join: r2 in assoc(p0, :repleis)>
#Ecto.Query<from p0 in Turbo.Ecto.Schemas.Post, join: c1 in assoc(p0, :category), as: :category, join: r2 in assoc(p0, :repleis), as: :repleis>

"""
@spec build(Ecto.Query.t(), [atom()]) :: Ecto.Query.t()
Expand All @@ -21,27 +19,18 @@ defmodule Turbo.Ecto.Builder.Join do

@spec apply_join(atom(), Ecto.Queryable.t()) :: Ecto.Query.t()
def apply_join(relation, query) do
query
|> Macro.escape()
|> Join.build(
:inner,
[{:query, [], Elixir}],
expr(relation),
nil,
nil,
nil,
nil,
nil,
__ENV__
)
|> elem(0)
|> Code.eval_quoted()
|> elem(0)
end
quoted =
quote do
import Ecto.Query

from(q in unquote(Macro.escape(query)),
join: r in assoc(q, unquote(relation)),
as: unquote(relation)
)
end

defp expr(relation) do
quote do
unquote(Macro.var(relation, Elixir)) in assoc(query, unquote(relation))
end
quoted
|> Code.eval_quoted([], __ENV__)
|> elem(0)
end
end
56 changes: 46 additions & 10 deletions lib/turbo_ecto/builder/order_by.ex
Original file line number Diff line number Diff line change
@@ -1,26 +1,62 @@
defmodule Turbo.Ecto.Builder.OrderBy do
@moduledoc false

alias Ecto.Query.Builder.OrderBy
alias Ecto.Query

@doc """
Builds a quoted order_by expression.
"""
@spec build(Macro.t(), [Macro.t()], [Macro.t()]) :: Macro.t()
@spec build(Query.t(), [map()], [term()]) :: Query.t()
def build(query, sorts, binding) do
query
|> Macro.escape()
|> OrderBy.build(binding, Enum.map(sorts, &expr/1), __ENV__)
|> Code.eval_quoted()
|> elem(0)
# If all sorts target the root binding, we can use atom fields safely
if Enum.all?(sorts, fn %{attribute: %{parent: parent}} -> parent == :query end) do
terms =
Enum.map(sorts, fn %{direction: direction, attribute: %{name: name}} ->
{direction, name}
end)

quoted =
quote do
import Ecto.Query
from(unquote(Macro.escape(query)), order_by: ^unquote(terms))
end

quoted
|> Code.eval_quoted([], __ENV__)
|> elem(0)
else
parent_to_index =
binding
|> Enum.with_index()
|> Enum.map(fn
{{name, _, _}, idx} -> {name, idx}
{name, idx} when is_atom(name) -> {name, idx}
end)
|> Map.new()

terms = Enum.map(sorts, &expr_with_index(&1, parent_to_index))

quoted =
quote do
import Ecto.Query
from(unquote(Macro.escape(query)), order_by: ^unquote(terms))
end

quoted
|> Code.eval_quoted([], __ENV__)
|> elem(0)
end
end

# [
# asc: {:field, [], [{:query, [], Elixir}, :updated_at]},
# desc: {:field, [], [{:query, [], Elixir}, :inserted_at]}
# ]
defp expr(%{direction: direction, attribute: %{name: name, parent: parent}}) do
parent = Macro.var(parent, Elixir)
quote do: {unquote(direction), field(unquote(parent), unquote(name))}
defp expr_with_index(
%{direction: direction, attribute: %{name: name, parent: parent}},
parent_to_index
) do
idx = Map.fetch!(parent_to_index, parent)
quote do: {unquote(direction), field(unquote({:&, [], [idx]}), unquote(name))}
end
end
Loading