Returns the fields that identify a record, used as the default tiebreaker.
iex> Flop.Schema.primary_key(%MyApp.Fruit{})
[:id]
Flop.Schema is a protocol that allows you to customize and set query options in your Ecto schemas.
This module allows you to define which fields are filterable and sortable, set default and maximum limits, specify default sort orders, restrict pagination types, and more.
To utilize this protocol, derive Flop.Schema in your Ecto schema and define
the filterable and sortable fields.
defmodule MyApp.Pet do
use Ecto.Schema
@derive {
Flop.Schema,
filterable: [:name, :species],
sortable: [:name, :age]
}
schema "pets" do
field :name, :string
field :age, :integer
field :species, :string
end
endSee option/0 for an overview of all available options.
@derive Flop.SchemaWhen you derive Flop.Schema, all the functions required for the
Flop.Schema protocol will be defined based on the options you set.
After that, you can pass the module as the :for option to Flop.validate/2.
iex> Flop.validate(%Flop{order_by: [:name]}, for: MyApp.Pet)
{:ok,
%Flop{
filters: [],
limit: 50,
offset: nil,
order_by: [:name],
order_directions: nil,
page: nil,
page_size: nil
}}
iex> {:error, %Flop.Meta{} = meta} = Flop.validate(
...> %Flop{order_by: [:species]}, for: MyApp.Pet
...> )
iex> meta.params
%{"order_by" => [:species], "filters" => []}
iex> meta.errors
[
order_by: [
{"has an invalid entry",
[validation: :subset, enum: [:name, :age, :mood, :owner_name, :owner_age]]}
]
]Define a default or maximum limit by setting the default_limit and
max_limit options while deriving Flop.Schema. Flop.validate/1 will apply
the default limit and validate the maximum limit.
@derive {
Flop.Schema,
filterable: [:name, :species],
sortable: [:name, :age],
max_limit: 100,
default_limit: 50
}Specify a default sort order by setting the default_order_by and
default_order_directions options when deriving Flop.Schema. The default
values will be applied by Flop.validate/1. If no order directions are set,
:asc is the default for all fields.
@derive {
Flop.Schema,
filterable: [:name, :species],
sortable: [:name, :age],
default_order: %{
order_by: [:name, :age],
order_directions: [:asc, :desc]
}
}If your order does not identify each row uniquely, tied rows come back in an
arbitrary order, and cursor pagination over that order skips rows. To prevent
that, Flop appends the schema's primary key to every order. Set the
:tiebreaker option to change the direction, to name other fields, or to
turn it off.
@derive {
Flop.Schema,
filterable: [:name],
sortable: [:name],
tiebreaker: {:primary_key, :desc}
}See Flop.tiebreaker/0 for the accepted values. You can also set the option
on a backend module, in the application environment, or per query function
call. The keyword list form can only be set on a schema or in a query function
call.
A tiebreaker field is only appended if the order parameters do not already
contain it. If the tiebreaker option is set to :primary_key (the default) or
{:primary_key, direction}, and the schema has no primary key, no tiebreaker
will be added.
The tiebreaker is only applied when Flop orders the query. If you set
ordering: false and no default order, the tiebreaker is not added.
The tiebreaker is not part of the Flop.t/0 struct, so it does not appear
in the query parameters. Flop.ordering/2 returns the full order that is
applied.
The tiebreaker appears in every cursor, so its value is read from the returned record like any other order field. A query that selects a subset of the fields has to include it.
By default, all supported pagination types (Flop.pagination_type/0) are
enabled. If you wish to restrict the pagination type for a schema, you can
set the :pagination_types option.
@derive {
Flop.Schema,
filterable: [:name, :species],
sortable: [:name, :age],
pagination_types: [:first, :last]
}Setting the value to nil (default) allows all pagination types.
See also Flop.option/0.
To sort by calculated values, you can use Ecto.Query.API.selected_as/2 in
your query, define an alias field in your schema, and add the alias field to
the list of sortable fields.
Schema:
@derive {
Flop.Schema,
filterable: [],
sortable: [:pet_count],
adapter_opts: [
alias_fields: [:pet_count]
]
}Query:
Owner
|> join(:left, [o], p in assoc(o, :pets), as: :pets)
|> group_by([o], o.id)
|> select(
[o, pets: p],
{o.id, p.id |> count() |> selected_as(:pet_count)}
)
|> Flop.validate_and_run(params, for: Owner)Note that it is not possible to use field aliases in WHERE clauses, which
means you cannot add alias fields to the list of filterable fields, and you
cannot sort by an alias field if you are using cursor-based pagination.
Sometimes you might need to apply a search term to multiple fields at once, e.g. you might want to search in both the family name and given name field. You can do that with Flop by defining a compound field.
@derive {
Flop.Schema,
filterable: [:full_name],
sortable: [:full_name],
adapter_opts: [
compound_fields: [full_name: [:family_name, :given_name]]
]
}This allows you to use the field name :full_name as any other field in the
filter and order parameters.
params = %{
filters: [%{
field: :full_name,
op: :like,
value: "margo"
}]
}This would translate to:
WHERE family_name like '%margo%' OR given_name like '%margo%'Partial matches of the search term can be achieved with one of the like operators.
params = %{
filters: [%{
field: :full_name,
op: :ilike_and,
value: ["margo", "martindale"]
}]
}or
params = %{
filters: [%{
field: :full_name,
op: :ilike_and,
value: "margo martindale"
}]
}This would translate to:
WHERE (family_name ilike '%margo%' OR given_name ilike '%margo%')
AND (family_name ilike '%martindale%' OR given_name ilike '%martindale%'):=~ :like :not_like :like_and :like_or :ilike :not_ilike :ilike_and :ilike_or:emptynil.:not_emptynil.:== :!= :<= :< :>= :> :in :not_in :contains :not_containsparams = %{
order_by: [:full_name],
order_directions: [:desc]
}This would translate to:
ORDER BY family_name DESC, given_name DESCNote that compound fields cannot be used as pagination cursors.
If you need to filter or order across tables, you can define join fields.
As an example, let's define these schemas:
schema "owners" do
field :name, :string
field :email, :string
has_many :pets, Pet
end
schema "pets" do
field :name, :string
field :species, :string
belongs_to :owner, Owner
endAnd now we want to find all owners that have pets of the species
"E. africanus". To do this, first we need to define a join field on the
Owner schema.
@derive {
Flop.Schema,
filterable: [:pet_species],
sortable: [:pet_species],
adapter_opts: [
join_fields: [
pet_species: [
binding: :pets,
field: :species,
ecto_type: :string
]
]
]
}In this case, :pet_species would be the alias of the field that you can
refer to in the filter and order parameters. The options are:
:binding - The named binding you set with the :as option in the join
statement of your query.:field - The field on that binding on which the filter should be applied.:ecto_type - The Ecto type of the field. This allows Flop to validate
filter values, and also to treat empty arrays and empty maps as empty values
depending on the type. See also Ecto type option section below.In order to retrieve the pagination cursor value for a join field, Flop needs
to know how to get the field value from the struct that is returned from the
database. Flop.Schema.get_field/2 is used for that. By default, Flop assumes
that the binding name matches the name of the field for the association in
your Ecto schema (the one you set with has_one, has_many or belongs_to).
In the example above, Flop would try to access the field in the struct under
the path [:pets, :species].
If you have joins across multiple tables, or if you can't give the binding the same name as the association field, you can specify the path explicitly.
@derive {
Flop.Schema,
filterable: [:pet_species],
sortable: [:pet_species],
adapter_opts: [
join_fields: [
pet_species: [
binding: :pets,
field: :species,
path: [:pets, :species]
]
]
]
}After setting up the join fields, you can write a query like this:
params = %{
filters: [%{field: :pet_species, op: :==, value: "E. africanus"}]
}
Owner
|> join(:left, [o], p in assoc(o, :pets), as: :pets)
|> preload([pets: p], [pets: p])
|> Flop.validate_and_run!(params, for: Owner)If your query returns data in a different format, you don't need to set the
:path option. Instead, you can pass a custom cursor value function in the
options. See Flop.Cursor.get_cursors/2 and Flop.option/0.
Flop adds the WHERE and ORDER BY clauses, but the SELECT is your
responsibility. Cursor pagination reads the join field from the returned
struct, so the association has to be preloaded, as in the example above.
Note that Flop doesn't create the join clauses for you. The named bindings
already have to be present in the query you pass to the Flop functions. You
can use Flop.with_named_bindings/4 or Flop.named_bindings/3 to get the
build the join clauses needed for a query dynamically and avoid adding
unnecessary joins.
You can join on a subquery with a named binding and add a join field as described above.
Schema:
@derive {
Flop.Schema,
filterable: [:pet_count],
sortable: [:pet_count],
adapter_opts: [
join_fields: [
pet_count: [
binding: :pet_count,
field: :count,
ecto_type: :integer
]
]
]
}Query:
params = %{filters: [%{field: :pet_count, op: :>, value: 2}]}
pet_count_query =
Pet
|> where([p], parent_as(:owner).id == p.owner_id)
|> select([p], %{count: count(p)})
q =
(o in Owner)
|> from(as: :owner)
|> join(:inner_lateral, [owner: o], p in subquery(pet_count_query),
as: :pet_count
)
|> Flop.validate_and_run(params, for: Owner)Custom fields allow for precise control over filtering and ordering, making it possible to implement logic that the built-in options cannot satisfy.
For example, you might need to handle dates and times in a particular way that takes into account different time zones, or perform database-specific queries using fragments.
Both callbacks are referenced by a tuple
{mod :: module, function :: atom, opts :: keyword}.
field_dynamic returns the field itself as an Ecto.Query.dynamic_expr.
It only receives an options keyword list, and Flop applies the ordering
clauses and the filter operators on the expression it returns. This
option is required to make a custom field sortable. It is also the simpler
way to make a custom field filterable, as you only need to define a dynamic
that returns a field value, and Flop can apply all operators on it.filter takes the Ecto query, the Flop filter and an options keyword list,
and returns the updated query. With this option, you can build more complex
filter expressions, but you have to handle all operators yourself.If both functions are configured, filter is used for filtering and
field_dynamic for sorting.
If runtime options are necessary (like the timezone of the request or the user
ID of the current user), use the extra_opts option when calling Flop
functions.
Schema:
@derive {
Flop.Schema,
filterable: [:inserted_at_date],
sortable: [:inserted_at_date],
adapter_opts: [
custom_fields: [
inserted_at_date: [
filter: {CustomFields, :date_filter, [source: :inserted_at]},
field_dynamic: {CustomFields, :date_field, [source: :inserted_at]},
ecto_type: :date,
operators: [:<=, :>=]
]
]
]
}If you pass the :ecto_type option like above, the filter value will be
automatically cast.
Custom field module:
defmodule CustomFields do
import Ecto.Query
def date_filter(query, %Flop.Filter{value: value, op: op}, opts) do
source = Keyword.fetch!(opts, :source)
timezone = Keyword.fetch!(opts, :timezone)
expr = dynamic(
[r],
fragment("((? AT TIME ZONE 'utc') AT TIME ZONE ?)::date",
field(r, ^source), ^timezone)
)
conditions =
case op do
:>= -> dynamic([r], ^expr >= ^value)
:<= -> dynamic([r], ^expr <= ^value)
end
where(query, ^conditions)
end
def date_field(opts) do
source = Keyword.fetch!(opts, :source)
timezone = Keyword.fetch!(opts, :timezone)
dynamic(
[r],
fragment("((? AT TIME ZONE 'utc') AT TIME ZONE ?)::date",
field(r, ^source), ^timezone)
)
end
endQuery:
Flop.validate_and_run(
MyApp.Pet,
params,
for: MyApp.Pet,
extra_opts: [timezone: timezone]
)If either callback requires certain named bindings, you can use the
:bindings option to specify them. Then, using Flop.with_named_bindings/4,
these bindings can be conditionally added to your query based on filter
conditions.
A custom field with a field_dynamic can be used for cursor pagination. As
with join fields, Flop reads the cursor value from the returned struct or
map, and the :path option says where to find it. It defaults to the field
name.
Add a virtual field to your schema and select the same dynamic that
field_dynamic returns:
schema "pets" do
field :inserted_at, :utc_datetime
field :inserted_at_date, :date, virtual: true
end
dynamic =
CustomFields.date_field(source: :inserted_at, timezone: timezone)
MyApp.Pet
|> select_merge(^%{inserted_at_date: dynamic})
|> Flop.validate_and_run(params,
for: MyApp.Pet,
extra_opts: [timezone: timezone]
)Flop adds the WHERE and ORDER BY clauses, but the SELECT is your
responsibility. A custom field is an expression, not a column, so nothing
puts it into the result unless you do. A missing value breaks pagination
silently.
Flop automatically retrieves the field type from the schema module for regular schema fields, enabling it to correctly cast filter values. Compound fields are always treated as string fields.
For join and custom fields, Flop cannot automatically determine the Ecto type.
Therefore, you need to specify the ecto_type option. This helps Flop cast
filter values for join and custom fields properly.
@derive {
Flop.Schema,
filterable: [:full_text, :pet_species],
sortable: [:id],
adapter_opts: [
join_fields: [
pet_species: [
binding: :pets,
field: :species,
ecto_type: :string
]
],
custom_fields: [
full_text: [
filter: {__MODULE__, :full_text_filter, []},
ecto_type: :string
]
]
]
}You can specify any Ecto type with the ecto_type option. Here are some
examples:
ecto_type: :stringecto_type: :integerecto_type: {:array, :string}ecto_type: MyCustomTypeFor parameterized types, use the following syntax:
ecto_type: Ecto.ParameterizedType.init(Ecto.Enum, values: [:one, :two])If you're working with Ecto.Enum types, you can use a more convenient
syntax:
ecto_type: {:ecto_enum, [:one, :two]}Furthermore, you can reference a type from another schema:
ecto_type: {:from_schema, MyApp.Pet, :mood}Naming a module here makes it a compile-time dependency of your schema, so
changing MyApp.Pet recompiles every schema that references it. This is not
specific to Flop: Ecto.Schema reads the @derive attribute at compile time,
which turns any module named in it into a compile-time dependency. Setting the
type directly (ecto_type: :string) avoids it. If you want the reference and
not the dependency, build the module name without an alias:
@pet Module.concat(["MyApp", "Pet"])
@derive {
Flop.Schema,
filterable: [:pet_mood],
sortable: [],
adapter_opts: [
join_fields: [
pet_mood: [
binding: :pets,
field: :mood,
ecto_type: {:from_schema, @pet, :mood}
]
]
]
}Note that Flop.Phoenix encodes all filters in query string using
Plug.Conn.Query. It is expected that filter values can be converted to
strings with to_string/1. If you are using an Ecto custom type that casts
as a struct, you will need to implement the String.Chars protocol for that
struct.
Options specific to the adapter.
Defines the options for a custom field.
Either an Ecto type, or reference to the type of an existing schema field, or an adhoc Ecto.Enum.
Defines the options for a join field.
Options that can be passed when deriving the Flop.Schema protocol.
All the types that implement this protocol.
Returns the default limit of a schema.
Returns the default order of a schema.
Returns the default pagination type of a schema.
Returns the field information for the given field name.
Returns the filterable fields of a schema.
Gets the field value from a struct.
Returns the maximum number of filters of a schema.
Returns the maximum limit of a schema.
Returns the allowed pagination types of a schema.
Returns the fields that identify a record, used as the default tiebreaker.
Returns the sortable fields of a schema.
Returns the tiebreaker of a schema.
@type adapter_option() :: {:join_fields, [{atom(), [join_field_option()]}]} | {:compound_fields, [{atom(), [atom()]}]} | {:custom_fields, [{atom(), [custom_field_option()]}]} | {:alias_fields, [atom()]}
Options specific to the adapter.
:join_fields - A list of fields on named bindings.:compound_fields - Groups of fields that can be combined and filtered, for
example a family name plus a given name field.:custom_fields - Custom fields with user-defined filter and order
functions.:alias_field - Fields that reference aliases defined with
Ecto.Query.API.selected_as/2.@type custom_field_option() :: {:filter, {module(), atom(), keyword()}} | {:field_dynamic, {module(), atom(), keyword()}} | {:ecto_type, ecto_type()} | {:bindings, [atom()]} | {:operators, [Flop.Filter.op()]} | {:path, [atom()]}
Defines the options for a custom field.
:filter - A module/function/options tuple referencing a custom filter
function. The function must take the Ecto query, the Flop.Filter struct,
and the options from the tuple as arguments, and return the updated query.
Takes precedence over :field_dynamic for filtering.:field_dynamic - A module/function/options tuple referencing a function
that returns the field expression as an Ecto.Query.dynamic_expr. The
function takes the options from the tuple as its only argument. Flop applies
the ordering clauses and the filter operators on the expression. Required if
the field is sortable. One of the two is required if it is filterable.:ecto_type (required) - The Ecto type of the field. The filter operator
and value validation is based on this option.:bindings - If either callback requires certain named bindings to be
present in the Ecto query, you can specify them here. These bindings
will be conditionally added by Flop.with_named_bindings/4 if the field
is used.:operators - Defines which filter operators are allowed for this field.
If omitted, all operators will be accepted.:path - This option is used by Flop.Schema.get_field/2 to retrieve the
field value from a row. That function is also used by the default cursor
functions in Flop.Cursor to determine the cursors. If the option is
omitted, it defaults to [field_name].If both the :ecto_type and the :operators option are set, the :operators
option takes precedence and only the filter value validation is based on the
:ecto_type.
@type ecto_type() :: Ecto.Type.t() | {:from_schema, module(), atom()} | {:ecto_enum, [atom()] | keyword()}
Either an Ecto type, or reference to the type of an existing schema field, or an adhoc Ecto.Enum.
You can pass any Ecto type:
:string:integerEcto.UUIDEcto.ParameterizedType.init/2.Or reference a schema field:
{:from_schema, MyApp.Pet, :mood}
This makes the referenced module a compile-time dependency of your schema. See the module documentation for the reason and for a way to avoid it.
Or build an adhoc Ecto.Enum:
{:ecto_enum, [:one, :two]}{:ecto_enum, [one: 1, two: 2]}Note that if you make an Ecto.Enum type this way, the filter value will be
cast as an atom. This means the field you filter on also needs to be an
Ecto.Enum, or a custom type that is able to cast atoms. You cannot use this
on a string field.
@type join_field_option() :: {:binding, atom()} | {:field, atom()} | {:ecto_type, ecto_type()} | {:path, [atom()]}
Defines the options for a join field.
:binding (required) - Any named binding:field (required):ecto_type (required) - The Ecto type of the field. The filter operator
and value validation is based on this option.:path - This option is used by Flop.Schema.get_field/2 to retrieve the
field value from a row. That function is also used by the default cursor
functions in Flop.Cursor to determine the cursors. If the option is
omitted, it defaults to [binding, field].@type option() :: {:filterable, [atom()]} | {:sortable, [atom()]} | {:default_limit, integer()} | {:max_filters, pos_integer() | false} | {:max_limit, integer()} | {:default_order, Flop.default_order()} | {:tiebreaker, Flop.tiebreaker()} | {:pagination_types, [Flop.pagination_type()]} | {:default_pagination_type, Flop.pagination_type()} | {:adapter_opts, [adapter_option()]} | adapter_option()
Options that can be passed when deriving the Flop.Schema protocol.
These are either general schema options or adapter-specific options nested
under the :adapter_opts key. For backward compatibility, the options of the
Ecto adapter can be set directly at the root level as well.
:filterable (required) - A list of fields that can be used in filters.
Supports fields from the Ecto schema, join fields, compound fields and
custom fields. Alias fields are not supported.:sortable (required) - A list of fields that can be used for sorting.
Supports fields from the Ecto schema, join fields, compound fields, alias
fields, and custom fields that configure :field_dynamic.Both lists can be narrowed for a single query by passing :filterable or
:sortable to a query function. A query function cannot widen them, so the
lists configured here are the only fields a caller can ever use.
:default_limit - The default limit applied if no limit, page_size,
first or last parameter is set. Set to false to not set any default
limit.:max_filters - The maximum number of filters that can be set via
parameters. Set to false to not set any maximum.:max_limit - The maximum limit that can be set via parameters. Set to
false to not set any maximum limit.:default_order - The default order applied when no order parameters are
set.:tiebreaker - The order fields appended to every query to make the order
unambiguous. Defaults to the primary key, ascending. See
Flop.tiebreaker/0.:pagination_types - A list of allowed pagination types for this schema.:default_pagination_type - The default pagination type used if no
pagination parameters are set.:adapter_opts - Additional adapter-specific options.@type t() :: term()
All the types that implement this protocol.
@spec default_limit(any()) :: pos_integer() | nil
Returns the default limit of a schema.
iex> Flop.Schema.default_limit(%MyApp.Fruit{})
60
@spec default_order(any()) :: %{order_by: [atom()] | nil, order_directions: [Flop.order_direction()] | nil} | nil
Returns the default order of a schema.
iex> Flop.Schema.default_order(%MyApp.Fruit{})
%{order_by: [:name], order_directions: [:asc]}
@spec default_pagination_type(any()) :: Flop.pagination_type() | nil
Returns the default pagination type of a schema.
iex> Flop.Schema.default_pagination_type(%MyApp.Owner{})
:page
@spec field_info(any(), atom()) :: Flop.FieldInfo.t()
Returns the field information for the given field name.
iex> field_info(%MyApp.Pet{}, :age)
%Flop.FieldInfo{ecto_type: :integer, extra: %{type: :normal, field: :age}}
iex> field_info(%MyApp.Pet{}, :full_name)
%Flop.FieldInfo{
ecto_type: :string,
operators: [
:=~,
:like,
:not_like,
:like_and,
:like_or,
:ilike,
:not_ilike,
:ilike_and,
:ilike_or,
:starts_with,
:ends_with,
:empty,
:not_empty
],
extra: %{type: :compound, fields: [:family_name, :given_name]}
}
iex> field_info(%MyApp.Pet{}, :owner_name)
%Flop.FieldInfo{
ecto_type: :string,
extra: %{
type: :join,
path: [:owner, :name],
binding: :owner,
field: :name
}
}
iex> field_info(%MyApp.Pet{}, :reverse_name)
%Flop.FieldInfo{
ecto_type: :string,
extra: %{
type: :custom,
filter: {MyApp.Pet, :reverse_name_filter, []},
field_dynamic: nil,
bindings: [],
path: [:reverse_name]
}
}
Returns the filterable fields of a schema.
iex> Flop.Schema.filterable(%MyApp.Pet{})
[
:id,
:age,
:full_name,
:mood,
:name,
:owner_age,
:owner_name,
:owner_tags,
:pet_and_owner_name,
:species,
:tags,
:custom,
:reverse_name
]
Gets the field value from a struct.
Resolves join, compound and custom fields according to the config.
# join_fields: [owner_name: [binding: :owner, field: :name]]
iex> pet = %MyApp.Pet{name: "George", owner: %MyApp.Owner{name: "Carl"}}
iex> Flop.Schema.get_field(pet, :name)
"George"
iex> Flop.Schema.get_field(pet, :owner_name)
"Carl"
# compound_fields: [full_name: [:family_name, :given_name]]
iex> pet = %MyApp.Pet{given_name: "George", family_name: "Gooney"}
iex> Flop.Schema.get_field(pet, :full_name)
"Gooney George"For join fields, this function relies on the binding name in the schema config matching the field name for the association in the struct.
Join and custom fields are read through their path. A custom field's value
is computed in the query and is only there if you selected it.
@spec max_filters(any()) :: pos_integer() | nil
Returns the maximum number of filters of a schema.
iex> Flop.Schema.max_filters(%MyApp.Pet{})
nil
@spec max_limit(any()) :: pos_integer() | nil
Returns the maximum limit of a schema.
iex> Flop.Schema.max_limit(%MyApp.Pet{})
1000
@spec pagination_types(any()) :: [Flop.pagination_type()] | nil
Returns the allowed pagination types of a schema.
iex> Flop.Schema.pagination_types(%MyApp.Fruit{})
[:first, :last, :offset]
Returns the fields that identify a record, used as the default tiebreaker.
iex> Flop.Schema.primary_key(%MyApp.Fruit{})
[:id]
Returns the sortable fields of a schema.
iex> Flop.Schema.sortable(%MyApp.Pet{})
[:name, :age, :mood, :owner_name, :owner_age]
@spec tiebreaker(any()) :: Flop.tiebreaker() | nil
Returns the tiebreaker of a schema.
iex> Flop.Schema.tiebreaker(%MyApp.Fruit{})
nil