From 64c3c505994da38c7651b964edecdca402106533 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Sat, 5 Sep 2020 23:56:44 -0400 Subject: [PATCH] fix: remove delegate data layer The delegation data layer was the wrong tactic. We should model shared behavior as composition, not inheritance (which is essentially what the delegation data layer turned into) --- .formatter.exs | 3 - documentation/topics/contexts_and_domains.md | 48 +-- lib/ash/data_layer/delegate/delegate.ex | 385 ------------------ .../transformers/ensure_api_compiled.ex | 18 - mix.exs | 2 +- test/ash/data_layer/delegate_test.exs | 297 -------------- 6 files changed, 7 insertions(+), 746 deletions(-) delete mode 100644 lib/ash/data_layer/delegate/delegate.ex delete mode 100644 lib/ash/data_layer/delegate/transformers/ensure_api_compiled.ex delete mode 100644 test/ash/data_layer/delegate_test.exs diff --git a/.formatter.exs b/.formatter.exs index 69ba22a8d..30ef5c22b 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -7,8 +7,6 @@ locals_without_parens = [ argument: 3, attribute: 2, attribute: 3, - authorize?: 1, - base_filter: 1, belongs_to: 2, belongs_to: 3, calculate: 2, @@ -57,7 +55,6 @@ locals_without_parens = [ source_field_on_join_table: 1, table: 1, through: 1, - to: 1, type: 1, update: 1, update: 2, diff --git a/documentation/topics/contexts_and_domains.md b/documentation/topics/contexts_and_domains.md index 8cc260fa8..8c2d513e4 100644 --- a/documentation/topics/contexts_and_domains.md +++ b/documentation/topics/contexts_and_domains.md @@ -4,47 +4,11 @@ It is suggested that you read a bit on Domain Driven Design before proceeding. I In order to support domain driven design, Ash supports defining multiple APIs, each with their own set of resources. It is possible to share a resource between APIs, but this gets untenable very quickly because any resources related to the shared resource must _both_ appear in each API. -To help solve for this you can use the `Ash.DataLayer.Delegate` data layer, which allows you to map a resource from one API(context) to another. A common example used with DDD is the "user" object. In each context, a user may more rightly be called something else. Perhaps there is a portion of your system used for administration, in which context the user will always be an "administrator". This "administrator" has different fields available, -and different relationships. To represent this in Ash, you may have an "administration api". +An experimental "Delegation" data layer was added to allow you to use other resources in other APIs as the data layer for a resource, but it created a significant amount of complexity in determining data layer behavior. Instead, simply use the same data layer and configuration in both resources. -When following this approach, it is advised to use the same file structure that phoenix uses, with the addition of a `resources` folder. For this example, we have +Things missing to make this work well: -- lib/my_app/administration/resources/administrator.ex -> `MyApp.Administration.Administrator` -- lib/my_app/administration/api.ex -> `MyApp.Administration.Api` -- lib/my_app/accounts/resources/user.ex -> `MyApp.Accounts.User` (not included in the example) -- lib/my_app/accounts/api.ex -> `MyApp.Accounts.Api` (not included in the example) - -```elixir -# lib/my_app/administration/api.ex - -defmodule MyApp.Administration.Api do - use Ash.Api - - resources do - resource MyApp.Administration.Administrator - end -end - -# in lib/my_app/administration/resources/administrator.ex - -defmodule MyApp.Administration.Administrator do - use Ash.Resource, - data_layer: Ash.DataLayer.Delegate - - delegate do - # This resource will be backed by calls to the other API and resource - to {MyApp.Accounts.Api, MyApp.Accounts.User} - # When querying this resource, we include a filter by default on all calls - # This lets us say `MyApp.Administration.Api.read(MyApp.Administration.Administrator)` to easily get - # all adminstrators - base_filter [admin: true] - end - - # Define attributes/relationships as usual, using a subset (or all of) the delegated resource's attributes - ... -end -``` - -Now we can add other resources and actions to our administration API, and they can use this more specific/appropriate variation of a user. - -More will be coming on the topic of Domain Driven Design with Ash. Many features that will power it have yet to be written. +- Define the ecto schema as a separate module (prerequisite for hidden attributes) +- "hidden" attributes - attributes that are defined on the schema but not the ash struct +- ability to filter on hidden fields in certain places (haven't determined where this needs to happen) +- ability to add a "base_filter" that can leverage hidden attributes diff --git a/lib/ash/data_layer/delegate/delegate.ex b/lib/ash/data_layer/delegate/delegate.ex deleted file mode 100644 index b94a2fa21..000000000 --- a/lib/ash/data_layer/delegate/delegate.ex +++ /dev/null @@ -1,385 +0,0 @@ -defmodule Ash.DataLayer.Delegate do - @moduledoc """ - A data_layer for adding a resource to one API that simply delegates - to a resource in a different (or the same) API - """ - - @behaviour Ash.DataLayer - - @delegate %Ash.Dsl.Section{ - name: :delegate, - describe: """ - A section for configuring which resource/api is delegated to - """, - schema: [ - to: [ - type: {:custom, __MODULE__, :to_option, []}, - required: true, - doc: "A tuple of {api, resource} to delegate calls to" - ], - base_filter: [ - type: {:custom, __MODULE__, :base_filter_option, []}, - doc: "A filter to apply to queries made against the resource" - ], - authorize?: [ - type: :boolean, - default: false, - doc: - "If `true`, calls to the destination api are authorized according to that resource's rules" - ] - ] - } - - @transformers [Ash.DataLayer.Delegate.Transformers.EnsureApiCompiled] - use Ash.Dsl.Extension, sections: [@delegate], transformers: @transformers - - def get_delegated(resource) do - if Ash.Resource.data_layer(resource) == __MODULE__ do - get_delegated(resource(resource)) - else - resource - end - end - - @doc false - def to_option({api, resource}) when is_atom(api) and is_atom(resource) do - {:ok, {api, resource}} - end - - def to_option(other) do - {:error, "Expected a tuple of {api, resource}, got: #{inspect(other)}"} - end - - @doc false - def base_filter_option(value) do - if Keyword.keyword?(value) do - {:ok, value} - else - {:error, "Expected a keyword for base_filter, got: #{inspect(value)}"} - end - end - - alias Ash.Dsl.Extension - - def resource(resource) do - Extension.get_opt(resource, [:delegate], :to, {nil, nil}) |> elem(1) - end - - def api(resource) do - Extension.get_opt(resource, [:delegate], :to, {nil, nil}) |> elem(0) - end - - def authorize?(resource) do - Extension.get_opt(resource, [:delegate], :authorize?, nil) - end - - def base_filter(resource) do - Extension.get_opt(resource, [:delegate], :base_filter, nil) - end - - defmodule Query do - @moduledoc false - defstruct [:resource, :query, :actor, :authorize?] - end - - @impl true - def can?(resource, feature) do - resource - |> resource() - |> Ash.Resource.data_layer_can?(feature) - end - - @impl true - def resource_to_query(resource) do - %Query{ - resource: resource, - query: %Ash.Query{resource: resource(resource)} - } - end - - @impl true - def limit(%{query: query} = source_query, limit, _) do - {:ok, %{source_query | query: Ash.Query.limit(query, limit)}} - end - - @impl true - def offset(%{query: query} = source_query, offset, _) do - {:ok, %{source_query | query: Ash.Query.offset(query, offset)}} - end - - @impl true - def filter(%{query: query} = source_query, filter, _resource) do - case filter do - %Ash.Filter{} -> - {:ok, - %{ - source_query - | query: Ash.Query.filter(query, filter) - }} - - filter -> - {:ok, - %{ - source_query - | query: Ash.Query.filter(query, filter) - }} - end - end - - @impl true - def sort(%{query: query} = source_query, sort, _resource) do - {:ok, %{source_query | query: Ash.Query.sort(query, sort)}} - end - - @impl true - def transaction(resource, fun) do - Ash.Resource.transaction(resource(resource), fun) - end - - @impl true - def rollback(resource, value) do - Ash.Resource.rollback(resource(resource), value) - end - - @impl true - def in_transaction?(resource) do - Ash.Resource.in_transaction?(resource(resource)) - end - - @impl true - def add_aggregate(%{query: query} = source_query, aggregate, _) do - new_query = %{ - query - | aggregates: Map.put(query.aggregates, aggregate.name, Map.put(aggregate, :load, nil)) - } - - {:ok, - %{ - source_query - | query: new_query - }} - end - - @impl true - def set_context(_resource, query, map) do - %{query | authorize?: Map.get(map, :authorize?, false), actor: Map.get(map, :author)} - end - - @impl true - def run_query( - %Query{resource: resource, query: query, authorize?: authorize?, actor: actor}, - _resource - ) do - api = api(resource) - - query = - if base_filter(resource) do - filter = Ash.Filter.parse!(resource(resource), base_filter(resource)) - Ash.Query.filter(query, filter) - else - query - end - - if authorize?(resource) && authorize? do - query - |> Ash.Query.unset([:side_load]) - |> api.read(actor: actor, authorize?: true) - else - query - |> Ash.Query.unset([:side_load]) - |> api.read() - end - |> transform_results(resource) - end - - @impl true - def run_query_with_lateral_join( - query, - root_data, - source_resource, - destination_resource, - source, - destination - ) do - Ash.DataLayer.run_query_with_lateral_join( - query, - root_data, - resource(source_resource), - destination_resource, - source, - destination - ) - end - - defp transform_results({:ok, results}, resource) do - keys = - Enum.map(Ash.Resource.attributes(resource), & &1.name) ++ - Enum.map(Ash.Resource.relationships(resource), & &1.name) ++ - [:aggregates] - - with_attrs_and_rels = - Enum.map(results, fn result -> - struct(resource, Map.take(result, keys)) - end) - - aggregate_names = Enum.map(Ash.Resource.aggregates(resource), & &1.name) - - with_lifted_aggregates = - Enum.map(with_attrs_and_rels, fn record -> - Map.merge(record, Map.take(record.aggregates, aggregate_names)) - end) - - {:ok, with_lifted_aggregates} - end - - defp transform_results({:error, error}, _) do - {:error, error} - end - - @impl true - def upsert(resource, source_changeset) do - destination_resource = resource(resource) - changeset = translate_changeset(destination_resource.__struct__, source_changeset) - - if authorize?(resource) && changeset_authorize?(changeset) do - api(resource).create(changeset, upsert?: true, actor: actor(changeset), authorize?: true) - else - api(resource).create(changeset, upsert?: true) - end - |> case do - {:ok, upserted} -> - keys = - Enum.map(Ash.Resource.attributes(resource), & &1.name) ++ - Enum.map(Ash.Resource.relationships(resource), & &1.name) - - {:ok, struct(resource, Map.take(upserted, keys))} - - {:error, error} -> - {:error, error} - end - end - - @impl true - def create(resource, source_changeset) do - destination_resource = resource(resource) - changeset = translate_changeset(destination_resource.__struct__, source_changeset) - - if authorize?(resource) && changeset_authorize?(changeset) do - api(resource).create(changeset, actor: actor(changeset), authorize?: true) - else - api(resource).create(changeset) - end - |> case do - {:ok, created} -> - keys = - Enum.map(Ash.Resource.attributes(resource), & &1.name) ++ - Enum.map(Ash.Resource.relationships(resource), & &1.name) - - {:ok, struct(resource, Map.take(created, keys))} - - {:error, error} -> - {:error, error} - end - end - - @impl true - def destroy(resource, %{data: %resource{} = record} = changeset) do - destination_api = api(resource) - pkey = Ash.Resource.primary_key(resource) - pkey_value = Map.to_list(Map.take(record, pkey)) - - case destination_api.get(resource(resource), pkey_value) do - {:ok, nil} -> - {:error, "Delegated resource not found"} - - {:error, error} -> - {:error, error} - - {:ok, to_destroy} -> - if authorize?(resource) && changeset_authorize?(changeset) do - api(resource).destroy(Ash.Changeset.new(to_destroy), - actor: actor(changeset), - authorize?: true - ) - else - api(resource).destroy(Ash.Changeset.new(to_destroy)) - end - end - end - - @impl true - def update(resource, source_changeset) do - destination_api = api(resource) - pkey = Ash.Resource.primary_key(resource) - pkey_value = Map.to_list(Map.take(source_changeset.data, pkey)) - - case destination_api.get(resource(resource), pkey_value) do - {:ok, nil} -> - {:error, "Delegated resource not found"} - - {:error, error} -> - {:error, error} - - {:ok, to_update} -> - changeset = translate_changeset(to_update, source_changeset) - - case api(resource).update(changeset) do - {:ok, updated} -> - keys = - Enum.map(Ash.Resource.attributes(resource), & &1.name) ++ - Enum.map(Ash.Resource.relationships(resource), & &1.name) - - {:ok, struct(resource, Map.take(updated, keys))} - - {:error, error} -> - {:error, error} - end - end - end - - defp changeset_authorize?(%{context: %{authorize?: true}}), do: true - defp changeset_authorize?(_), do: false - - defp actor(%{context: %{actor: actor}}), do: actor - defp actor(_), do: nil - - defp translate_changeset(data, source_changeset) do - changeset = Ash.Changeset.new(data) - - changeset = - Enum.reduce(source_changeset.attributes, changeset, fn {attr, change}, changeset -> - Ash.Changeset.change_attribute(changeset, attr, change) - end) - - Enum.reduce(source_changeset.relationships, changeset, fn {rel, change}, changeset -> - relationship = Ash.Resource.relationship(changeset.source, rel) - - Enum.reduce(change, changeset, fn - {:add, to_add}, changeset -> - Ash.Changeset.append_to_relationship( - changeset, - relationship.name, - List.wrap(to_add) - ) - - {:remove, to_remove}, changeset -> - Ash.Changeset.remove_from_relationship(changeset, relationship.name, to_remove) - - {:replace, to_replace}, changeset -> - do_translate_replace(relationship, changeset, to_replace) - end) - end) - end - - defp do_translate_replace(relationship, changeset, to_replace) do - if relationship.cardinality == :one do - Ash.Changeset.replace_relationship( - changeset, - relationship.name, - List.wrap(to_replace) - ) - else - Ash.Changeset.replace_relationship(changeset, relationship.name, to_replace) - end - end -end diff --git a/lib/ash/data_layer/delegate/transformers/ensure_api_compiled.ex b/lib/ash/data_layer/delegate/transformers/ensure_api_compiled.ex deleted file mode 100644 index c8114ef05..000000000 --- a/lib/ash/data_layer/delegate/transformers/ensure_api_compiled.ex +++ /dev/null @@ -1,18 +0,0 @@ -defmodule Ash.DataLayer.Delegate.Transformers.EnsureApiCompiled do - @moduledoc "Validates and caches the primary key of a resource" - use Ash.Dsl.Transformer - - alias Ash.DataLayer.Delegate - alias Ash.Resouce.Transformers - - def transform(resource, dsl_state) do - resource - |> Delegate.api() - |> Code.ensure_compiled() - - {:ok, dsl_state} - end - - def before?(Transformers.ValidateActionTypesSupported), do: true - def before?(_), do: false -end diff --git a/mix.exs b/mix.exs index 60df6fda3..0ab45e64d 100644 --- a/mix.exs +++ b/mix.exs @@ -122,7 +122,7 @@ defmodule Ash.MixProject do sobelow: "sobelow --skip", credo: "credo --strict", "ash.formatter": - "ash.formatter --extensions Ash.Resource.Dsl,Ash.Api.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia,Ash.DataLayer.Delegate" + "ash.formatter --extensions Ash.Resource.Dsl,Ash.Api.Dsl,Ash.DataLayer.Ets,Ash.DataLayer.Mnesia" ] end end diff --git a/test/ash/data_layer/delegate_test.exs b/test/ash/data_layer/delegate_test.exs deleted file mode 100644 index 344a11271..000000000 --- a/test/ash/data_layer/delegate_test.exs +++ /dev/null @@ -1,297 +0,0 @@ -defmodule Ash.DataLayer.DelegateTest do - use ExUnit.Case, async: false - - alias Ash.DataLayer.Delegate, as: DelegateDataLayer - alias Ash.DataLayer.Delegate.Query - alias Ash.Filter.Predicate.{Eq, GreaterThan, In, LessThan} - - defmodule DelegateResource do - use Ash.Resource, data_layer: Ash.DataLayer.Ets - - ets do - private?(true) - end - - actions do - read(:default) - create(:default) - update(:default) - destroy(:default) - end - - attributes do - attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0 - attribute :name, :string - attribute :age, :integer - attribute :title, :string - end - end - - setup do - on_exit(fn -> - case ETS.Set.wrap_existing(DelegateResource) do - {:error, :table_not_found} -> :ok - {:ok, set} -> ETS.Set.delete_all!(set) - end - end) - end - - defmodule DelegateApi do - use Ash.Api - - resources do - resource DelegateResource - end - end - - defmodule EtsTestUser do - use Ash.Resource, data_layer: Ash.DataLayer.Delegate - - delegate do - to {DelegateApi, DelegateResource} - end - - actions do - read(:default) - create(:default) - update(:default) - destroy(:default) - end - - attributes do - attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0 - attribute :name, :string - attribute :age, :integer - attribute :title, :string - end - end - - defmodule EtsApiTest do - use Ash.Api - - resources do - resource EtsTestUser - end - end - - test "can?" do - assert DelegateDataLayer.can?(EtsTestUser, :async_engine) == false - assert DelegateDataLayer.can?(EtsTestUser, :composite_primary_key) == true - assert DelegateDataLayer.can?(EtsTestUser, :upsert) == true - assert DelegateDataLayer.can?(EtsTestUser, :boolean_filter) == true - assert DelegateDataLayer.can?(EtsTestUser, :transact) == false - assert DelegateDataLayer.can?(EtsTestUser, :create) == true - assert DelegateDataLayer.can?(EtsTestUser, :read) == true - assert DelegateDataLayer.can?(EtsTestUser, :update) == true - assert DelegateDataLayer.can?(EtsTestUser, :destroy) == true - assert DelegateDataLayer.can?(EtsTestUser, :sort) == true - assert DelegateDataLayer.can?(EtsTestUser, :filter) == true - assert DelegateDataLayer.can?(EtsTestUser, :limit) == true - assert DelegateDataLayer.can?(EtsTestUser, :offset) == true - assert DelegateDataLayer.can?(EtsTestUser, {:filter_predicate, :foo, %In{}}) == true - assert DelegateDataLayer.can?(EtsTestUser, {:filter_predicate, :foo, %Eq{}}) == true - assert DelegateDataLayer.can?(EtsTestUser, {:filter_predicate, :foo, %LessThan{}}) == true - assert DelegateDataLayer.can?(EtsTestUser, {:filter_predicate, :foo, %GreaterThan{}}) == true - assert DelegateDataLayer.can?(EtsTestUser, {:sort, :foo}) == true - assert DelegateDataLayer.can?(EtsTestUser, :foo) == false - end - - test "resource_to_query" do - assert %Query{resource: EtsTestUser} = DelegateDataLayer.resource_to_query(EtsTestUser) - end - - test "limit, offset, filter, sortm, aggregate" do - query = DelegateDataLayer.resource_to_query(EtsTestUser) - assert {:ok, %Query{query: %{limit: 3}}} = DelegateDataLayer.limit(query, 3, :foo) - assert {:ok, %Query{query: %{offset: 10}}} = DelegateDataLayer.offset(query, 10, :foo) - {:ok, parsed_filter} = Ash.Filter.parse(DelegateResource, true) - - assert {:ok, %Query{query: %{filter: ^parsed_filter}}} = - DelegateDataLayer.filter(query, true, :foo) - - assert {:ok, %Query{query: %{sort: [name: :asc]}}} = - DelegateDataLayer.sort(query, :name, :foo) - - # Can't test delegating to aggregates - # {:ok, aggregate} = Ash.Query.Aggregate.new(DelegateDataLayer, :foo, :count, :foobars, nil) - - # assert {:ok, %Query{query: %{aggregates: [^aggregate]}}} = - # DelegateDataLayer.add_aggregate(query, aggregate, :bar) - end - - test "create" do - assert %EtsTestUser{id: id, name: "Mike"} = create_user(%{name: "Mike"}) - - assert [{%{id: ^id}, %DelegateResource{name: "Mike", id: ^id}}] = user_table() - end - - test "update" do - %EtsTestUser{id: id} = user = create_user(%{name: "Mike"}) - - assert [{%{id: ^id}, %DelegateResource{id: ^id, name: "Mike"}}] = user_table() - - user - |> Ash.Changeset.new(name: "Joe") - |> EtsApiTest.update!() - - assert [{%{id: ^id}, %DelegateResource{name: "Joe", id: ^id}}] = user_table() - end - - test "upsert" do - %EtsTestUser{id: id} = create_user(%{name: "Mike"}, upsert?: true) - - assert [{%{id: ^id}, %DelegateResource{id: ^id, name: "Mike"}}] = user_table() - - create_user(%{name: "Joe", id: id}, upsert?: true) - - assert [{%{id: ^id}, %DelegateResource{name: "Joe", id: ^id}}] = user_table() - end - - test "destroy" do - mike = create_user(%{name: "Mike"}) - %EtsTestUser{id: joes_id} = create_user(%{name: "Joe"}) - stored = DelegateApi.get!(DelegateResource, id: joes_id) - - assert length(user_table()) == 2 - - EtsApiTest.destroy!(mike) - - assert [{%{id: ^joes_id}, ^stored}] = user_table() - end - - test "get" do - create_user(%{name: "Mike"}) - create_user(%{name: "Joe"}) - %{id: id} = create_user(%{name: "Matthew"}) - create_user(%{name: "Zachary"}) - - assert %EtsTestUser{id: ^id, name: "Matthew"} = EtsApiTest.get!(EtsTestUser, id) - end - - test "sort" do - mike = create_user(%{name: "Mike"}) - joe = create_user(%{name: "Joe"}) - matthew = create_user(%{name: "Matthew"}) - zachary = create_user(%{name: "Zachary"}) - - query = - EtsTestUser - |> Ash.Query.new() - |> Ash.Query.sort(:name) - - assert [^joe, ^matthew, ^mike, ^zachary] = EtsApiTest.read!(query) - end - - test "limit" do - _mike = create_user(%{name: "Mike"}) - joe = create_user(%{name: "Joe"}) - matthew = create_user(%{name: "Matthew"}) - _zachary = create_user(%{name: "Zachary"}) - - query = - EtsTestUser - |> Ash.Query.new() - |> Ash.Query.sort(:name) - |> Ash.Query.limit(2) - - assert [^joe, ^matthew] = EtsApiTest.read!(query) - end - - test "offset" do - mike = create_user(%{name: "Mike"}) - _joe = create_user(%{name: "Joe"}) - matthew = create_user(%{name: "Matthew"}) - zachary = create_user(%{name: "Zachary"}) - - query = - EtsTestUser - |> Ash.Query.new() - |> Ash.Query.sort(:name) - |> Ash.Query.offset(1) - - assert [^matthew, ^mike, ^zachary] = EtsApiTest.read!(query) - end - - describe "filter" do - setup do - mike = create_user(%{name: "Mike", age: 37, title: "Dad"}) - joe = create_user(%{name: "Joe", age: 11}) - matthew = create_user(%{name: "Matthew", age: 9}) - zachary = create_user(%{name: "Zachary", age: 6}) - %{mike: mike, zachary: zachary, matthew: matthew, joe: joe} - end - - test "values", %{zachary: zachary, matthew: matthew, joe: joe} do - assert [^zachary] = filter_users(name: "Zachary") - assert [^joe] = filter_users(name: "Joe") - assert [^matthew] = filter_users(age: 9) - end - - test "or, in, eq", %{mike: mike, zachary: zachary, joe: joe} do - assert [^joe, ^mike, ^zachary] = - filter_users( - or: [ - [name: [in: ["Zachary", "Mike"]]], - [age: [eq: 11]] - ] - ) - end - - test "and, in, eq", %{mike: mike} do - assert [^mike] = - filter_users( - and: [ - [name: [in: ["Zachary", "Mike"]]], - [age: [eq: 37]] - ] - ) - end - - test "and, in, not", %{zachary: zachary} do - assert [^zachary] = - filter_users( - and: [ - [name: [in: ["Zachary", "Mike"]]], - [not: [age: 37]] - ] - ) - end - - test "gt", %{mike: mike, joe: joe} do - assert [^joe, ^mike] = filter_users(age: [gt: 10]) - end - - test "lt", %{zachary: zachary, matthew: matthew} do - assert [^matthew, ^zachary] = filter_users(age: [lt: 10]) - end - - test "boolean", %{zachary: zachary, matthew: matthew} do - assert [^matthew, ^zachary] = filter_users(and: [true, age: [lt: 10]]) - end - - test "is_nil", %{zachary: zachary, matthew: matthew, joe: joe} do - assert [^joe, ^matthew, ^zachary] = filter_users(is_nil: :title) - assert [^joe, ^matthew, ^zachary] = filter_users(title: [is_nil: true]) - end - end - - defp filter_users(filter) do - EtsTestUser - |> Ash.Query.new() - |> Ash.Query.sort(:name) - |> Ash.Query.filter(filter) - |> EtsApiTest.read!() - end - - defp create_user(attrs, opts \\ []) do - %EtsTestUser{} - |> Ash.Changeset.new(attrs) - |> EtsApiTest.create!(opts) - end - - defp user_table do - DelegateResource - |> ETS.Set.wrap_existing!() - |> ETS.Set.to_list!() - end -end