defmodule Utilx.EctoUtils do
@moduledoc """
A utility module for handling Ecto queries, with functions to perform common
tasks like applying a range filter, and executing a series of query operations
in a pipeline. This module includes Ecto changeset validation functions as well.
"""
import Ecto.Query
import Ecto.Changeset
# Taken from here https://mathiasbynens.be/demo/url-regex
@http_regex ~r/^(?:(?:https?):\/\/)(?:\S+(?::\S*)?@)?(?:(?!10(?:\.\d{1,3}){3})(?!127(?:\.\d{1,3}){3})(?!169\.254(?:\.\d{1,3}){2})(?!192\.168(?:\.\d{1,3}){2})(?!172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2})(?:[1-9]\d?|1\d\d|2[01]\d|22[0-3])(?:\.(?:1?\d{1,2}|2[0-4]\d|25[0-5])){2}(?:\.(?:[1-9]\d?|1\d\d|2[0-4]\d|25[0-4]))|(?:(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)(?:\.(?:[a-z\x{00a1}-\x{ffff}0-9]+-?)*[a-z\x{00a1}-\x{ffff}0-9]+)*(?:\.(?:[a-z\x{00a1}-\x{ffff}]{2,})))(?::\d{2,5})?(?:\/[^\s]*)?$/ius
@doc """
Validates the structure of a URL field in an Ecto changeset.
If the `field` in the `changeset` is a URL, this function ensures that it has a scheme (defaulting to "https://" if
none is present), and then checks the URL's structure against a regular expression.
If the URL's structure is invalid, the `error_message` is attached to the `field` in the `changeset`'s errors.
## Parameters
- `changeset`: The Ecto changeset containing the URL to validate.
- `field`: The key (atom) for the field in the changeset containing the URL.
- `error_message`: The error message to attach to the `field` in the `changeset` if the URL is invalid.
## Examples
iex> types = %{url: :string}
iex> params = %{url: "https://www.example.com/"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> EctoUtils.validate_url(:url, "is not a valid url")
#Ecto.Changeset<action: nil, changes: %{url: "https://www.example.com/"}, errors: [], data: %{}, valid?: true>
iex> types = %{url: :string}
iex> params = %{url: "www.example.com/"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> EctoUtils.validate_url(:url, "is not a valid url")
#Ecto.Changeset<action: nil, changes: %{url: "https://www.example.com/"}, errors: [], data: %{}, valid?: true>
iex> types = %{url: :string}
iex> params = %{url: "some@invalid_url"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> EctoUtils.validate_url(:url, "is not a valid url")
#Ecto.Changeset<action: nil, changes: %{url: "https://some@invalid_url"}, errors: [url: {"is not a valid url", [validation: :format]}], data: %{}, valid?: false>
iex> types = %{url: :string}
iex> params = %{url: "Just some random text"}
iex> Ecto.Changeset.cast({%{}, types}, params, Map.keys(types))
...> |> EctoUtils.validate_url(:url, "is not a valid url")
#Ecto.Changeset<action: nil, changes: %{url: "https://Just some random text"}, errors: [url: {"is not a valid url", [validation: :format]}], data: %{}, valid?: false>
"""
def validate_url(changeset, field, error_message) do
changeset
|> ensure_url_scheme(field)
|> validate_format(field, @http_regex, message: error_message)
end
defp ensure_url_scheme(changeset, field) do
changeset
|> get_field(field)
|> do_ensure_url_scheme()
|> then(&put_change(changeset, field, &1))
end
defp do_ensure_url_scheme(nil), do: nil
defp do_ensure_url_scheme(url) do
case URI.parse(url) do
%URI{scheme: nil} -> "https://#{url}"
_ -> url
end
end
@doc """
Filters a query to only include rows where the specified column's value is within a provided range.
## Parameters
- `query`: The Ecto query to filter.
- `column`: The column on which to apply the range filter.
- `min..max`: The range of values to filter on.
## Examples
iex> query = from(u in "users", select: u.age)
iex> EctoUtils.in_range(query, :age, 18..30)
#Ecto.Query<from u0 in \"users\", where: u0.age >= ^18 and u0.age <= ^30, select: u0.age>
"""
def in_range(query, column, min..max) do
query
|> where([row], field(row, ^column) >= ^min and field(row, ^column) <= ^max)
end
@doc """
Applies a series of operations to an Ecto query.
## Parameters
- `query`: The Ecto query to which operations should be applied.
- `opts`: A list of operations to apply. Each operation is a tuple where the first element is the operation name
and the second element is the value to use for that operation.
The following operations are supported:
- `{:where, filters}`: Adds a `where` clause to the query.
- `{:fields, fields}`: Adds a `select` clause to the query.
- `{:order_by, criteria}`: Adds an `order_by` clause to the query.
- `{:limit, criteria}`: Adds a `limit` clause to the query.
- `{:preload, preload}`: Adds a `preload` clause to the query.
## Examples
iex> query = from(u in "users")
iex> filters = [{:where, [age: 18]}, {:order_by, [desc: :age]}]
iex> EctoUtils.apply_filters(query, filters)
#Ecto.Query<from u0 in \"users\", where: u0.age == ^18, order_by: [desc: u0.age]>
"""
def apply_filters(query, opts) when is_list(opts) do
Enum.reduce(opts, query, fn
{:where, filters}, query ->
where(query, ^filters)
{:fields, fields}, query ->
select(query, [i], map(i, ^fields))
{:order_by, criteria}, query ->
order_by(query, ^criteria)
{:limit, criteria}, query ->
limit(query, ^criteria)
{:preload, preload}, query ->
preload(query, ^preload)
_, query ->
query
end)
end
end