Bind

Define an API controller like this:

def index(conn, _params) do
users = conn.query_string
|> Bind.query(User)
|> Repo.all()
render(conn, :index, result: users)
end

Now your endpoint supports all these queries out of the box:

GET /users?name[contains]=john&sort=-id&limit=25
GET /users?salary[gte]=50000&location[eq]=berlin
GET /users?joined_at[lt]=2024-01-01&status[neq]=disabled

Bind is a flexible and dynamic Ecto query builder, for retrieving data flexibly without writing custom queries for each use case.

Installation

Add bind to your list of dependencies in mix.exs:

def deps do
[
{:bind, "~> 0.1.1"}
]
end

API

Bind.query(schema, params)

Parameters:

Returns: An Ecto query.

Usage Example

Create Ecto query:

query = Bind.query(%{ "name[eq]" => "Alice", "age[gte]" => 30 }, MyApp.User)

Alternatively, with a query string:

query = Bind.query("?name[eq]=Alice&age[gte]=30", MyApp.User)

And finally run the query to get results from the database:

results = Repo.all(query)

Here's how it looks in a controller:

def index(conn, params) do
images = conn.query_string
|> Bind.query(MyApp.Media.Image)
|> MyApp.Repo.all()
render(conn, :index, result: images)
end

Error handling

case Bind.query(%{ "name[eq]" => "Alice", "age[gte]" => 30 }, MyApp.User) do
{:error, reason} ->
IO.puts("Error building query: #{reason}")
query ->
results = Repo.all(query)
end

Filtering

Examples:

%{"name[eq]" => "Alice", "age[gte]" => 30}
%{
"name[starts_with]" => "A",
"age[gte]" => 18,
"role[in]" => "superuser,admin,mod",
"is_active[true]" => "",
"last_login[nil]" => false
}

List of comparison operators supported:

Sorting

Use the sort parameter to specify sorting order:

%{"sort" => "-age"} # Sort by age descending
%{"sort" => "age"} # Sort by age ascending

If nothing specified, sorts by ID field ascending.

Pagination

Example:

%{"limit" => 20, "start" => 100}

Example for descending pagination:

GET /users?sort=-created_at&start=-100

Query String Support

In a typical Phoenix controller, you can simply pass conn.query_string and get Ecto query back:

query_string = conn.query_string
|> Bind.query(query_string, MyApp.User)
|> MyApp.Repo.all()

Transforming Query Parameters

You can transform filter values before query is built:

"user_id[eq]=123&team_id[eq]=456"
|> Bind.map(%{
user_id: fn id -> HashIds.decode(id) end,
team_id: fn id -> HashIds.decode(id) end
})
|> Bind.query(MyApp.User)
|> Repo.all()

Transform specific fields

Bind.map(params, %{
user_id: fn id -> HashIds.decode(id) end,
name: &String.upcase/1
})

Transform multiple fields with regex pattern:

Bind.map(params, %{
~r/_id$/i => fn id -> HashIds.decode(id) end
})

Note: Value transformation only applies to filter fields (e.g. [eq], [gte]), not to sort/limit/pagination params.

Access Control with Filters

You can use filters to enforce access control and limit what users can query. Filters compose nicely with the query builder:

def index(conn, _params) do
my_posts = conn.query_string
# User can only see their own posts
|> Bind.filter(%{"user_id[eq]" => conn.assigns.current_user.id})
# That are active
|> Bind.filter(%{"active[true]" => true})
|> Bind.query(Post)
|> Repo.all()
render(conn, :index, posts: my_posts)
end