ash/test/filter/filter_test.exs

638 lines
16 KiB
Elixir
Raw Normal View History

2019-12-16 13:20:44 +13:00
defmodule Ash.Test.Filter.FilterTest do
2020-06-02 17:47:25 +12:00
@moduledoc false
2019-12-16 13:20:44 +13:00
use ExUnit.Case, async: true
import Ash.Changeset
2021-06-06 10:11:09 +12:00
import Ash.Test.Helpers
alias Ash.Filter
2020-10-08 18:22:55 +13:00
require Ash.Query
defmodule Profile do
2020-06-02 17:47:25 +12:00
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
read :read
create :create
update :update
end
attributes do
uuid_primary_key :id
attribute :bio, :string
attribute :private, :string, private?: true
end
relationships do
2019-12-24 17:22:31 +13:00
belongs_to :user, Ash.Test.Filter.FilterTest.User
end
end
2019-12-16 13:20:44 +13:00
defmodule User do
2020-06-02 17:47:25 +12:00
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
2019-12-16 13:20:44 +13:00
actions do
read :read
create :create
update :update
2019-12-16 13:20:44 +13:00
end
attributes do
uuid_primary_key :id
2019-12-16 13:20:44 +13:00
attribute :name, :string
attribute :allow_second_author, :boolean
attribute :special, :boolean
2019-12-16 13:20:44 +13:00
end
relationships do
2020-06-22 16:34:44 +12:00
has_many :posts, Ash.Test.Filter.FilterTest.Post, destination_field: :author1_id
2020-06-22 16:34:44 +12:00
has_many :second_posts, Ash.Test.Filter.FilterTest.Post, destination_field: :author1_id
2019-12-24 17:22:31 +13:00
has_one :profile, Profile, destination_field: :user_id
end
end
defmodule PostLink do
2020-06-02 17:47:25 +12:00
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
actions do
read :read
create :create
update :update
end
relationships do
belongs_to :source_post, Ash.Test.Filter.FilterTest.Post,
primary_key?: true,
required?: true
belongs_to :destination_post, Ash.Test.Filter.FilterTest.Post,
primary_key?: true,
required?: true
2019-12-16 13:20:44 +13:00
end
end
defmodule Post do
2020-06-02 17:47:25 +12:00
@moduledoc false
use Ash.Resource, data_layer: Ash.DataLayer.Ets
ets do
private?(true)
end
2019-12-16 13:20:44 +13:00
actions do
read :read
2019-12-16 13:20:44 +13:00
create :create
update :update
2019-12-16 13:20:44 +13:00
end
attributes do
uuid_primary_key :id
2019-12-16 13:20:44 +13:00
attribute :title, :string
attribute :contents, :string
attribute :points, :integer
feat: freeform expressions feat: validatiosn in actions feat: query arguments feat: add `Ash.Query.for_read/3` feat: return changeset with API errors feat: add case insensitive string `CiString`/`:ci_string` feat: support `context/1` and `arg/1` in filter templates feat: support targeting notifications with the `for` option feat: add `ago/2` query function feat: add basic arithmetic operators (+, *, -, /) feat: `sensitive?` option for attributes feat: `sensitive?` option for arguments feat: `private` arguments, which can’t be set using `for_<action>` feat: add `prevent_change` which will erase changes just before the changeset is committed feat: add `match?` validation that supports a custom error message feat: add `interval` type to support `ago/2` function feat: add `url_encoded_binary` type feat: add `function` type improvement: `changing?` is now a validation improvement: add `Transformer.get_persisted/3` improvement: add `api` field to `Notification` improvement: standardize errors, add `to_error_class` improvement: use `Comp` everywhere Improvement: use action on changeset if set by `for_<action_type>` improvement: `action_failed?` field on change sets improvement: remove ability for data layers to add operators (for now at least) Improvement: Changeset.apply_attributes/2 now returns an error tuple Improvement: add a bunch of new/informative errors improvement: runtime filter now uses left join logic (a naive implementation of it) improvement: support more filter templates in resources Improvement: basic/naive type system for operators/functions Fix: properly expand module aliases for options w/o compile time dependency chore(engine): track changeset changes for the request with `manage_changeset?: true`
2021-01-22 09:21:58 +13:00
attribute :approved_at, :utc_datetime
attribute :category, :ci_string
2019-12-16 13:20:44 +13:00
end
relationships do
belongs_to :author1, User,
destination_field: :id,
source_field: :author1_id
belongs_to :special_author1, User,
destination_field: :id,
source_field: :author1_id,
define_field?: false,
filter: expr(special == true)
2019-12-16 13:20:44 +13:00
belongs_to :author2, User,
destination_field: :id,
source_field: :author2_id
many_to_many :related_posts, __MODULE__,
through: PostLink,
source_field_on_join_table: :source_post_id,
2020-06-22 16:34:44 +12:00
destination_field_on_join_table: :destination_post_id
2019-12-16 13:20:44 +13:00
end
end
2020-09-20 07:46:34 +12:00
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 :read
create :create
2020-09-20 07:46:34 +12:00
destroy :destroy do
2020-09-20 07:46:34 +12:00
soft? true
change set_attribute(:deleted_at, &DateTime.utc_now/0)
end
end
attributes do
uuid_primary_key :id
2020-09-20 07:46:34 +12:00
attribute :deleted_at, :utc_datetime
end
end
defmodule Registry do
@moduledoc false
use Ash.Registry
entries do
entry(Post)
entry(SoftDeletePost)
entry(User)
entry(Profile)
entry(PostLink)
end
end
2019-12-16 13:20:44 +13:00
defmodule Api do
2020-06-02 17:47:25 +12:00
@moduledoc false
2019-12-16 13:20:44 +13:00
use Ash.Api
resources do
registry Registry
end
2019-12-16 13:20:44 +13:00
end
describe "predicate optimization" do
# Testing against the stringified query may be a bad idea, but its a quick win and we
# can switch to actually checking the structure if this bites us
test "equality simplifies to `in`" do
stringified_query =
Post
|> Ash.Query.filter(title == "foo" or title == "bar")
|> inspect()
assert stringified_query =~ ~S(title in ["bar", "foo"])
end
test "in with equality simplifies to `in`" do
stringified_query =
Post
|> Ash.Query.filter(title in ["foo", "bar", "baz"] or title == "bar")
|> inspect()
assert stringified_query =~ ~S(title in ["bar", "baz", "foo"])
end
test "in with non-equality simplifies to `in`" do
stringified_query =
Post
|> Ash.Query.filter(title in ["foo", "bar", "baz"] and title != "bar")
|> inspect()
assert stringified_query =~ ~S(title in ["baz", "foo"])
end
test "in with or-in simplifies to `in`" do
stringified_query =
Post
|> Ash.Query.filter(title in ["foo", "bar"] or title in ["bar", "baz"])
|> inspect()
assert stringified_query =~ ~S(title in ["bar", "baz", "foo"])
end
test "in with and-in simplifies to `in` when multiple values overlap" do
stringified_query =
Post
|> Ash.Query.filter(title in ["foo", "bar", "baz"] and title in ["bar", "baz", "bif"])
|> inspect()
assert stringified_query =~ ~S(title in ["bar", "baz"])
end
test "in with and-in simplifies to `eq` when one value overlaps" do
stringified_query =
Post
|> Ash.Query.filter(title in ["foo", "bar"] and title in ["bar", "baz", "bif"])
|> inspect()
assert stringified_query =~ ~S(title == "bar")
end
end
describe "simple attribute filters" do
setup do
2020-07-12 18:25:53 +12:00
post1 =
Post
|> new(%{title: "title1", contents: "contents1", points: 1})
2020-07-12 18:25:53 +12:00
|> Api.create!()
post2 =
Post
|> new(%{title: "title2", contents: "contents2", points: 2})
2020-07-12 18:25:53 +12:00
|> Api.create!()
%{post1: post1, post2: post2}
end
test "single filter field", %{post1: post1} do
assert [^post1] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(title == ^post1.title)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
end
test "multiple filter field matches", %{post1: post1} do
assert [^post1] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(title == ^post1.title and contents == ^post1.contents)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
end
test "no field matches" do
assert [] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(title == "no match")
|> Api.read!()
end
test "no field matches single record, but each matches one record", %{
post1: post1,
post2: post2
} do
assert [] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(title == ^post1.title and contents == ^post2.contents)
|> Api.read!()
end
test "less than works", %{
post1: post1,
post2: post2
} do
assert [^post1] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(points < 2)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
assert [^post1, ^post2] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(points < 3)
|> Ash.Query.sort(points: :asc)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
end
test "greater than works", %{
post1: post1,
post2: post2
} do
assert [^post2] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(points > 1)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
assert [^post1, ^post2] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(points > 0)
|> Ash.Query.sort(points: :asc)
|> Api.read!()
2021-06-06 10:11:09 +12:00
|> clear_meta()
end
end
describe "relationship filters" do
setup do
2020-07-12 18:25:53 +12:00
post1 =
Post
|> new(%{title: "title1", contents: "contents1", points: 1})
2020-07-12 18:25:53 +12:00
|> Api.create!()
post2 =
Post
|> new(%{title: "title2", contents: "contents2", points: 2})
2020-07-12 18:25:53 +12:00
|> Api.create!()
post3 =
2020-07-12 18:25:53 +12:00
Post
|> new(%{title: "title3", contents: "contents3", points: 3})
2020-07-12 18:25:53 +12:00
|> replace_relationship(:related_posts, [post1, post2])
|> Api.create!()
2019-12-24 17:22:31 +13:00
post4 =
2020-07-12 18:25:53 +12:00
Post
|> new(%{title: "title4", contents: "contents4", points: 4})
2020-07-12 18:25:53 +12:00
|> replace_relationship(:related_posts, [post3])
|> Api.create!()
2019-12-24 17:22:31 +13:00
2020-07-12 18:25:53 +12:00
profile1 =
Profile
|> new(%{bio: "dope"})
2020-07-12 18:25:53 +12:00
|> Api.create!()
user1 =
2020-07-12 18:25:53 +12:00
User
|> new(%{name: "broseph"})
2020-07-12 18:25:53 +12:00
|> replace_relationship(:posts, [post1, post2])
|> replace_relationship(:profile, profile1)
|> Api.create!()
user2 =
User
|> new(%{name: "broseph", special: false})
2020-07-12 18:25:53 +12:00
|> replace_relationship(:posts, [post2])
|> Api.create!()
profile2 =
Profile
|> new(%{bio: "dope2"})
2020-07-12 18:25:53 +12:00
|> replace_relationship(:user, user2)
|> Api.create!()
%{
2019-12-24 17:22:31 +13:00
post1: Api.reload!(post1),
post2: Api.reload!(post2),
post3: Api.reload!(post3),
post4: Api.reload!(post4),
profile1: Api.reload!(profile1),
user1: Api.reload!(user1),
user2: Api.reload!(user2),
profile2: Api.reload!(profile2)
}
end
test "filtering on a has_one relationship", %{profile2: profile2, user2: %{id: user2_id}} do
assert [%{id: ^user2_id}] =
User
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(profile == ^profile2.id)
|> Api.read!()
2019-12-24 17:22:31 +13:00
end
test "filtering on a belongs_to relationship", %{profile1: %{id: id}, user1: user1} do
assert [%{id: ^id}] =
Profile
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(user == ^user1.id)
|> Api.read!()
2019-12-24 17:22:31 +13:00
end
test "filtering on a has_many relationship", %{user2: %{id: user2_id}, post2: post2} do
assert [%{id: ^user2_id}] =
User
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(posts == ^post2.id)
|> Api.read!()
2019-12-24 17:22:31 +13:00
end
test "filtering on a many_to_many relationship", %{post4: %{id: post4_id}, post3: post3} do
assert [%{id: ^post4_id}] =
Post
2020-10-08 18:22:55 +13:00
|> Ash.Query.filter(related_posts == ^post3.id)
|> Api.read!()
2019-12-24 17:22:31 +13:00
end
test "relationship filters are honored when filtering on relationships", %{post2: post} do
2021-04-29 09:43:02 +12:00
post = Api.load!(post, [:special_author1, :author1])
assert post.author1
refute post.special_author1
end
2019-12-16 13:20:44 +13:00
end
describe "filter subset logic" do
test "can detect a filter is a subset of itself" do
filter = Filter.parse!(Post, %{points: 1})
assert Filter.strict_subset_of?(filter, filter)
end
test "can detect a filter is a subset of itself *and* something else" do
filter = Filter.parse!(Post, points: 1)
candidate = Filter.add_to_filter!(filter, title: "Title")
assert Filter.strict_subset_of?(filter, candidate)
end
test "can detect a filter is not a subset of itself *or* something else" do
filter = Filter.parse!(Post, points: 1)
2020-07-23 17:09:59 +12:00
candidate = Filter.add_to_filter!(filter, [title: "Title"], :or)
refute Filter.strict_subset_of?(filter, candidate)
end
test "can detect a filter is a subset based on a simplification" do
query = Ash.Query.filter(Post, points in [1, 2])
assert Ash.Query.superset_of?(query, points == 1)
assert Ash.Query.subset_of?(query, points in [1, 2, 3])
assert Ash.Query.equivalent_to?(query, points == 1 or points == 2)
end
test "can detect a filter is not a subset based on a simplification" do
filter = Filter.parse!(Post, points: [in: [1, 2]])
candidate = Filter.parse!(Post, points: 3)
refute Filter.strict_subset_of?(filter, candidate)
end
test "can detect a more complicated scenario" do
filter = Filter.parse!(Post, or: [[points: [in: [1, 2, 3]]], [points: 4], [points: 5]])
candidate = Filter.parse!(Post, or: [[points: 1], [points: 3], [points: 5]])
assert Filter.strict_subset_of?(filter, candidate)
end
test "can detect less than and greater than closing in on a single value" do
filter = Filter.parse!(Post, points: [greater_than: 1, less_than: 3])
candidate = Filter.parse!(Post, points: 2)
assert Filter.strict_subset_of?(filter, candidate)
end
test "doesnt have false positives on less than and greater than closing in on a single value" do
filter = Filter.parse!(Post, points: [greater_than: 1, less_than: 3])
candidate = Filter.parse!(Post, points: 4)
refute Filter.strict_subset_of?(filter, candidate)
end
test "understands unrelated negations" do
filter = Filter.parse!(Post, or: [[points: [in: [1, 2, 3]]], [points: 4], [points: 5]])
candidate =
Filter.parse!(Post, or: [[points: 1], [points: 3], [points: 5]], not: [points: 7])
assert Filter.strict_subset_of?(filter, candidate)
end
test "understands relationship filter subsets" do
2021-02-24 06:27:49 +13:00
id1 = Ash.UUID.generate()
id2 = Ash.UUID.generate()
filter = Filter.parse!(Post, author1: [id: [in: [id1, id2]]])
candidate = Filter.parse!(Post, author1: id1)
assert Filter.strict_subset_of?(filter, candidate)
end
test "understands relationship filter subsets when a value coincides with the join field" do
2021-02-24 06:27:49 +13:00
id1 = Ash.UUID.generate()
id2 = Ash.UUID.generate()
filter = Filter.parse!(Post, author1: [id: [in: [id1, id2]]])
candidate = Filter.parse!(Post, author1_id: id1)
assert Filter.strict_subset_of?(filter, candidate)
end
end
2020-09-20 07:46:34 +12:00
describe "parse_input" do
test "parse_input works when no private attributes are used" do
Ash.Filter.parse_input!(Profile, bio: "foo")
end
test "parse_input fails when a private attribute is used" do
Ash.Filter.parse!(Profile, private: "private")
assert_raise(Ash.Error.Query.NoSuchAttributeOrRelationship, fn ->
Ash.Filter.parse_input!(Profile, private: "private")
end)
end
end
2020-09-20 07:46:34 +12:00
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
feat: freeform expressions feat: validatiosn in actions feat: query arguments feat: add `Ash.Query.for_read/3` feat: return changeset with API errors feat: add case insensitive string `CiString`/`:ci_string` feat: support `context/1` and `arg/1` in filter templates feat: support targeting notifications with the `for` option feat: add `ago/2` query function feat: add basic arithmetic operators (+, *, -, /) feat: `sensitive?` option for attributes feat: `sensitive?` option for arguments feat: `private` arguments, which can’t be set using `for_<action>` feat: add `prevent_change` which will erase changes just before the changeset is committed feat: add `match?` validation that supports a custom error message feat: add `interval` type to support `ago/2` function feat: add `url_encoded_binary` type feat: add `function` type improvement: `changing?` is now a validation improvement: add `Transformer.get_persisted/3` improvement: add `api` field to `Notification` improvement: standardize errors, add `to_error_class` improvement: use `Comp` everywhere Improvement: use action on changeset if set by `for_<action_type>` improvement: `action_failed?` field on change sets improvement: remove ability for data layers to add operators (for now at least) Improvement: Changeset.apply_attributes/2 now returns an error tuple Improvement: add a bunch of new/informative errors improvement: runtime filter now uses left join logic (a naive implementation of it) improvement: support more filter templates in resources Improvement: basic/naive type system for operators/functions Fix: properly expand module aliases for options w/o compile time dependency chore(engine): track changeset changes for the request with `manage_changeset?: true`
2021-01-22 09:21:58 +13:00
describe "contains/2" do
test "works for simple strings" do
Post
|> new(%{title: "foobar"})
|> Api.create!()
Post
|> new(%{title: "bazbuz"})
|> Api.create!()
assert [%{title: "foobar"}] =
Post
|> Ash.Query.filter(contains(title, "oba"))
|> Api.read!()
end
test "works for simple strings with a case insensitive search term" do
Post
|> new(%{title: "foobar"})
|> Api.create!()
Post
|> new(%{title: "bazbuz"})
|> Api.create!()
assert [%{title: "foobar"}] =
Post
|> Ash.Query.filter(contains(title, ^%Ash.CiString{string: "OBA"}))
|> Api.read!()
end
test "works for case insensitive strings" do
Post
|> new(%{category: "foobar"})
|> Api.create!()
Post
|> new(%{category: "bazbuz"})
|> Api.create!()
assert [%{category: %Ash.CiString{string: "foobar"}}] =
Post
|> Ash.Query.filter(contains(category, "OBA"))
|> Api.read!()
end
end
feat: freeform expressions feat: validatiosn in actions feat: query arguments feat: add `Ash.Query.for_read/3` feat: return changeset with API errors feat: add case insensitive string `CiString`/`:ci_string` feat: support `context/1` and `arg/1` in filter templates feat: support targeting notifications with the `for` option feat: add `ago/2` query function feat: add basic arithmetic operators (+, *, -, /) feat: `sensitive?` option for attributes feat: `sensitive?` option for arguments feat: `private` arguments, which can’t be set using `for_<action>` feat: add `prevent_change` which will erase changes just before the changeset is committed feat: add `match?` validation that supports a custom error message feat: add `interval` type to support `ago/2` function feat: add `url_encoded_binary` type feat: add `function` type improvement: `changing?` is now a validation improvement: add `Transformer.get_persisted/3` improvement: add `api` field to `Notification` improvement: standardize errors, add `to_error_class` improvement: use `Comp` everywhere Improvement: use action on changeset if set by `for_<action_type>` improvement: `action_failed?` field on change sets improvement: remove ability for data layers to add operators (for now at least) Improvement: Changeset.apply_attributes/2 now returns an error tuple Improvement: add a bunch of new/informative errors improvement: runtime filter now uses left join logic (a naive implementation of it) improvement: support more filter templates in resources Improvement: basic/naive type system for operators/functions Fix: properly expand module aliases for options w/o compile time dependency chore(engine): track changeset changes for the request with `manage_changeset?: true`
2021-01-22 09:21:58 +13:00
describe "calls in filters" do
test "calls are evaluated and can be used in predicates" do
post1 =
Post
|> new(%{title: "title1", contents: "contents1", points: 2})
|> Api.create!()
post_id = post1.id
assert [%Post{id: ^post_id}] =
Post
|> Ash.Query.filter(points + 1 == 3)
|> Api.read!()
end
test "function calls are evaluated properly" do
post1 =
Post
|> new(%{title: "title1", approved_at: Timex.shift(Timex.now(), weeks: -1)})
|> Api.create!()
Post
|> new(%{title: "title1", approved_at: Timex.shift(Timex.now(), weeks: -4)})
|> Api.create!()
post_id = post1.id
assert [%Post{id: ^post_id}] =
Post
|> Ash.Query.filter(approved_at > ago(2, :week))
|> Api.read!()
end
end
2019-12-16 13:20:44 +13:00
end