diff --git a/documentation/topics/policies.md b/documentation/topics/policies.md index d149e7b5..ae9849bd 100644 --- a/documentation/topics/policies.md +++ b/documentation/topics/policies.md @@ -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 diff --git a/lib/ash/policy/authorizer.ex b/lib/ash/policy/authorizer.ex index 76534a86..7d34bb44 100644 --- a/lib/ash/policy/authorizer.ex +++ b/lib/ash/policy/authorizer.ex @@ -225,7 +225,7 @@ defmodule Ash.Policy.Authorizer do @bypass ], imports: [ - Ash.Policy.Check.BuiltInChecks, + Ash.Policy.Check.Builtins, Ash.Filter.TemplateHelpers ], schema: [ diff --git a/lib/ash/policy/check/built_in_checks.ex b/lib/ash/policy/check/built_in_checks.ex index df318a89..52d1bf13 100644 --- a/lib/ash/policy/check/built_in_checks.ex +++ b/lib/ash/policy/check/built_in_checks.ex @@ -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" diff --git a/lib/ash/query/query.ex b/lib/ash/query/query.ex index 3bfa2899..a3358b3a 100644 --- a/lib/ash/query/query.ex +++ b/lib/ash/query/query.ex @@ -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 diff --git a/lib/ash/resource/calculation/builtins.ex b/lib/ash/resource/calculation/builtins.ex index 4c824294..b2de9492 100644 --- a/lib/ash/resource/calculation/builtins.ex +++ b/lib/ash/resource/calculation/builtins.ex @@ -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 diff --git a/lib/ash/resource/calculation/calculation.ex b/lib/ash/resource/calculation/calculation.ex index bb105f88..cfeeb75b 100644 --- a/lib/ash/resource/calculation/calculation.ex +++ b/lib/ash/resource/calculation/calculation.ex @@ -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] diff --git a/lib/ash/resource/change/builtins.ex b/lib/ash/resource/change/builtins.ex index 6314795e..e20a597a 100644 --- a/lib/ash/resource/change/builtins.ex +++ b/lib/ash/resource/change/builtins.ex @@ -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 diff --git a/lib/ash/resource/change/change.ex b/lib/ash/resource/change/change.ex index d1c40b64..94265c08 100644 --- a/lib/ash/resource/change/change.ex +++ b/lib/ash/resource/change/change.ex @@ -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 diff --git a/lib/ash/resource/change/relate_actor.ex b/lib/ash/resource/change/relate_actor.ex index 37eec151..6f851a23 100644 --- a/lib/ash/resource/change/relate_actor.ex +++ b/lib/ash/resource/change/relate_actor.ex @@ -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 diff --git a/lib/ash/resource/preparation/builtins.ex b/lib/ash/resource/preparation/builtins.ex index 4314bb33..a1b74a78 100644 --- a/lib/ash/resource/preparation/builtins.ex +++ b/lib/ash/resource/preparation/builtins.ex @@ -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 diff --git a/lib/ash/resource/preparation/preparation.ex b/lib/ash/resource/preparation/preparation.ex index 5364d526..b21ab478 100644 --- a/lib/ash/resource/preparation/preparation.ex +++ b/lib/ash/resource/preparation/preparation.ex @@ -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 diff --git a/lib/ash/resource/validation.ex b/lib/ash/resource/validation.ex index f09b34ba..0b141c10 100644 --- a/lib/ash/resource/validation.ex +++ b/lib/ash/resource/validation.ex @@ -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} diff --git a/lib/ash/resource/validation/builtins.ex b/lib/ash/resource/validation/builtins.ex index 4a577092..9015653d 100644 --- a/lib/ash/resource/validation/builtins.ex +++ b/lib/ash/resource/validation/builtins.ex @@ -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} diff --git a/lib/ash/resource/validation/compare.ex b/lib/ash/resource/validation/compare.ex index 371917e9..1894b476 100644 --- a/lib/ash/resource/validation/compare.ex +++ b/lib/ash/resource/validation/compare.ex @@ -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 diff --git a/lib/ash/resource/validation/one_of.ex b/lib/ash/resource/validation/one_of.ex index 9e1b5986..9678f77b 100644 --- a/lib/ash/resource/validation/one_of.ex +++ b/lib/ash/resource/validation/one_of.ex @@ -7,7 +7,7 @@ defmodule Ash.Resource.Validation.OneOf do @opt_schema [ values: [ - type: {:custom, __MODULE__, :values, []}, + type: {:list, :any}, required: true ], attribute: [ diff --git a/lib/ash/resource/validation/string_length.ex b/lib/ash/resource/validation/string_length.ex index 1a108b17..eb2efeae 100644 --- a/lib/ash/resource/validation/string_length.ex +++ b/lib/ash/resource/validation/string_length.ex @@ -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 diff --git a/lib/doc_index.ex b/lib/doc_index.ex index 8da3571d..ce701785 100644 --- a/lib/doc_index.ex +++ b/lib/doc_index.ex @@ -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 ]}, diff --git a/mix.exs b/mix.exs index 5ebb3d83..756536b4 100644 --- a/mix.exs +++ b/mix.exs @@ -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 ],