defmodule Bind do import Ecto.Query @doc """ Merges additional filters with existing query params. ## Examples # Only allow users to see their own posts conn.query_string |> Bind.filter(%{"user_id[eq]" => current_user.id}) |> Bind.query(Post) # Scope to team and active status params |> Bind.filter(%{"team_id[eq]" => team_id}) |> Bind.filter(%{"active[true]" => true}) |> Bind.query(Post) """ def filter(query_string, filters) when is_binary(query_string) do query_string |> Bind.QueryString.to_map() |> filter(filters) end def filter(params, filters) when is_map(params) do Map.merge(params, filters) end @moduledoc """ `Bind` provides functionality to build dynamic Ecto queries based on given parameters. It allows developers to retrieve data flexibly without writing custom queries for each use case. ## Examples Given an Ecto schema module `MyApp.User` and a map of query parameters, you can build and run a query like this: > params = %{ "name[eq]" => "Alice", "age[gte]" => 30, "sort" => "-age", "limit" => "10" } > query = Bind.query(params, MyApp.User) > results = Repo.all(query) > IO.inspect(results) Or, you can pipe: > %{ "name[eq]" => "Alice", "age[gte]" => 30, "sort" => "-age", "limit" => "10" } |> Bind.query(MyApp.User) |> Repo.all() |> IO.inspect() If you're in a Phoenix controller: def index(conn, params) do users = conn.query_string |> Bind.decode_query() |> Bind.query(MyApp.User) |> Repo.all() render(conn, "index.json", users: users) end """ @doc """ Builds an Ecto query for the given schema based on the provided parameters. ## Parameters - `params`: A map of query parameters. - `schema`: The Ecto schema module (e.g., `MyApp.User`). ## Examples > params = %{"name[eq]" => "Alice", "age[gte]" => "30", "sort" => "-age"} > Bind.query(MyApp.User, params) #Ecto.Query= ^30, order_by: [desc: u0.age]> """ def query(params, schema) when is_map(params) do case Bind.QueryBuilder.build_where_query(params) do {:error, reason} -> {:error, reason} where_query -> sort_query = Bind.QueryBuilder.build_sort_query(params) schema |> where(^where_query) |> order_by(^Enum.into(sort_query, [])) |> Bind.QueryBuilder.add_limit_query(params) |> Bind.QueryBuilder.add_offset_query(params) end end @doc """ Builds an Ecto query for the given schema based on the provided query string. ## Parameters - `query_string`: The query string from a URL. - `schema`: The Ecto schema module (e.g., `MyApp.User`). ## Examples > query_string = "?name[eq]=Alice&age[gte]=30&sort=-age&limit=10" > Bind.query(query_string, MyApp.User) #Ecto.Query= ^30, order_by: [desc: u0.age]> """ def query(query_string, schema) when is_binary(query_string) do query_string |> Bind.QueryString.to_map() |> query(schema) end @doc """ Maps over query parameters, letting you transform values by pattern matching field names. ## Example # Simple value transformation qs = "user_id[eq]=123&team_id[in]=456,789" Bind.map(qs, %{ user_id: fn id -> HashIds.decode(id) end, team_id: fn ids -> ids |> String.split(",") |> Enum.map(&HashIds.decode/1) |> Enum.join(",") end }) # Pattern matching fields qs = "org_id[eq]=123&user_id[eq]=456" Bind.map(qs, %{ ~r/^.*_id$/i => fn id -> HashIds.decode(id) end }) """ def map(query_string, field_mappers) when is_binary(query_string) do query_string |> URI.decode_query() |> map(field_mappers) end @doc """ Maps over query parameters using field transformer functions. ## Parameters - `params`: Map of query parameters (e.g. %{"user_id[eq]" => "123"}) - `field_mappers`: Map of field names to transformer functions. Can contain atom keys for exact matches or regex patterns for flexible matching. ## Examples # Transform specific fields params = %{"user_id[eq]" => "123", "name[eq]" => "alice"} Bind.map(params, %{ user_id: fn id -> HashIds.decode(id) end, name: &String.upcase/1 }) # => %{"user_id[eq]" => "u_123", "name[eq]" => "ALICE"} # Transform multiple fields with regex pattern params = %{"user_id[eq]" => "123", "team_id[eq]" => "456"} Bind.map(params, %{ ~r/_id$/i => fn id -> HashIds.decode(id) end }) # => %{"user_id[eq]" => "u_123", "team_id[eq]" => "t_456"} Note: Only transforms values for where conditions (e.g. [eq], [gte]). Other parameters like sort, limit etc. are preserved unchanged. """ def map(params, field_mappers) when is_map(params) do Enum.reduce(params, %{}, fn {key, value}, acc -> case Bind.Parse.where_field(key) do # Handle regular fields [field_name, constraint] [field_name, _] -> field = to_string(field_name) # Try exact match first, then regex patterns for where fields new_value = find_mapper(field_mappers, field).(value) Map.put(acc, key, new_value) # Handle JSONB fields [json_field, json_key, constraint] [json_field, _json_key, _constraint] -> field = to_string(json_field) # Apply mapper to the main JSON field (e.g., "options") new_value = find_mapper(field_mappers, field).(value) Map.put(acc, key, new_value) # Handle non-where fields (like start, limit) nil -> # For non-where fields (like start, limit), check if it's negated {field, is_negated} = case String.starts_with?(key, "-") do true -> {String.trim_leading(key, "-"), true} false -> {key, false} end # Find mapper using non-negated field name new_value = find_mapper(field_mappers, field).(value) # Restore the negative prefix if it was present final_key = if is_negated, do: "-#{field}", else: field Map.put(acc, final_key, new_value) end end) end defp find_mapper(mappers, field) do # Try exact match case Map.get(mappers, String.to_atom(field)) do nil -> # Try regex patterns case Enum.find(mappers, fn {%Regex{} = re, _} -> Regex.match?(re, field) _ -> false end) do {_, mapper} -> mapper # identity function if no match nil -> & &1 end mapper -> mapper end end end