Skip to content
Merged
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
109 changes: 109 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,72 @@ dynamics = FatEcto.HospitalBuilder.build(%{})
# Returns: dynamic([q], true) instead of nil
```

##### Example 7: Filtering on Joined Associations

Use nested `filterable` to filter on joined tables. The nested key (e.g., `doctors:`) becomes the expected `as:` binding name in your query.

```elixir
defmodule FatEcto.HospitalDynamicsBuilder do
use FatEcto.Query.Dynamics.Buildable,
filterable: [
name: ["$ILIKE"], # Direct filter on hospitals table
doctors: [ # Join filter: fields use :doctors binding
specialty: ["$EQUAL"],
rating: ["$GTE"]
]
]

def override_buildable(_field, _operator, _value), do: nil
end

import Ecto.Query

# Filter params use field names directly - "specialty" maps to :doctors binding
# because it's defined under `doctors:` in filterable config
params = %{"name" => %{"$ILIKE" => "%General%"}, "specialty" => %{"$EQUAL" => "Cardiology"}}
dynamics = FatEcto.HospitalDynamicsBuilder.build(params)

# Query must include the join with matching `as:` binding
FatEcto.FatHospital
|> join(:inner, [h], d in assoc(h, :doctors), as: :doctors)
|> where(^dynamics)
|> Repo.all()
```

##### Example 8: Field Aliases (Renaming API Fields)

When joined tables share the same field name (e.g., both `hospitals` and `doctors` have `name`), you can use aliases to expose them under different API names. Use the `{:schema_field, operators}` tuple syntax:

```elixir
defmodule MyApp.HospitalFilter do
use FatEcto.Query.Dynamics.Buildable,
filterable: [
name: ["$ILIKE", "$EQUAL"], # hospital's own name
doctors: [
doctor_name: {:name, ["$ILIKE"]}, # "doctor_name" in API -> :name in DB
rating: ["$GTE", "$LTE"]
]
],
default_dynamic: :return_true

def override_buildable(_field, _operator, _value), do: nil
end

# Now the API can filter both names without conflict
params = %{
"name" => %{"$EQUAL" => "City Hospital"},
"doctor_name" => %{"$ILIKE" => "%Smith%"}
}
dynamics = MyApp.HospitalFilter.build(params)

Hospital
|> join(:left, [h], d in assoc(h, :doctors), as: :doctors)
|> where(^dynamics)
|> Repo.all()

# Generates: WHERE h.name = 'City Hospital' AND d.name ILIKE '%Smith%'
```

---

### 🔄 FatEcto.Sort.Sortable – Effortless Sorting
Expand All @@ -202,6 +268,49 @@ defmodule Fat.SortQuery do
end
```

##### Sort Aliases

Just like Buildable, you can alias sort field names using the `{:schema_field, directions}` tuple:

```elixir
defmodule MyApp.HospitalSort do
use FatEcto.Sort.Sortable,
sortable: [
id: "*",
hospital_name: {:name, ["$ASC", "$DESC"]} # "hospital_name" in API -> :name in DB
]
end

order = MyApp.HospitalSort.build(%{"hospital_name" => "$ASC"})
# Generates: ORDER BY h.name ASC
```

##### Sorting on Joined Tables

Use nested keyword lists to sort on fields from joined associations — same syntax as Buildable join filters:

```elixir
defmodule MyApp.HospitalSort do
use FatEcto.Sort.Sortable,
sortable: [
name: "*",
doctors: [
doctor_name: {:name, "*"}, # alias on joined table
rating: ["$ASC", "$DESC"] # non-aliased join field
]
]
end

order = MyApp.HospitalSort.build(%{"doctor_name" => "$DESC", "rating" => "$ASC"})

Hospital
|> join(:left, [h], d in assoc(h, :doctors), as: :doctors)
|> order_by(^order)
|> Repo.all()

# Generates: ORDER BY d.name DESC, d.rating ASC
```

---

### 📌 FatEcto.Pagination.Paginator – Paginate Like a Pro
Expand Down
24 changes: 20 additions & 4 deletions lib/fat_ecto/pagination/v2_paginator.ex
Original file line number Diff line number Diff line change
Expand Up @@ -38,15 +38,16 @@ defmodule FatEcto.Pagination.V2Paginator do
alias FatEcto.Pagination.Helper

@repo Keyword.fetch!(unquote(opts), :repo)
@default_limit Keyword.get(unquote(opts), :default_limit, 20)
@max_limit Keyword.get(unquote(opts), :max_limit, 100)
@max_limit Keyword.fetch!(unquote(opts), :max_limit)
@default_limit Keyword.get(unquote(opts), :default_limit, @max_limit)

@doc """
Paginates the query and returns records with metadata.

## Parameters
- `query`: Ecto query to paginate
- `params`: Map or keyword list with :limit and :skip/:page parameters
- `params`: Keyword list or string-keyed map with `limit` and `skip` keys.
String values are parsed to integers automatically.

## Returns
`{:ok, %{records: [map], meta: map}}` or `{:error, reason}`
Expand All @@ -56,6 +57,11 @@ defmodule FatEcto.Pagination.V2Paginator do
- :limit - records per page
- :skip - records skipped
- :pages - total pages

## Examples

{:ok, result} = MyPaginator.paginate(query, limit: 10, skip: 0)
{:ok, result} = MyPaginator.paginate(query, %{"limit" => "10", "skip" => "0"})
"""

@impl true
Expand All @@ -78,7 +84,17 @@ defmodule FatEcto.Pagination.V2Paginator do
end
end

defp get_pagination_params(params) do
defp get_pagination_params(params) when is_map(params) do
keyword_params =
Enum.reject(
[skip: params["skip"], limit: params["limit"]],
fn {_k, v} -> is_nil(v) end
)

get_pagination_params(keyword_params)
end

defp get_pagination_params(params) when is_list(params) do
{skip, params} = Helper.get_skip_value(params)
{limit, _params} = Helper.get_limit_value(params, unquote(opts))
{limit, skip}
Expand Down
Loading