docs: add docs for all builtins

This commit is contained in:
Zach Daniel 2022-09-07 15:03:09 -04:00
parent 0368c1a8da
commit ac60cda9ac
18 changed files with 297 additions and 70 deletions

View file

@ -17,7 +17,7 @@ depending on the details of the request being authorized.
## Guide
To see what checks are built-in, see `Ash.Policy.Check.BuiltInChecks`
To see what checks are built-in, see `Ash.Policy.Check.Builtins`
### Basics

View file

@ -225,7 +225,7 @@ defmodule Ash.Policy.Authorizer do
@bypass
],
imports: [
Ash.Policy.Check.BuiltInChecks,
Ash.Policy.Check.Builtins,
Ash.Filter.TemplateHelpers
],
schema: [

View file

@ -1,4 +1,4 @@
defmodule Ash.Policy.Check.BuiltInChecks do
defmodule Ash.Policy.Check.Builtins do
@moduledoc "The global authorization checks built into ash"
@doc "This check always passes"

View file

@ -1185,6 +1185,50 @@ defmodule Ash.Query do
do_unload_load(loads, {field, []})
end
@build_opts [
filter: [
type: :any,
doc: "A filter keyword, expression or %Ash.Filter{}"
],
sort: [
type: :any,
doc: "A sort list or keyword"
],
limit: [
type: :integer,
doc: "A limit to apply"
],
offset: [
type: :integer,
doc: "An offset to apply"
],
load: [
type: :any,
doc: "A load statement to add to the query"
],
aggregate: [
type: :any,
doc:
"A custom aggregate to add to the query. Can be `{name, type, relationship}` or `{name, type, relationship, build_opts}`"
],
calculate: [
type: :any,
doc:
"A custom calculation to add to the query. Can be `{name, module_and_opts}` or `{name, module_and_opts, context}`"
],
distinct: [
type: {:list, :atom},
doc: "A distinct clause to add to the query"
],
context: [
type: :map,
doc: "A map to merge into the query context"
]
]
@doc false
def build_opts, do: @build_opts
@doc """
Builds a query from a keyword list.
@ -1208,18 +1252,9 @@ defmodule Ash.Query do
Ash.Query.build(Myresource, filter: expr(name == "marge"))
```
Supported keys:
* `filter` - filter keyword/expr or `%Ash.Filter{}`
* `sort` - sort keyword
* `limit` - integer limit
* `offset` - integer offset
* `load` - keyword/list of atoms to load
* `aggregate` - `{name, type, relationship}`
* `aggregate` - `{name, type, relationship, query_in_build_format}`
* `calculate` - `{name, module_and_opts}`
* `calculate` - `{name, module_and_opts, context}`
* `distinct` - list of atoms
* `context: %{key: value}`
## Options
#{Spark.OptionsHelpers.docs(@build_opts)}
"""
@spec build(Ash.Resource.t(), Ash.Api.t() | nil, Keyword.t()) :: t()
def build(resource, api \\ nil, keyword) do

View file

@ -1,7 +1,14 @@
defmodule Ash.Resource.Calculation.Builtins do
@moduledoc "Built in calculations that are automatically imported in the calculations section"
@doc "An example concatenation calculation, that accepts the delimeter as an argument"
@doc """
An example concatenation calculation, that accepts the delimiter as an argument
## Examples
calculate :full_name, concat([:first_name, :last_name], " ")
"""
@spec concat(keys :: list(atom), separator :: String.t()) :: Ash.Resource.Calculation.ref()
def concat(keys, separator \\ "") do
{Ash.Resource.Calculation.Concat, keys: keys, separator: separator}
end

View file

@ -111,6 +111,8 @@ defmodule Ash.Resource.Calculation do
allow_nil?: boolean
}
@type ref :: {module(), Keyword.t()} | module()
defmodule Argument do
@moduledoc "An argument to a calculation"
defstruct [:name, :type, :default, :allow_nil?, :constraints]

View file

@ -7,9 +7,16 @@ defmodule Ash.Resource.Change.Builtins do
@doc """
Relates the actor to the data being changed, as the provided relationship.
Accepts the option `:allow_nil?`, which will not force an actor to be set.
`:allow_nil?` defaults to `false`.
## Options
#{Spark.OptionsHelpers.docs(Ash.Resource.Change.RelateActor.opt_schema())}
## Examples
change relate_actor(:owner, allow_nil?: true)
"""
@spec relate_actor(relationship :: atom, opts :: Keyword.t()) :: Ash.Resource.Change.ref()
def relate_actor(relationship, opts \\ []) do
opts =
opts
@ -24,20 +31,35 @@ defmodule Ash.Resource.Change.Builtins do
If a zero argument function is provided, it is called to determine the value.
If a `arg(:arg_name)` is provided, the value will be read from the argument if supplied.
If the argument specified is not given to the action, then nothing happens.
Use `arg(:argument_name)` to use the value of the given argument. If the argument is not supplied then nothing happens.
## Examples
change set_attribute(:active, false)
change set_attribute(:opened_at, &DateTime.utc_now/0)
change set_attribute(:status, arg(:status))
"""
@spec set_attribute(relationship :: atom, (() -> term) | {:_arg, :status} | term()) ::
Ash.Resource.Change.ref()
def set_attribute(attribute, value) do
{Ash.Resource.Change.SetAttribute, attribute: attribute, value: value}
end
@doc """
Sets the attribute to the value provided if the attribtue is not already being changed.
Sets the attribute to the value provided if the attribute is not already being changed.
If a zero argument function is provided, it is called to determine the value.
Use `arg(:argument_name)` to use the value of the given argument. If the argument is not supplied then nothing happens.
## Examples
change set_new_attribute(:active, false)
change set_new_attribute(:opened_at, &DateTime.utc_now/0)
change set_new_attribute(:status, arg(:status))
"""
@spec set_new_attribute(relationship :: atom, (() -> term) | {:_arg, :status} | term()) ::
Ash.Resource.Change.ref()
def set_new_attribute(attribute, value) do
{Ash.Resource.Change.SetAttribute, attribute: attribute, value: value, new?: true}
end
@ -45,21 +67,37 @@ defmodule Ash.Resource.Change.Builtins do
@doc """
Clears a change off of the changeset before the action runs.
Useful if a change is only used in validations but shouldn't ultimately be written to the data layer
Does not fail if it is being changed, simply ensures it is cleared just before the action.
Can be useful if a change is only used in validations but shouldn't ultimately be written to the data layer.
## Examples
change prevent_change(:email)
"""
def prevent_change(field) do
{Ash.Resource.Change.PreventChange, field: field}
@spec prevent_change(attribute :: atom) :: Ash.Resource.Change.ref()
def prevent_change(attribute) do
{Ash.Resource.Change.PreventChange, field: attribute}
end
@doc """
Calls `Ash.Changeset.manage_relationship/4` with the changeset and relationship provided, using the value provided for the named argument
Calls `Ash.Changeset.manage_relationship/4` with the changeset and relationship provided, using the value provided for the named argument.
For example
If relationship_name is not specified, it is assumed to be the same as the argument.
```elixir
change manage_relationship(:add_comments, :comments, on_missing: :ignore, on_match: :no_match, on_no_match: {:create, :add_comment_to_post}
```
For information on the available options, see `Ash.Changeset.manage_relationship/4`.
## Examples
change manage_relationship(:comments, type: :append)
change manage_relationship(:remove_comments, :comments, type: :remove)
"""
@spec manage_relationship(
argument :: atom,
relationship_name :: atom | nil,
opts :: Keyword.t()
) ::
Ash.Resource.Change.ref()
def manage_relationship(argument, relationship_name \\ nil, opts) do
relationship_name = relationship_name || argument
@ -68,33 +106,64 @@ defmodule Ash.Resource.Change.Builtins do
end
@doc """
Merges the given query context. If an MFA is provided, it will be called with the changeset.
Merges the given query context.
If an MFA is provided, it will be called with the changeset.
The MFA should return `{:ok, context_to_be_merged}` or `{:error, term}`
## Examples
change set_context(%{something_used_internally: true})
change set_context({MyApp.Context, :set_context, []})
"""
@spec set_context(map | mfa) ::
{atom, Keyword.t()}
@spec set_context(context :: map | mfa) ::
Ash.Resource.Change.ref()
def set_context(context) do
{Ash.Resource.Change.SetContext, context: context}
end
@doc """
Passes the provided value into `changeset.api.load()`, after the action has completed.
## Example
change load(:comments)
change load([:friend_count, :friends])
"""
@spec load(load :: term()) :: Ash.Resource.Change.ref()
def load(value) do
{Ash.Resource.Change.Load, target: value}
end
@doc """
Passes the provided value into `Ash.Changeset.select/3`
Keep in mind, this will *limit* the fields that are selected. You may want `ensure_selected/1` if you
want to make sure that something is selected, without deselecting anything else.
Selecting in changesets does not actually do a select in the data layer, it simply nils out any
fields that were not selected. This can be useful if you are writing policies that have to do with
specific fields being selected.
## Example
change select([:name])
"""
@spec select(select :: atom | list(atom)) :: Ash.Resource.Change.ref()
def select(value) do
{Ash.Resource.Change.Select, target: value}
end
@doc """
Passes the provided value into `Ash.Changeset.ensure_selected/2`
If the value is not already selected, this makes sure it is. Does not deselect anything else.
## Example
change ensure_selected([:necessary_field])
"""
@spec ensure_selected(select :: atom | list(atom)) :: Ash.Resource.Change.ref()
def ensure_selected(value) do
{Ash.Resource.Change.Select, target: value, ensure?: true}
end

View file

@ -13,6 +13,7 @@ defmodule Ash.Resource.Change do
defstruct [:change, :on, :only_when_valid?, :description, where: []]
@type t :: %__MODULE__{}
@type ref :: {module(), Keyword.t()} | module()
@doc false
def schema do

View file

@ -4,16 +4,28 @@ defmodule Ash.Resource.Change.RelateActor do
alias Ash.Changeset
alias Ash.Error.Changes.InvalidRelationship
def init(opts) do
case opts[:relationship] do
nil ->
{:error, "Relationship is required"}
@opt_schema [
relationship: [
doc: "The relationship to set the actor to.",
required: true,
type: :atom
],
allow_nil?: [
doc: "Wether or not to allow the actor to be nil, in which case nothing will happen.",
type: :boolean,
default: false
]
]
relationship when is_atom(relationship) ->
def opt_schema(), do: @opt_schema
def init(opts) do
case Spark.OptionsHelpers.validate(opts, @opt_schema) do
{:ok, opts} ->
{:ok, opts}
relationship ->
{:error, "Expected an atom for relationship, got: #{inspect(relationship)}"}
{:error, error} ->
{:error, Exception.message(error)}
end
end

View file

@ -2,12 +2,18 @@ defmodule Ash.Resource.Preparation.Builtins do
@moduledoc "Builtin query preparations"
@doc """
Merges the given query context. If an MFA is provided, it will be called with the query.
Merges the given query context.
If an MFA is provided, it will be called with the changeset.
The MFA should return `{:ok, context_to_be_merged}` or `{:error, term}`
## Examples
change set_context(%{something_used_internally: true})
change set_context({MyApp.Context, :set_context, []})
"""
@spec set_context(map | (Ash.Query.t() -> mfa)) ::
{atom, Keyword.t()}
@spec set_context(context :: map | mfa) ::
Ash.Resource.Preparation.ref()
def set_context(context) do
{Ash.Resource.Preparation.SetContext, context: context}
end
@ -15,18 +21,18 @@ defmodule Ash.Resource.Preparation.Builtins do
@doc """
Passes the given keyword list to `Ash.Query.build/2` with the query being prepared.
This allows declaring simple query modifications in-line. For more complicated query modifications,
use a custom preparation.
This allows declaring simple query modifications in-line.
For example:
## Options
```elixir
read :top_ten_songs do
prepare build(sort: [song_rank: :desc], limit: 10)
end
```
#{Spark.OptionsHelpers.docs(Ash.Query.build_opts())}
## Examples
prepare build(sort: [song_rank: :desc], limit: 10)
prepare build(load: [:friends])
"""
@spec build(Keyword.t()) :: {atom, Keyword.t()}
@spec build(Keyword.t()) :: Ash.Resource.Preparation.ref()
def build(options) do
{Ash.Resource.Preparation.Build, options: options}
end

View file

@ -16,6 +16,7 @@ defmodule Ash.Resource.Preparation do
defstruct [:preparation]
@type t :: %__MODULE__{}
@type ref :: {module(), Keyword.t()} | module()
@doc false
def schema do

View file

@ -62,6 +62,8 @@ defmodule Ash.Resource.Validation do
}
@type path :: [atom | integer]
@type ref :: {module(), Keyword.t()} | module()
@callback init(Keyword.t()) :: {:ok, Keyword.t()} | {:error, String.t()}
@callback validate(Ash.Changeset.t(), Keyword.t()) :: :ok | {:error, term}

View file

@ -9,60 +9,142 @@ defmodule Ash.Resource.Validation.Builtins do
@doc """
Validates that an attribute's value is in a given list
## Examples
validate one_of(:status, [:closed_won, :closed_lost])
"""
@spec one_of(attribute :: atom, list(any)) :: Validation.ref()
def one_of(attribute, values) do
{Validation.OneOf, attribute: attribute, values: values}
end
@doc "Validates that an attribute is being changed"
@doc """
Validates that an attribute or relationship is being changed
## Examples
validate changing(:first_name)
validate changing(:comments)
"""
@spec changing(attribute :: atom) :: Validation.ref()
def changing(field) do
{Validation.Changing, field: field}
end
@doc "Validates that a field or argument matches another field or argument"
@doc """
Validates that a field or argument matches another field or argument
## Examples
validate confirm(:password, :password_confirmation)
validate confirm(:email, :email_confirmation)
"""
@spec confirm(attribute_or_argument :: atom, confirmation_attribute_or_argument :: atom) ::
Validation.ref()
def confirm(field, confirmation) do
{Validation.Confirm, [field: field, confirmation: confirmation]}
end
@doc "Validates that an attribute on the original record does not equal a specific value"
@doc """
Validates that an attribute is not being changed to a specific value, or does not equal the given value if it is not being changed.
## Examples
validate attribute_does_not_equal(:admin, true)
# Or to only check for changing to a given value
validate attribute_does_not_equal(:admin, true), where: [changing(:admin)]
"""
@spec attribute_does_not_equal(attribute :: atom, value :: term) :: Validation.ref()
def attribute_does_not_equal(attribute, value) do
{Validation.AttributeDoesNotEqual, attribute: attribute, value: value}
end
@doc "Validates that an attribute on the original record equals a specific value"
@doc """
Validates that an attribute is being changed to a specific value, or equals the given value if it is not being changed.
## Examples
validate attribute_equals(:admin, true)
# Or to only check for changing to a given value
validate attribute_equals(:admin, true), where: [changing(:admin)]
"""
@spec attribute_equals(attribute :: atom, value :: term) :: Validation.ref()
def attribute_equals(attribute, value) do
{Validation.AttributeEquals, attribute: attribute, value: value}
end
@doc "Validates that an attribute on the original record meets the given length criteria"
@doc """
Validates that an attribute on the original record meets the given length criteria
## Options
#{Spark.OptionsHelpers.docs(Keyword.delete(Ash.Resource.Validation.StringLength.opt_schema(), :attribute))}
## Examples
validate string_length(:slug, exactly: 8)
validate string_length(:password, min: 6)
validate string_length(:secret, min: 4, max: 12)
"""
@spec string_length(attribute :: atom, opts :: Keyword.t()) :: Validation.ref()
def string_length(attribute, opts \\ []) do
{Validation.StringLength, Keyword.merge(opts, attribute: attribute)}
end
@doc "Validates that attribute meets the given criteria"
@numericality_docs """
Validates that an attribute or argument meets the given comparison criteria.
## Options
#{Spark.OptionsHelpers.docs(Keyword.delete(Ash.Resource.Validation.Compare.opt_schema(), :attribute))}
## Examples
validate numericality(:age, greater_than_or_equal_to: 18),
where: [attribute_equals(:show_adult_content, true)],
message: "Must be over %{greater_than_or_equal_to} to enable adult content."
validate numericality(:points, greater_than: 0, less_than_or_equal_to: 100)
"""
@doc @numericality_docs
@spec numericality(attribute :: atom, opts :: Keyword.t()) :: Validation.ref()
def numericality(attribute, opts \\ []) do
compare(attribute, opts)
end
@doc String.replace(@numericality_docs, "numericality(", "compare(")
@spec compare(attribute :: atom, opts :: Keyword.t()) :: Validation.ref()
def compare(attribute, opts \\ []) do
{Validation.Compare, Keyword.merge(opts, attribute: attribute)}
end
@doc """
Validates that an attribute's value matches a given regex or string, using the provided error, message if not.
Validates that an attribute's value matches a given regex.
`String.match?/2` is used to determine if it matches.
`String.match?/2` is used to determine if the value matches.
## Examples
validate match(:slug, ~r/^[0-9a-z-_]+$/)
"""
def match(attribute, match, message \\ nil) do
message = message || "must match #{match}"
{Validation.Match, attribute: attribute, match: match, message: message}
@spec match(attribute :: atom, match :: Regex.t()) :: Validation.ref()
def match(attribute, match) do
{Validation.Match, attribute: attribute, match: match, message: "must match #{match}"}
end
@doc """
Validates the presence of a list of attributes
Validates the presence of a list of attributes or arguments.
If no options are provided, validates that they are all present.
## Options
#{Spark.OptionsHelpers.docs(Keyword.delete(Validation.Present.schema(), :attributes))}
"""
@spec present(attributes_or_arguments :: atom | list(atom), opts :: Keyword.t()) ::
Validation.ref()
def present(attributes, opts \\ []) do
if opts == [] do
attributes = List.wrap(attributes)
@ -74,12 +156,18 @@ defmodule Ash.Resource.Validation.Builtins do
end
@doc """
Validates the absence of a list of attributes
Validates the absence of a list of attributes or arguments.
If no options are provided, validates that they are all absent.
The docs behave the same as `present/2`, except they validate absence.
This works by changing your options and providing them to the `present` validation.
## Options
#{String.replace(Spark.OptionsHelpers.docs(Keyword.delete(Validation.Present.schema(), :attributes)), "present", "absent")}
"""
@spec absent(attributes_or_arguments :: atom | list(atom), opts :: Keyword.t()) ::
Validation.ref()
def absent(attributes, opts \\ []) do
if opts == [] do
{Validation.Present, attributes: List.wrap(attributes), exactly: 0}

View file

@ -33,6 +33,8 @@ defmodule Ash.Resource.Validation.Compare do
]
]
def opt_schema, do: @opt_schema
@impl true
def init(opts) do
case Spark.OptionsHelpers.validate(opts, @opt_schema) do

View file

@ -7,7 +7,7 @@ defmodule Ash.Resource.Validation.OneOf do
@opt_schema [
values: [
type: {:custom, __MODULE__, :values, []},
type: {:list, :any},
required: true
],
attribute: [

View file

@ -23,6 +23,8 @@ defmodule Ash.Resource.Validation.StringLength do
]
]
def opt_schema, do: @opt_schema
@impl true
def init(opts) do
case Spark.OptionsHelpers.validate(opts, @opt_schema) do

View file

@ -116,7 +116,7 @@ defmodule Ash.DocIndex do
[
Ash.Authorizer,
Ash.Policy.Check,
Ash.Policy.Check.BuiltInChecks,
Ash.Policy.Check.Builtins,
Ash.Policy.FilterCheck,
Ash.Policy.SimpleCheck
]},

View file

@ -121,7 +121,7 @@ defmodule Ash.MixProject do
Authorization: [
Ash.Authorizer,
Ash.Policy.Check,
Ash.Policy.Check.BuiltInChecks,
Ash.Policy.Check.Builtins,
Ash.Policy.FilterCheck,
Ash.Policy.SimpleCheck
],