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