Various Improvements (#113)

This commit is contained in:
Zach Daniel 2020-09-19 15:46:34 -04:00 committed by GitHub
parent 157f57c8ae
commit f41cc77549
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 385 additions and 20 deletions

View file

@ -81,8 +81,7 @@
# You can customize the priority of any check # You can customize the priority of any check
# Priority values are: `low, normal, high, higher` # Priority values are: `low, normal, high, higher`
# #
{Credo.Check.Design.AliasUsage, {Credo.Check.Design.AliasUsage, false},
[priority: :low, if_nested_deeper_than: 2, if_called_more_often_than: 0]},
# You can also customize the exit_status of each check. # You can also customize the exit_status of each check.
# If you don't want TODO comments to cause `mix credo` to fail, just # If you don't want TODO comments to cause `mix credo` to fail, just
# set this value to 0 (zero). # set this value to 0 (zero).

View file

@ -7,6 +7,7 @@ locals_without_parens = [
argument: 3, argument: 3,
attribute: 2, attribute: 2,
attribute: 3, attribute: 3,
base_filter: 1,
belongs_to: 2, belongs_to: 2,
belongs_to: 3, belongs_to: 3,
calculate: 2, calculate: 2,
@ -51,6 +52,7 @@ locals_without_parens = [
required?: 1, required?: 1,
resource: 1, resource: 1,
resource: 2, resource: 2,
soft?: 1,
source_field: 1, source_field: 1,
source_field_on_join_table: 1, source_field_on_join_table: 1,
table: 1, table: 1,

View file

@ -5,6 +5,13 @@ defmodule Ash.Actions.Destroy do
@spec run(Ash.api(), Ash.Changeset.t(), Ash.action(), Keyword.t()) :: @spec run(Ash.api(), Ash.Changeset.t(), Ash.action(), Keyword.t()) ::
:ok | {:error, Ash.Changeset.t()} | {:error, Ash.error()} :ok | {:error, Ash.Changeset.t()} | {:error, Ash.error()}
def run(api, changeset, %{soft?: true} = action, opts) do
case Ash.Actions.Update.run(api, %{changeset | action_type: :destroy}, action, opts) do
{:ok, _} -> :ok
other -> other
end
end
def run(api, %{data: record, resource: resource} = changeset, action, opts) do def run(api, %{data: record, resource: resource} = changeset, action, opts) do
engine_opts = engine_opts =
opts opts

View file

@ -90,7 +90,7 @@ defmodule Ash.Actions.Update do
defp add_validations(changeset) do defp add_validations(changeset) do
changeset.resource() changeset.resource()
|> Ash.Resource.validations(:update) |> Ash.Resource.validations(changeset.action_type)
|> Enum.reduce(changeset, fn validation, changeset -> |> Enum.reduce(changeset, fn validation, changeset ->
Ash.Changeset.before_action(changeset, &do_validation(&1, validation)) Ash.Changeset.before_action(changeset, &do_validation(&1, validation))
end) end)

View file

@ -7,7 +7,16 @@ defmodule Ash.DataLayer.Ets do
alias Ash.Actions.Sort alias Ash.Actions.Sort
alias Ash.Filter.{Expression, Not, Predicate} alias Ash.Filter.{Expression, Not, Predicate}
alias Ash.Filter.Predicate.{Eq, GreaterThan, In, IsNil, LessThan}
alias Ash.Filter.Predicate.{
Eq,
GreaterThan,
GreaterThanOrEqual,
In,
IsNil,
LessThan,
LessThanOrEqual
}
@behaviour Ash.DataLayer @behaviour Ash.DataLayer
@ -58,6 +67,8 @@ defmodule Ash.DataLayer.Ets do
def can?(_, {:filter_predicate, _, %Eq{}}), do: true def can?(_, {:filter_predicate, _, %Eq{}}), do: true
def can?(_, {:filter_predicate, _, %LessThan{}}), do: true def can?(_, {:filter_predicate, _, %LessThan{}}), do: true
def can?(_, {:filter_predicate, _, %GreaterThan{}}), do: true def can?(_, {:filter_predicate, _, %GreaterThan{}}), do: true
def can?(_, {:filter_predicate, _, %LessThanOrEqual{}}), do: true
def can?(_, {:filter_predicate, _, %GreaterThanOrEqual{}}), do: true
def can?(_, {:filter_predicate, _, %IsNil{}}), do: true def can?(_, {:filter_predicate, _, %IsNil{}}), do: true
def can?(_, {:sort, _}), do: true def can?(_, {:sort, _}), do: true
def can?(_, _), do: false def can?(_, _), do: false
@ -180,6 +191,20 @@ defmodule Ash.DataLayer.Ets do
end end
end end
defp matches_predicate?(record, field, %LessThanOrEqual{value: predicate_value}) do
case Map.fetch(record, field) do
{:ok, value} -> value <= predicate_value
:error -> false
end
end
defp matches_predicate?(record, field, %GreaterThanOrEqual{value: predicate_value}) do
case Map.fetch(record, field) do
{:ok, value} -> value >= predicate_value
:error -> false
end
end
defp matches_predicate?(record, field, %In{values: predicate_values}) do defp matches_predicate?(record, field, %In{values: predicate_values}) do
case Map.fetch(record, field) do case Map.fetch(record, field) do
{:ok, value} -> value in predicate_values {:ok, value} -> value in predicate_values
@ -197,7 +222,7 @@ defmodule Ash.DataLayer.Ets do
@impl true @impl true
def upsert(resource, changeset) do def upsert(resource, changeset) do
create(resource, changeset) update(resource, changeset)
end end
@impl true @impl true

View file

@ -14,6 +14,51 @@ defmodule Ash.Filter do
You can pass a filter template to `build_filter_from_template/2` with an actor, and it will return the new result You can pass a filter template to `build_filter_from_template/2` with an actor, and it will return the new result
Additionally, you can ask if the filter template contains an actor reference via `template_references_actor?/1` Additionally, you can ask if the filter template contains an actor reference via `template_references_actor?/1`
## Writing a filter:
A filter is a nested keyword list (with some exceptions, like `true` for everything and `false` for nothing).
The key is the "predicate" (A.K.A condition) and the value is the parameter. You can use `and` and `or` to create
nested filters. Datalayers can expose custom predicates. Eventually, you will be able to define your own custom
predicates, which will be a mechanism for you to attach complex filters supported by the data layer to your queries.
** Important **
In a given keyword list, all predicates are considered to be "ands". So `[or: [first_name: "Tom", last_name: "Bombadil"]]` doesn't
mean 'First name == "tom" or last_name == "bombadil"'. To say that, you want to provide a list of filters,
like so: `[or: [[first_name: "Tom"], [last_name: "Bombadil"]]]`
The builtin predicates are:
* eq - shorthand for equals
* equals
* in
* lt - shorthand for less_than
* gt - shorthand for greater_than
* lte - shorthand for less_than_or_equal
* gte - shorthand for greater_than_or_equal
* less_than
* greater_than
* less_than_or_equal
* greater_than_or_equal
* is_nil
Some example filters:
```elixir
[name: "Zardoz"]
[first_name: "Zar", last_name: "Doz"]
[first_name: "Zar", last_name: [in: ["Doz", "Daz"]], high_score: [greater_than: 10]]
[first_name: "Zar", last_name: [in: ["Doz", "Daz"]], high_score: [greater_than: 10]]
[or: [
[first_name: "Zar"],
[last_name: "Doz"],
[or: [
[high_score: [greater_than: 10]]],
[high_score: [less_than: -10]]
]
]]
```
""" """
alias Ash.Actions.SideLoad alias Ash.Actions.SideLoad
alias Ash.Engine.Request alias Ash.Engine.Request
@ -26,7 +71,16 @@ defmodule Ash.Filter do
ReadActionRequired ReadActionRequired
} }
alias Ash.Filter.Predicate.{Eq, GreaterThan, In, IsNil, LessThan} alias Ash.Filter.Predicate.{
Eq,
GreaterThan,
GreaterThanOrEqual,
In,
IsNil,
LessThan,
LessThanOrEqual
}
alias Ash.Filter.{Expression, Not, Predicate} alias Ash.Filter.{Expression, Not, Predicate}
alias Ash.Query.Aggregate alias Ash.Query.Aggregate
@ -36,8 +90,12 @@ defmodule Ash.Filter do
in: In, in: In,
lt: LessThan, lt: LessThan,
gt: GreaterThan, gt: GreaterThan,
lte: LessThanOrEqual,
gte: GreaterThanOrEqual,
less_than: LessThan, less_than: LessThan,
greater_than: GreaterThan, greater_than: GreaterThan,
less_than_or_equal: LessThanOrEqual,
greater_than_or_equal: GreaterThanOrEqual,
is_nil: IsNil is_nil: IsNil
] ]

View file

@ -1,7 +1,12 @@
defmodule Ash.Filter.Predicate do defmodule Ash.Filter.Predicate do
@moduledoc "Represents a filter predicate" @moduledoc """
Represents a filter predicate
defstruct [:resource, :attribute, :relationship_path, :predicate, :value] The `embedded` flag is set to true for predicates that are present in the `base_filter`.
Datalayers may optionally use this information.
"""
defstruct [:resource, :attribute, :relationship_path, :predicate, :value, embedded: false]
alias Ash.Error.Query.UnsupportedPredicate alias Ash.Error.Query.UnsupportedPredicate
alias Ash.Filter alias Ash.Filter

View file

@ -0,0 +1,64 @@
defmodule Ash.Filter.Predicate.GreaterThanOrEqual do
@moduledoc "A predicate for a value being greater than the provided value"
defstruct [:field, :value, :type]
alias Ash.Filter.Predicate.Eq
alias Ash.Error.Query.InvalidFilterValue
use Ash.Filter.Predicate
def new(_resource, attribute, value) do
case Ash.Type.cast_input(attribute.type, value) do
{:ok, value} ->
{:ok, %__MODULE__{field: attribute.name, value: value}}
_ ->
{:error,
InvalidFilterValue.exception(
value: value,
context: %__MODULE__{field: attribute.name, value: value},
message: "Could not be casted to type #{inspect(attribute.type)}"
)}
end
end
def match?(%{value: predicate_value}, value, _) do
value >= predicate_value
end
def compare(%__MODULE__{value: value}, %__MODULE__{value: value}), do: :mutually_inclusive
def compare(%__MODULE__{value: value}, %__MODULE__{value: other_value})
when value > other_value do
:right_includes_left
end
def compare(%__MODULE__{value: value}, %__MODULE__{value: other_value})
when value < other_value do
:left_includes_right
end
def compare(%__MODULE__{value: value}, %Eq{value: eq_value}) when eq_value >= value do
:left_includes_right
end
def compare(%__MODULE__{}, %Eq{}) do
:mutually_exclusive
end
def compare(_, _), do: :unknown
defimpl Inspect do
import Inspect.Algebra
alias Ash.Filter.Predicate
def inspect(predicate, opts) do
concat([
Predicate.add_inspect_path(opts, predicate.field),
" >= ",
to_doc(predicate.value, opts)
])
end
end
end

View file

@ -50,16 +50,16 @@ defmodule Ash.Filter.Predicate.In do
end end
def compare(%__MODULE__{} = left, %__MODULE__{} = right) do def compare(%__MODULE__{} = left, %__MODULE__{} = right) do
{:simplify, in_to_or_equals(left), in_to_or_equals(right)} {:simplify, into_or_equals(left), into_or_equals(right)}
end end
def compare(%__MODULE__{} = in_expr, _) do def compare(%__MODULE__{} = in_expr, _) do
{:simplify, in_to_or_equals(in_expr)} {:simplify, into_or_equals(in_expr)}
end end
def compare(_, _), do: :unknown def compare(_, _), do: :unknown
defp in_to_or_equals(%{field: field, values: values}) do defp into_or_equals(%{field: field, values: values}) do
Enum.reduce(values, nil, fn value, expression -> Enum.reduce(values, nil, fn value, expression ->
Expression.new(:or, expression, %Eq{field: field, value: value}) Expression.new(:or, expression, %Eq{field: field, value: value})
end) end)

View file

@ -24,7 +24,7 @@ defmodule Ash.Filter.Predicate.LessThan do
end end
def match?(%{value: predicate_value}, value, _) do def match?(%{value: predicate_value}, value, _) do
value > predicate_value value < predicate_value
end end
def compare(%__MODULE__{value: value}, %__MODULE__{value: value}), do: :mutually_inclusive def compare(%__MODULE__{value: value}, %__MODULE__{value: value}), do: :mutually_inclusive

View file

@ -0,0 +1,64 @@
defmodule Ash.Filter.Predicate.LessThanOrEqual do
@moduledoc "A predicate for a value being greater than the provided value"
defstruct [:field, :value, :type]
alias Ash.Filter.Predicate.Eq
alias Ash.Error.Query.InvalidFilterValue
use Ash.Filter.Predicate
def new(_resource, attribute, value) do
case Ash.Type.cast_input(attribute.type, value) do
{:ok, value} ->
{:ok, %__MODULE__{field: attribute.name, value: value}}
_ ->
{:error,
InvalidFilterValue.exception(
value: value,
context: %__MODULE__{field: attribute.name, value: value},
message: "Could not be casted type type #{inspect(attribute.type)}"
)}
end
end
def match?(%{value: predicate_value}, value, _) do
value <= predicate_value
end
def compare(%__MODULE__{value: value}, %__MODULE__{value: value}), do: :mutually_inclusive
def compare(%__MODULE__{value: value}, %__MODULE__{value: other_value})
when value < other_value do
:right_includes_left
end
def compare(%__MODULE__{value: value}, %__MODULE__{value: other_value})
when value > other_value do
:left_includes_right
end
def compare(%__MODULE__{value: value}, %Eq{value: eq_value}) when eq_value <= value do
:left_includes_right
end
def compare(%__MODULE__{}, %Eq{}) do
:mutually_exclusive
end
def compare(_, _), do: :unknown
defimpl Inspect do
import Inspect.Algebra
alias Ash.Filter.Predicate
def inspect(predicate, opts) do
concat([
Predicate.add_inspect_path(opts, predicate.field),
" <= ",
to_doc(predicate.value, opts)
])
end
end
end

View file

@ -66,12 +66,32 @@ defmodule Ash.Query do
@doc "Create a new query." @doc "Create a new query."
def new(resource, api \\ nil) when is_atom(resource) do def new(resource, api \\ nil) when is_atom(resource) do
query =
%__MODULE__{ %__MODULE__{
api: api, api: api,
filter: nil, filter: nil,
resource: resource resource: resource
} }
|> set_data_layer_query() |> set_data_layer_query()
case Ash.Resource.base_filter(resource) do
nil ->
query
filter ->
filter = Ash.Filter.parse!(resource, filter)
filter =
Ash.Filter.map(filter, fn
%Ash.Filter.Predicate{} = pred ->
%{pred | embedded: true}
other ->
other
end)
filter(query, filter)
end
end end
@spec load(t(), atom | list(atom) | Keyword.t()) :: t() @spec load(t(), atom | list(atom) | Keyword.t()) :: t()
@ -515,6 +535,12 @@ defmodule Ash.Query do
end) end)
end end
@doc """
Attach a filter statement to the query.
The filter is applied as an "and" to any filters currently on the query.
For more information on writing filters, see: `Ash.Filter`.
"""
@spec filter(t() | Ash.resource(), nil | false | Ash.filter() | Keyword.t()) :: t() @spec filter(t() | Ash.resource(), nil | false | Ash.filter() | Keyword.t()) :: t()
def filter(query, nil), do: to_query(query) def filter(query, nil), do: to_query(query)

View file

@ -89,6 +89,11 @@ defmodule Ash.Resource do
Extension.get_opt(resource, [:resource], :description, "no description") Extension.get_opt(resource, [:resource], :description, "no description")
end end
@spec base_filter(Ash.resource()) :: term
def base_filter(resource) do
Extension.get_opt(resource, [:resource], :base_filter, nil)
end
@doc "A list of identities for the resource" @doc "A list of identities for the resource"
@spec identities(Ash.resource()) :: [Ash.Resource.Identity.t()] @spec identities(Ash.resource()) :: [Ash.Resource.Identity.t()]
def identities(resource) do def identities(resource) do

View file

@ -1,7 +1,7 @@
defmodule Ash.Resource.Actions.Destroy do defmodule Ash.Resource.Actions.Destroy do
@moduledoc "Represents a destroy action on a resource." @moduledoc "Represents a destroy action on a resource."
defstruct [:name, :primary?, type: :destroy] defstruct [:name, :primary?, :changes, :accept, :soft?, type: :destroy]
@type t :: %__MODULE__{ @type t :: %__MODULE__{
type: :destroy, type: :destroy,
@ -18,6 +18,16 @@ defmodule Ash.Resource.Actions.Destroy do
type: :boolean, type: :boolean,
default: false, default: false,
doc: "Whether or not this action should be used when no action is specified by the caller." doc: "Whether or not this action should be used when no action is specified by the caller."
],
accept: [
type: {:custom, Ash.OptionsHelpers, :list_of_atoms, []},
doc:
"The list of attributes and relationships to accept. Defaults to all attributes on the resource. Has no effect unless `soft?` is specified."
],
soft?: [
type: :atom,
doc:
"If specified, the destroy action calls the datalayer's update function with any specified changes."
] ]
] ]

View file

@ -8,5 +8,9 @@ defmodule Ash.Resource.Change.Builtins do
{Ash.Resource.Change.RelateActor, relationship: relationship} {Ash.Resource.Change.RelateActor, relationship: relationship}
end end
def set_attribute(attribute, value) do
{Ash.Resource.Change.SetAttribute, attribute: attribute, value: value}
end
def actor(value), do: {:_actor, value} def actor(value), do: {:_actor, value}
end end

View file

@ -0,0 +1,34 @@
defmodule Ash.Resource.Change.SetAttribute do
@moduledoc """
Sets the attribute to the value provided. If a zero argument function is provided, it is called to determine the value.
"""
use Ash.Resource.Change
alias Ash.Changeset
def init(opts) do
with :ok <- validate_attribute(opts[:attribute]),
:ok <- validate_value(opts[:value]) do
{:ok, opts}
end
end
defp validate_attribute(nil), do: {:error, "attribute is required"}
defp validate_attribute(value) when is_atom(value), do: :ok
defp validate_attribute(other), do: {:error, "attribute is invalid: #{inspect(other)}"}
defp validate_value(value) when is_function(value, 0), do: :ok
defp validate_value(value) when is_function(value),
do: {:error, "only 0 argument functions are supported"}
defp validate_value(_), do: :ok
def change(changeset, opts, _) do
value =
case opts[:value] do
value when is_function(value) -> value.()
value -> value
end
Changeset.force_change_attribute(changeset, opts[:attribute], value)
end
end

View file

@ -182,6 +182,8 @@ defmodule Ash.Resource.Dsl do
so you can say something like: so you can say something like:
`change my_change(1)` `change my_change(1)`
For destroys, `changes` are not applied unless `soft?` is set to true.
""", """,
examples: [ examples: [
"change relate_actor(:reporter)", "change relate_actor(:reporter)",
@ -250,6 +252,11 @@ defmodule Ash.Resource.Dsl do
examples: [ examples: [
"destroy :soft_delete, primary?: true" "destroy :soft_delete, primary?: true"
], ],
entities: [
changes: [
@change
]
],
target: Ash.Resource.Actions.Destroy, target: Ash.Resource.Actions.Destroy,
schema: Ash.Resource.Actions.Destroy.opt_schema(), schema: Ash.Resource.Actions.Destroy.opt_schema(),
args: [:name] args: [:name]
@ -322,7 +329,12 @@ defmodule Ash.Resource.Dsl do
], ],
schema: [ schema: [
description: [ description: [
type: :string type: :string,
doc: "A human readable description of the resource, to be used in generated documentation"
],
base_filter: [
type: :any,
doc: "A filter statement to be applied to any queries on the resource"
] ]
] ]
} }

View file

@ -117,12 +117,42 @@ defmodule Ash.Test.Filter.FilterTest do
end end
end end
defmodule SoftDeletePost do
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private? true
end
resource do
base_filter is_nil: :deleted_at
end
actions do
read :default
create :default
destroy :default do
soft? true
change set_attribute(:deleted_at, &DateTime.utc_now/0)
end
end
attributes do
attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0
attribute :deleted_at, :utc_datetime
end
end
defmodule Api do defmodule Api do
@moduledoc false @moduledoc false
use Ash.Api use Ash.Api
resources do resources do
resource(Post) resource(Post)
resource(SoftDeletePost)
resource(User) resource(User)
resource(Profile) resource(Profile)
resource(PostLink) resource(PostLink)
@ -374,4 +404,24 @@ defmodule Ash.Test.Filter.FilterTest do
assert Filter.strict_subset_of?(filter, candidate) assert Filter.strict_subset_of?(filter, candidate)
end end
end end
describe "base_filter" do
test "resources that apply to the base filter are returned" do
%{id: id} =
SoftDeletePost
|> new(%{})
|> Api.create!()
assert [%{id: ^id}] = Api.read!(SoftDeletePost)
end
test "resources that don't apply to the base filter are not returned" do
SoftDeletePost
|> new(%{})
|> Api.create!()
|> Api.destroy()
assert [] = Api.read!(SoftDeletePost)
end
end
end end