diff --git a/README.md b/README.md index e513c9646..7d2a6d1c1 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,10 @@ -![Test Image 6](https://github.com/ash-project/ash/blob/master/logos/cropped-for-header.png) +![Logo](https://github.com/ash-project/ash/blob/master/logos/cropped-for-header.png) ![Elixir CI](https://github.com/ash-project/ash/workflows/Elixir%20CI/badge.svg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Coverage Status](https://coveralls.io/repos/github/ash-project/ash/badge.svg?branch=master)](https://coveralls.io/github/ash-project/ash?branch=master) [![Hex version badge](https://img.shields.io/hexpm/v/ash.svg)](https://hex.pm/packages/ash) -## Quick Links - -- [Resource Documentation](https://hexdocs.pm/ash/Ash.Resource.html) -- [DSL Documentation](https://hexdocs.pm/ash/Ash.Resource.DSL.html) -- [Code API documentation](https://hexdocs.pm/ash/Ash.Api.Interface.html) - ## Introduction Traditional MVC Frameworks (Rails, Django, .Net, Phoenix, etc) leave it up to the user to build the glue between requests for data (HTTP requests in various forms as well as server-side domain logic) and their respective ORMs. In that space, there is an incredible amount of boilerplate code that must get written from scratch for each application (authentication, authorization, sorting, filtering, sideloading relationships, serialization, etc). diff --git a/documentation/Getting Started.md b/documentation/Getting Started.md new file mode 100644 index 000000000..e69de29bb diff --git a/lib/ash.ex b/lib/ash.ex index 7af91919a..c060fb1d3 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -1,9 +1,53 @@ defmodule Ash do @moduledoc """ - The primary interface for interrogating apis and resources. - These are tools for interrogating resources to derive behavior based on their - configuration. This is how all of the behavior of Ash is ultimately configured. + ![Logo](https://github.com/ash-project/ash/blob/master/logos/cropped-for-header.png?raw=true) + + ## Quick Links + + - [Resource Documentation](Ash.Resource.html) + - [DSL Documentation](Ash.Dsl.html) + - [Code API documentation](Ash.Api.Interface.html) + + ## Introduction + + Traditional MVC Frameworks (Rails, Django, .Net, Phoenix, etc) leave it up to the user to build the glue between requests for data (HTTP requests in various forms as well as server-side domain logic) and their respective ORMs. In that space, there is an incredible amount of boilerplate code that must get written from scratch for each application (authentication, authorization, sorting, filtering, sideloading relationships, serialization, etc). + + Ash is an opinionated yet configurable framework designed to reduce boilerplate in an Elixir application. Ash does this by providing a layer of abstraction over your system's data layer(s) with `Resources`. It is designed to be used in conjunction with a phoenix application, or on its own. + + To riff on a famous JRR Tolkien quote, a `Resource`is "One Interface to rule them all, One Interface to find them" and will become an indispensable place to define contracts for interacting with data throughout your application. + + To start using Ash, first declare your `Resources` using the Ash `Resource` DSL. You could technically stop there, and just leverage the Ash Elixir API to avoid writing boilerplate. More likely, you would use extensions like Ash.JsonApi or Ash.GraphQL with Phoenix to add external interfaces to those resources without having to write any extra code at all. + + Ash is an open-source project and draws inspiration from similar ideas in other frameworks and concepts. The goal of Ash is to lower the barrier to adopting and using Elixir and Phoenix, and in doing so help these amazing communities attract new developers, projects, and companies. + + ## Example Resource + + ```elixir + defmodule Post do + use Ash.Resource + + actions do + read :default + + create :default + end + + attributes do + attribute :name, :string + end + + relationships do + belongs_to :author, Author + end + end + ``` + + For those looking to add ash extensions, see `Ash.Dsl.Extension` for adding configuration. + If you are looking to write a new data source, also see the `Ash.DataLayer` documentation. + If you are looking to write a new authorizer, see `Ash.Authorizer` + If you are looking to write a "front end", something powered by Ash resources, a guide on + building those kinds of tools is in the works. """ alias Ash.Resource.Actions.{Create, Destroy, Read, Update} alias Ash.Resource.Relationships.{BelongsTo, HasMany, HasOne, ManyToMany} @@ -22,49 +66,42 @@ defmodule Ash do @type params :: Keyword.t() @type sort :: Keyword.t() @type side_loads :: Keyword.t() - @type attribute :: Ash.Resource.Attributes.Attribute.t() + @type attribute :: Ash.Resource.Attribute.t() @type action :: Create.t() | Read.t() | Update.t() | Destroy.t() @type query :: Ash.Query.t() @type actor :: Ash.record() - @doc "A short description of the resource, to be included in autogenerated documentation" - @spec describe(resource()) :: String.t() - def describe(resource) do - resource.describe() - end + require Ash.Dsl.Extension + alias Ash.Dsl.Extension @doc "A list of authorizers to be used when accessing the resource" @spec authorizers(resource()) :: [module] def authorizers(resource) do - resource.authorizers() - end - - @doc "A list of resource modules for a given API" - @spec resources(api) :: list(resource()) - def resources(api) do - api.resources() + :persistent_term.get({resource, :authorizers}, []) end @doc "A list of field names corresponding to the primary key of a resource" @spec primary_key(resource()) :: list(atom) def primary_key(resource) do - resource.primary_key() + :persistent_term.get({resource, :primary_key}, []) + end + + def relationships(resource) do + Extension.get_entities(resource, [:relationships]) end @doc "Gets a relationship by name from the resource" @spec relationship(resource(), atom() | String.t()) :: relationship() | nil def relationship(resource, relationship_name) when is_bitstring(relationship_name) do - Enum.find(resource.relationships(), &(to_string(&1.name) == relationship_name)) + resource + |> relationships() + |> Enum.find(&(to_string(&1.name) == relationship_name)) end def relationship(resource, relationship_name) do - Enum.find(resource.relationships(), &(&1.name == relationship_name)) - end - - @doc "A list of relationships on the resource" - @spec relationships(resource()) :: list(relationship()) - def relationships(resource) do - resource.relationships() + resource + |> relationships() + |> Enum.find(&(&1.name == relationship_name)) end @spec resource_module?(module) :: boolean @@ -95,38 +132,44 @@ defmodule Ash do end end + def actions(resource) do + Extension.get_entities(resource, [:actions]) + end + @doc "Returns the action with the matching name and type on the resource" @spec action(resource(), atom(), atom()) :: action() | nil def action(resource, name, type) do - Enum.find(resource.actions(), &(&1.name == name && &1.type == type)) + resource + |> actions() + |> Enum.find(&(&1.name == name && &1.type == type)) end - @doc "A list of all actions on the resource" - @spec actions(resource()) :: list(action()) - def actions(resource) do - resource.actions() + def attributes(resource) do + Extension.get_entities(resource, [:attributes]) + end + + def extensions(resource) do + :persistent_term.get({resource, :extensions}, []) end @doc "Get an attribute name from the resource" @spec attribute(resource(), String.t() | atom) :: attribute() | nil def attribute(resource, name) when is_bitstring(name) do - Enum.find(resource.attributes, &(to_string(&1.name) == name)) + resource + |> attributes() + |> Enum.find(&(to_string(&1.name) == name)) end def attribute(resource, name) do - Enum.find(resource.attributes, &(&1.name == name)) - end - - @doc "A list of all attributes on the resource" - @spec attributes(resource()) :: list(attribute()) - def attributes(resource) do - resource.attributes() + resource + |> attributes() + |> Enum.find(&(&1.name == name)) end @doc "The data layer of the resource, or nil if it does not have one" @spec data_layer(resource()) :: data_layer() def data_layer(resource) do - resource.data_layer() + :persistent_term.get({resource, :data_layer}) end @doc false diff --git a/lib/ash/api/api.ex b/lib/ash/api/api.ex index aeba3bfff..e64de621e 100644 --- a/lib/ash/api/api.ex +++ b/lib/ash/api/api.ex @@ -1,6 +1,6 @@ defmodule Ash.Api do @moduledoc """ - An Api allows you to interact with your resources, anc holds non-resource-specific configuration. + An Api allows you to interact with your resources, and holds non-resource-specific configuration. Your Api can also house config that is not resource specific. Defining a resource won't do much for you. Once you have some resources defined, @@ -10,7 +10,9 @@ defmodule Ash.Api do defmodule MyApp.Api do use Ash.Api - resources [OneResource, SecondResource] + resources do + resource OneResource + resource SecondResource end ``` @@ -19,55 +21,475 @@ defmodule Ash.Api do `MyApp.Api.read(query)`. Corresponding actions must be defined in your resources in order to call them through the Api. """ - defmacro __using__(_) do - quote do - @before_compile Ash.Api - @side_load_type :simple + import Ash.OptionsHelpers, only: [merge_schemas: 3] - Module.register_attribute(__MODULE__, :extensions, accumulate: true) - Module.register_attribute(__MODULE__, :resources, accumulate: true) - Module.register_attribute(__MODULE__, :named_resources, accumulate: true) + alias Ash.Actions.{Create, Destroy, Read, SideLoad, Update} + alias Ash.Error.NoSuchResource - import Ash.Api, - only: [ - resources: 1 - ] + @global_opts [ + verbose?: [ + type: :boolean, + default: false, + doc: "Log engine operations (very verbose?)" + ], + action: [ + type: :any, + doc: "The action to use, either an Action struct or the name of the action" + ], + authorize?: [ + type: :boolean, + default: false, + doc: + "If an actor is provided, authorization happens automatically. If not, this flag can be used to authorize with no user." + ], + actor: [ + type: :any, + doc: + "If an actor is provided, it will be used in conjunction with the authorizers of a resource to authorize access" + ] + ] + + @read_opts_schema merge_schemas([], @global_opts, "Global Options") + + @doc false + def read_opts_schema, do: @read_opts_schema + + @side_load_opts_schema merge_schemas([], @global_opts, "Global Options") + + @get_opts_schema [ + side_load: [ + type: :any, + doc: + "Side loads to include in the query, same as you would pass to `Ash.Query.side_load/2`" + ] + ] + |> merge_schemas(@global_opts, "Global Options") + + @shared_create_and_update_opts_schema [ + attributes: [ + type: {:custom, Ash.OptionsHelpers, :map, []}, + default: %{}, + doc: "Changes to be applied to attribute values" + ], + relationships: [ + type: {:custom, Ash.OptionsHelpers, :map, []}, + default: %{}, + doc: "Changes to be applied to relationship values" + ] + ] + |> merge_schemas(@global_opts, "Global Options") + + @create_opts_schema [ + upsert?: [ + type: :boolean, + default: false, + doc: + "If a conflict is found based on the primary key, the record is updated in the database (requires upsert support)" + ] + ] + |> merge_schemas(@global_opts, "Global Options") + |> merge_schemas( + @shared_create_and_update_opts_schema, + "Shared Create/Edit Options" + ) + + @update_opts_schema [] + |> merge_schemas(@global_opts, "Global Options") + |> merge_schemas( + @shared_create_and_update_opts_schema, + "Shared Create/Edit Options" + ) + + @destroy_opts_schema merge_schemas([], @global_opts, "Global Opts") + + @doc """ + Get a record by a primary key + + #{NimbleOptions.docs(@get_opts_schema)} + """ + @callback get!(resource :: Ash.resource(), id_or_filter :: term(), params :: Keyword.t()) :: + Ash.record() | no_return + + @doc """ + Get a record by a primary key + + #{NimbleOptions.docs(@get_opts_schema)} + """ + @callback get(resource :: Ash.resource(), id_or_filter :: term(), params :: Keyword.t()) :: + {:ok, Ash.record()} | {:error, Ash.error()} + + @doc """ + Run a query on a resource + + #{NimbleOptions.docs(@read_opts_schema)} + """ + @callback read!(Ash.query(), params :: Keyword.t()) :: + list(Ash.resource()) | no_return + + @doc """ + Run a query on a resource + + #{NimbleOptions.docs(@read_opts_schema)} + """ + @callback read(Ash.query(), params :: Keyword.t()) :: + {:ok, list(Ash.resource())} | {:error, Ash.error()} + + @doc """ + Side load on already fetched records + + #{NimbleOptions.docs(@side_load_opts_schema)} + """ + @callback side_load!(resource :: Ash.resource(), params :: Keyword.t()) :: + list(Ash.resource()) | no_return + + @doc """ + Side load on already fetched records + + #{NimbleOptions.docs(@side_load_opts_schema)} + """ + @callback side_load(resource :: Ash.resource(), params :: Keyword.t()) :: + {:ok, list(Ash.resource())} | {:error, Ash.error()} + + @doc """ + Create a record + + #{NimbleOptions.docs(@create_opts_schema)} + """ + @callback create!(resource :: Ash.resource(), params :: Keyword.t()) :: + Ash.record() | no_return + + @doc """ + Create a record + + #{NimbleOptions.docs(@create_opts_schema)} + """ + @callback create(resource :: Ash.resource(), params :: Keyword.t()) :: + {:ok, Ash.record()} | {:error, Ash.error()} + + @doc """ + Update a record + + #{NimbleOptions.docs(@update_opts_schema)} + """ + @callback update!(record :: Ash.record(), params :: Keyword.t()) :: + Ash.record() | no_return + + @doc """ + Update a record + + #{NimbleOptions.docs(@update_opts_schema)} + """ + @callback update(record :: Ash.record(), params :: Keyword.t()) :: + {:ok, Ash.record()} | {:error, Ash.error()} + + @doc """ + Destroy a record + + #{NimbleOptions.docs(@destroy_opts_schema)} + """ + @callback destroy!(record :: Ash.record(), params :: Keyword.t()) :: :ok | no_return + + @doc """ + Destroy a record + + #{NimbleOptions.docs(@destroy_opts_schema)} + """ + @callback destroy(record :: Ash.record(), params :: Keyword.t()) :: + :ok | {:error, Ash.error()} + + @doc """ + Refetches a record from the database, raising on error. + + See `reload/1`. + """ + @callback reload!(record :: Ash.record(), params :: Keyword.t()) :: Ash.record() | no_return + + @doc """ + Refetches a record from the database + """ + @callback reload(record :: Ash.record()) :: {:ok, Ash.record()} | {:error, Ash.error()} + + alias Ash.Dsl.Extension + + defmacro __using__(opts) do + extensions = [Ash.Api.Dsl | opts[:extensions] || []] + + body = + quote do + @before_compile Ash.Api + @behaviour Ash.Api + @on_load :build_dsl + end + + preparations = Extension.prepare(extensions) + + [body | preparations] + end + + defmacro __before_compile__(_env) do + quote generated: true do + alias Ash.Dsl.Extension + + Extension.set_state(false) + + def raw_dsl do + @ash_dsl_config + end + + def build_dsl do + Extension.set_state(true) + + :ok + end + + use Ash.Api.Interface + end + end + + alias Ash.Dsl.Extension + + @spec resource(Ash.api(), Ash.resource()) :: {:ok, Ash.resource()} | :error + def resource(api, resource) do + api + |> resources() + |> Enum.find(&(&1 == resource)) + |> case do + nil -> :error + resource -> {:ok, resource} end end - defmacro resources(resources) do - quote do - Enum.map(unquote(resources), fn resource -> - case resource do - {name, resource} -> - @resources resource - @named_resources {name, resource} + @spec resources(Ash.api()) :: [Ash.resource()] + def resources(api) do + api + |> Extension.get_entities([:resources]) + |> Enum.map(& &1.resource) + end + + @doc false + @spec get!(Ash.api(), Ash.resource(), term(), Keyword.t()) :: Ash.record() | no_return + def get!(api, resource, id, opts \\ []) do + opts = NimbleOptions.validate!(opts, @get_opts_schema) + + api + |> get(resource, id, opts) + |> unwrap_or_raise!() + end - resource -> - @resources resource + @doc false + @spec get(Ash.api(), Ash.resource(), term(), Keyword.t()) :: + {:ok, Ash.record()} | {:error, Ash.error()} + def get(api, resource, id, opts) do + with {:ok, opts} <- NimbleOptions.validate(opts, @get_opts_schema), + {:resource, {:ok, resource}} <- {:resource, Ash.Api.resource(api, resource)}, + {:pkey, primary_key} when primary_key != [] <- {:pkey, Ash.primary_key(resource)}, + {:ok, filter} <- get_filter(primary_key, id) do + resource + |> api.query() + |> Ash.Query.filter(filter) + |> Ash.Query.side_load(opts[:side_load] || []) + |> api.read(Keyword.delete(opts, :side_load)) + |> case do + {:ok, [single_result]} -> + {:ok, single_result} + + {:ok, []} -> + {:ok, nil} + + {:error, error} -> + {:error, error} + + {:ok, results} when is_list(results) -> + {:error, :too_many_results} + end + else + {:error, error} -> + {:error, error} + + {:resource, :error} -> + {:error, NoSuchResource.exception(resource: resource)} + + {:pkey, _} -> + {:error, "Resource has no primary key"} + end + end + + defp get_filter(primary_key, id) do + case {primary_key, id} do + {[field], [{field, value}]} -> + {:ok, [{field, value}]} + + {[field], value} -> + {:ok, [{field, value}]} + + {fields, value} -> + if Keyword.keyword?(value) and Enum.sort(Keyword.keys(value)) == Enum.sort(fields) do + {:ok, value} + else + {:error, "invalid primary key provided to `get/3`"} end - end) end end - defmacro __before_compile__(env) do - quote generated: true do - def extensions, do: @extensions - def resources, do: @resources + @doc false + @spec side_load!( + Ash.api(), + Ash.record() | list(Ash.record()), + Ash.query() | list(atom | {atom, list()}), + Keyword.t() + ) :: + list(Ash.record()) | Ash.record() | no_return + def side_load!(api, data, query, opts \\ []) do + opts = NimbleOptions.validate!(opts, @side_load_opts_schema) - def get_resource(mod) when mod in @resources, do: {:ok, mod} + api + |> side_load(data, query, opts) + |> unwrap_or_raise!() + end + + @doc false + @spec side_load(Ash.api(), Ash.query(), Keyword.t()) :: + {:ok, list(Ash.resource())} | {:error, Ash.error()} + def side_load(api, data, query, opts \\ []) + def side_load(_, [], _, _), do: {:ok, []} + def side_load(_, nil, _, _), do: {:ok, nil} - def get_resource(name) do - Keyword.fetch(@named_resources, name) + def side_load(api, data, query, opts) when not is_list(data) do + api + |> side_load(List.wrap(data), query, opts) + |> case do + {:ok, [data]} -> {:ok, data} + {:error, error} -> {:error, error} + end + end + + def side_load(api, [%resource{} | _] = data, query, opts) do + query = + case query do + %Ash.Query{} = query -> + query + + keyword -> + resource + |> api.query() + |> Ash.Query.side_load(keyword) end - use Ash.Api.Interface + with %{valid?: true} <- query, + {:ok, opts} <- NimbleOptions.validate(opts, @side_load_opts_schema) do + SideLoad.side_load(data, query, opts) + else + {:error, error} -> + {:error, error} - Enum.map(@extensions, fn hook_module -> - code = hook_module.before_compile_hook(unquote(Macro.escape(env))) - Module.eval_quoted(__MODULE__, code) - end) + %{errors: errors} -> + {:error, errors} end end + + @doc false + @spec read!(Ash.api(), Ash.query(), Keyword.t()) :: + list(Ash.record()) | no_return + def read!(api, query, opts \\ []) do + opts = NimbleOptions.validate!(opts, @read_opts_schema) + + api + |> read(query, opts) + |> unwrap_or_raise!() + end + + @doc false + @spec read(Ash.api(), Ash.query(), Keyword.t()) :: + {:ok, list(Ash.resource())} | {:error, Ash.error()} + def read(_api, query, opts \\ []) do + with {:ok, opts} <- NimbleOptions.validate(opts, @read_opts_schema), + {:ok, action} <- get_action(query.resource, opts, :read) do + Read.run(query, action, opts) + else + {:error, error} -> + {:error, error} + end + end + + @doc false + @spec create!(Ash.api(), Ash.resource(), Keyword.t()) :: + Ash.record() | {:error, Ash.error()} + def create!(api, resource, opts) do + opts = NimbleOptions.validate!(opts, @create_opts_schema) + + api + |> create(resource, opts) + |> unwrap_or_raise!() + end + + @doc false + @spec create(Ash.api(), Ash.resource(), Keyword.t()) :: + {:ok, Ash.resource()} | {:error, Ash.error()} + def create(api, resource, opts) do + with {:ok, opts} <- NimbleOptions.validate(opts, @create_opts_schema), + {:ok, resource} <- Ash.Api.resource(api, resource), + {:ok, action} <- get_action(resource, opts, :create) do + Create.run(api, resource, action, opts) + end + end + + @doc false + @spec update!(Ash.api(), Ash.record(), Keyword.t()) :: Ash.resource() | no_return() + def update!(api, record, opts) do + opts = NimbleOptions.validate!(opts, @update_opts_schema) + + api + |> update(record, opts) + |> unwrap_or_raise!() + end + + @doc false + @spec update(Ash.api(), Ash.record(), Keyword.t()) :: + {:ok, Ash.record()} | {:error, Ash.error()} + def update(api, %resource{} = record, opts) do + with {:ok, opts} <- NimbleOptions.validate(opts, @update_opts_schema), + {:ok, resource} <- Ash.Api.resource(api, resource), + {:ok, action} <- get_action(resource, opts, :update) do + Update.run(api, record, action, opts) + end + end + + @doc false + @spec destroy!(Ash.api(), Ash.record(), Keyword.t()) :: :ok | no_return + def destroy!(api, record, opts) do + opts = NimbleOptions.validate!(opts, @destroy_opts_schema) + + api + |> destroy(record, opts) + |> unwrap_or_raise!() + end + + @doc false + @spec destroy(Ash.api(), Ash.record(), Keyword.t()) :: :ok | {:error, Ash.error()} + def destroy(api, %resource{} = record, opts) do + with {:ok, opts} <- NimbleOptions.validate(opts, @destroy_opts_schema), + {:ok, resource} <- Ash.Api.resource(api, resource), + {:ok, action} <- get_action(resource, opts, :destroy) do + Destroy.run(api, record, action, opts) + end + end + + defp get_action(resource, params, type) do + case params[:action] || Ash.primary_action(resource, type) do + nil -> + {:error, "no action provided, and no primary action found for #{to_string(type)}"} + + action -> + {:ok, action} + end + end + + defp unwrap_or_raise!(:ok), do: :ok + defp unwrap_or_raise!({:ok, result}), do: result + + defp unwrap_or_raise!({:error, error}) do + exception = Ash.Error.to_ash_error(error) + raise exception + end end diff --git a/lib/ash/api/dsl.ex b/lib/ash/api/dsl.ex new file mode 100644 index 000000000..d89db8eb7 --- /dev/null +++ b/lib/ash/api/dsl.ex @@ -0,0 +1,28 @@ +defmodule Ash.Api.Dsl do + @resource %Ash.Dsl.Entity{ + name: :resource, + describe: "A reference to a resource", + target: Ash.Api.ResourceReference, + args: [:resource], + examples: [ + "resource MyApp.User" + ], + schema: [ + resource: [ + type: :atom, + required: true, + doc: "The module of the resource" + ] + ] + } + + @resources %Ash.Dsl.Section{ + name: :resources, + describe: "List the resources present in this API", + entities: [ + @resource + ] + } + + use Ash.Dsl.Extension, sections: [@resources] +end diff --git a/lib/ash/api/interface.ex b/lib/ash/api/interface.ex index 0944dd0ef..4b3b62bee 100644 --- a/lib/ash/api/interface.ex +++ b/lib/ash/api/interface.ex @@ -1,187 +1,18 @@ defmodule Ash.Api.Interface do @moduledoc false - import Ash.OptionsHelpers, only: [merge_schemas: 3] - - alias Ash.Actions.{Create, Destroy, Read, SideLoad, Update} - alias Ash.Error.Interface.NoSuchResource - - @global_opts [ - verbose?: [ - type: :boolean, - default: false, - doc: "Log engine operations (very verbose?)" - ], - action: [ - type: :any, - doc: "The action to use, either an Action struct or the name of the action" - ], - authorize?: [ - type: :boolean, - default: false, - doc: - "If an actor is provided, authorization happens automatically. If not, this flag can be used to authorize with no user." - ], - actor: [ - type: :any, - doc: - "If an actor is provided, it will be used in conjunction with the authorizers of a resource to authorize access" - ] - ] - - @read_opts_schema merge_schemas([], @global_opts, "Global Options") - - @side_load_opts_schema merge_schemas([], @global_opts, "Global Options") - - @get_opts_schema [ - side_load: [ - type: :any, - doc: - "Side loads to include in the query, same as you would pass to `Ash.Query.side_load/2`" - ] - ] - |> merge_schemas(@global_opts, "Global Options") - - @shared_create_and_update_opts_schema [ - attributes: [ - type: {:custom, Ash.OptionsHelpers, :map, []}, - default: %{}, - doc: "Changes to be applied to attribute values" - ], - relationships: [ - type: {:custom, Ash.OptionsHelpers, :map, []}, - default: %{}, - doc: "Changes to be applied to relationship values" - ] - ] - |> merge_schemas(@global_opts, "Global Options") - - @create_opts_schema [ - upsert?: [ - type: :boolean, - default: false, - doc: - "If a conflict is found based on the primary key, the record is updated in the database (requires upsert support)" - ] - ] - |> merge_schemas(@global_opts, "Global Options") - |> merge_schemas( - @shared_create_and_update_opts_schema, - "Shared Create/Edit Options" - ) - - @update_opts_schema [] - |> merge_schemas(@global_opts, "Global Options") - |> merge_schemas( - @shared_create_and_update_opts_schema, - "Shared Create/Edit Options" - ) - - @destroy_opts_schema merge_schemas([], @global_opts, "Global Opts") - - @doc """ - #{NimbleOptions.docs(@get_opts_schema)} - """ - @callback get!(resource :: Ash.resource(), id_or_filter :: term(), params :: Keyword.t()) :: - Ash.record() | no_return - - @doc """ - #{NimbleOptions.docs(@get_opts_schema)} - """ - @callback get(resource :: Ash.resource(), id_or_filter :: term(), params :: Keyword.t()) :: - {:ok, Ash.record()} | {:error, Ash.error()} - - @doc """ - #{NimbleOptions.docs(@read_opts_schema)} - """ - @callback read!(resource :: Ash.resource(), params :: Keyword.t()) :: - list(Ash.resource()) | no_return - - @doc """ - #{NimbleOptions.docs(@read_opts_schema)} - """ - @callback read(resource :: Ash.resource(), params :: Keyword.t()) :: - {:ok, list(Ash.resource())} | {:error, Ash.error()} - - @doc """ - #{NimbleOptions.docs(@side_load_opts_schema)} - """ - @callback side_load!(resource :: Ash.resource(), params :: Keyword.t()) :: - list(Ash.resource()) | no_return - - @doc """ - #{NimbleOptions.docs(@side_load_opts_schema)} - """ - @callback side_load(resource :: Ash.resource(), params :: Keyword.t()) :: - {:ok, list(Ash.resource())} | {:error, Ash.error()} - - @doc """ - #{NimbleOptions.docs(@create_opts_schema)} - """ - @callback create!(resource :: Ash.resource(), params :: Keyword.t()) :: - Ash.record() | no_return - - @doc """ - #{NimbleOptions.docs(@create_opts_schema)} - """ - @callback create(resource :: Ash.resource(), params :: Keyword.t()) :: - {:ok, Ash.record()} | {:error, Ash.error()} - - @doc """ - #{NimbleOptions.docs(@update_opts_schema)} - """ - @callback update!(record :: Ash.record(), params :: Keyword.t()) :: - Ash.record() | no_return - - @doc """ - #{NimbleOptions.docs(@update_opts_schema)} - """ - @callback update(record :: Ash.record(), params :: Keyword.t()) :: - {:ok, Ash.record()} | {:error, Ash.error()} - - @doc """ - #{NimbleOptions.docs(@destroy_opts_schema)} - """ - @callback destroy!(record :: Ash.record(), params :: Keyword.t()) :: :ok | no_return - - @doc """ - #{NimbleOptions.docs(@destroy_opts_schema)} - """ - @callback destroy(record :: Ash.record(), params :: Keyword.t()) :: - :ok | {:error, Ash.error()} - - @doc """ - Refetches a record from the database - """ - @callback reload(record :: Ash.record(), params :: Keyword.t()) :: - {:ok, Ash.record()} | {:error, Ash.error()} - - @doc """ - Refetches a record from the database, raising on error. - - See `reload/1`. - """ - @callback reload!(record :: Ash.record(), params :: Keyword.t()) :: Ash.record() | no_return - - @doc """ - Refetches a record from the database - """ - @callback reload(record :: Ash.record()) :: {:ok, Ash.record()} | {:error, Ash.error()} - defmacro __using__(_) do quote location: :keep do - @behaviour Ash.Api.Interface - - alias Ash.Api.Interface + alias Ash.Api @impl true def get!(resource, id, params \\ []) do - Interface.get!(__MODULE__, resource, id, params) + Api.get!(__MODULE__, resource, id, params) end @impl true def get(resource, id, params \\ []) do - case Interface.get(__MODULE__, resource, id, params) do + case Api.get(__MODULE__, resource, id, params) do {:ok, instance} -> {:ok, instance} {:error, error} -> {:error, List.wrap(error)} end @@ -196,7 +27,7 @@ defmodule Ash.Api.Interface do end def read!(query, opts) do - Interface.read!(__MODULE__, query, opts) + Api.read!(__MODULE__, query, opts) end @impl true @@ -208,7 +39,7 @@ defmodule Ash.Api.Interface do end def read(query, opts) do - case Interface.read(__MODULE__, query, opts) do + case Api.read(__MODULE__, query, opts) do {:ok, results} -> {:ok, results} {:error, error} -> {:error, List.wrap(error)} end @@ -216,12 +47,12 @@ defmodule Ash.Api.Interface do @impl true def side_load!(data, query, opts \\ []) do - Interface.side_load!(__MODULE__, data, query, opts) + Api.side_load!(__MODULE__, data, query, opts) end @impl true def side_load(data, query, opts \\ []) do - case Interface.side_load(__MODULE__, data, query, opts) do + case Api.side_load(__MODULE__, data, query, opts) do {:ok, results} -> {:ok, results} {:error, error} -> {:error, List.wrap(error)} end @@ -229,12 +60,12 @@ defmodule Ash.Api.Interface do @impl true def create!(resource, params \\ []) do - Interface.create!(__MODULE__, resource, params) + Api.create!(__MODULE__, resource, params) end @impl true def create(resource, params \\ []) do - case Interface.create(__MODULE__, resource, params) do + case Api.create(__MODULE__, resource, params) do {:ok, instance} -> {:ok, instance} {:error, error} -> {:error, List.wrap(error)} end @@ -242,12 +73,12 @@ defmodule Ash.Api.Interface do @impl true def update!(record, params \\ []) do - Interface.update!(__MODULE__, record, params) + Api.update!(__MODULE__, record, params) end @impl true def update(record, params \\ []) do - case Interface.update(__MODULE__, record, params) do + case Api.update(__MODULE__, record, params) do {:ok, instance} -> {:ok, instance} {:error, error} -> {:error, List.wrap(error)} end @@ -255,12 +86,12 @@ defmodule Ash.Api.Interface do @impl true def destroy!(record, params \\ []) do - Interface.destroy!(__MODULE__, record, params) + Api.destroy!(__MODULE__, record, params) end @impl true def destroy(record, params \\ []) do - case Interface.destroy(__MODULE__, record, params) do + case Api.destroy(__MODULE__, record, params) do :ok -> :ok {:error, error} -> {:error, List.wrap(error)} end @@ -283,230 +114,4 @@ defmodule Ash.Api.Interface do end end end - - @doc false - @spec get!(Ash.api(), Ash.resource(), term(), Keyword.t()) :: Ash.record() | no_return - def get!(api, resource, id, opts \\ []) do - opts = NimbleOptions.validate!(opts, @get_opts_schema) - - api - |> get(resource, id, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec get(Ash.api(), Ash.resource(), term(), Keyword.t()) :: - {:ok, Ash.record()} | {:error, Ash.error()} - def get(api, resource, id, opts) do - with {:ok, opts} <- NimbleOptions.validate(opts, @get_opts_schema), - {:resource, {:ok, resource}} <- {:resource, api.get_resource(resource)}, - {:pkey, primary_key} when primary_key != [] <- {:pkey, Ash.primary_key(resource)}, - {:ok, filter} <- get_filter(primary_key, id) do - resource - |> api.query() - |> Ash.Query.filter(filter) - |> Ash.Query.side_load(opts[:side_load] || []) - |> api.read(Keyword.delete(opts, :side_load)) - |> case do - {:ok, [single_result]} -> - {:ok, single_result} - - {:ok, []} -> - {:ok, nil} - - {:error, error} -> - {:error, error} - - {:ok, results} when is_list(results) -> - {:error, :too_many_results} - end - else - {:error, error} -> - {:error, error} - - {:resource, :error} -> - {:error, NoSuchResource.exception(resource: resource)} - - {:pkey, _} -> - {:error, "Resource has no primary key"} - end - end - - defp get_filter(primary_key, id) do - case {primary_key, id} do - {[field], [{field, value}]} -> - {:ok, [{field, value}]} - - {[field], value} -> - {:ok, [{field, value}]} - - {fields, value} -> - if Keyword.keyword?(value) and Enum.sort(Keyword.keys(value)) == Enum.sort(fields) do - {:ok, value} - else - {:error, "invalid primary key provided to `get/3`"} - end - end - end - - @doc false - @spec side_load!( - Ash.api(), - Ash.record() | list(Ash.record()), - Ash.query() | list(atom | {atom, list()}), - Keyword.t() - ) :: - list(Ash.record()) | Ash.record() | no_return - def side_load!(api, data, query, opts \\ []) do - opts = NimbleOptions.validate!(opts, @side_load_opts_schema) - - api - |> side_load(data, query, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec side_load(Ash.api(), Ash.query(), Keyword.t()) :: - {:ok, list(Ash.resource())} | {:error, Ash.error()} - def side_load(api, data, query, opts \\ []) - def side_load(_, [], _, _), do: {:ok, []} - def side_load(_, nil, _, _), do: {:ok, nil} - - def side_load(api, data, query, opts) when not is_list(data) do - api - |> side_load(List.wrap(data), query, opts) - |> case do - {:ok, [data]} -> {:ok, data} - {:error, error} -> {:error, error} - end - end - - def side_load(api, [%resource{} | _] = data, query, opts) do - query = - case query do - %Ash.Query{} = query -> - query - - keyword -> - resource - |> api.query() - |> Ash.Query.side_load(keyword) - end - - with %{valid?: true} <- query, - {:ok, opts} <- NimbleOptions.validate(opts, @side_load_opts_schema) do - SideLoad.side_load(data, query, opts) - else - {:error, error} -> - {:error, error} - - %{errors: errors} -> - {:error, errors} - end - end - - @doc false - @spec read!(Ash.api(), Ash.query(), Keyword.t()) :: - list(Ash.record()) | no_return - def read!(api, query, opts \\ []) do - opts = NimbleOptions.validate!(opts, @read_opts_schema) - - api - |> read(query, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec read(Ash.api(), Ash.query(), Keyword.t()) :: - {:ok, list(Ash.resource())} | {:error, Ash.error()} - def read(_api, query, opts \\ []) do - with {:ok, opts} <- NimbleOptions.validate(opts, @read_opts_schema), - {:ok, action} <- get_action(query.resource, opts, :read) do - Read.run(query, action, opts) - else - {:error, error} -> - {:error, error} - end - end - - @doc false - @spec create!(Ash.api(), Ash.resource(), Keyword.t()) :: - Ash.record() | {:error, Ash.error()} - def create!(api, resource, opts) do - opts = NimbleOptions.validate!(opts, @create_opts_schema) - - api - |> create(resource, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec create(Ash.api(), Ash.resource(), Keyword.t()) :: - {:ok, Ash.resource()} | {:error, Ash.error()} - def create(api, resource, opts) do - with {:ok, opts} <- NimbleOptions.validate(opts, @create_opts_schema), - {:ok, resource} <- api.get_resource(resource), - {:ok, action} <- get_action(resource, opts, :create) do - Create.run(api, resource, action, opts) - end - end - - @doc false - @spec update!(Ash.api(), Ash.record(), Keyword.t()) :: Ash.resource() | no_return() - def update!(api, record, opts) do - opts = NimbleOptions.validate!(opts, @update_opts_schema) - - api - |> update(record, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec update(Ash.api(), Ash.record(), Keyword.t()) :: - {:ok, Ash.resource()} | {:error, Ash.error()} - def update(api, %resource{} = record, opts) do - with {:ok, opts} <- NimbleOptions.validate(opts, @update_opts_schema), - {:ok, resource} <- api.get_resource(resource), - {:ok, action} <- get_action(resource, opts, :update) do - Update.run(api, record, action, opts) - end - end - - @doc false - @spec destroy!(Ash.api(), Ash.record(), Keyword.t()) :: :ok | no_return - def destroy!(api, record, opts) do - opts = NimbleOptions.validate!(opts, @destroy_opts_schema) - - api - |> destroy(record, opts) - |> unwrap_or_raise!() - end - - @doc false - @spec destroy(Ash.api(), Ash.record(), Keyword.t()) :: :ok | {:error, Ash.error()} - def destroy(api, %resource{} = record, opts) do - with {:ok, opts} <- NimbleOptions.validate(opts, @destroy_opts_schema), - {:ok, resource} <- api.get_resource(resource), - {:ok, action} <- get_action(resource, opts, :destroy) do - Destroy.run(api, record, action, opts) - end - end - - defp get_action(resource, params, type) do - case params[:action] || Ash.primary_action(resource, type) do - nil -> - {:error, "no action provided, and no primary action found for #{to_string(type)}"} - - action -> - {:ok, action} - end - end - - defp unwrap_or_raise!(:ok), do: :ok - defp unwrap_or_raise!({:ok, result}), do: result - - defp unwrap_or_raise!({:error, error}) do - exception = Ash.Error.to_ash_error(error) - raise exception - end end diff --git a/lib/ash/api/resource_reference.ex b/lib/ash/api/resource_reference.ex new file mode 100644 index 000000000..db3719b4f --- /dev/null +++ b/lib/ash/api/resource_reference.ex @@ -0,0 +1,4 @@ +defmodule Ash.Api.ResourceReference do + @moduledoc false + defstruct [:resource] +end diff --git a/lib/ash/engine/authorizer.ex b/lib/ash/authorizer.ex similarity index 97% rename from lib/ash/engine/authorizer.ex rename to lib/ash/authorizer.ex index 6db6106d8..f28d9d2f5 100644 --- a/lib/ash/engine/authorizer.ex +++ b/lib/ash/authorizer.ex @@ -1,4 +1,4 @@ -defmodule Ash.Engine.Authorizer do +defmodule Ash.Authorizer do @moduledoc """ The interface for an ash authorizer diff --git a/lib/ash/data_layer/data_layer.ex b/lib/ash/data_layer/data_layer.ex index 74e361a07..d4efc7bd3 100644 --- a/lib/ash/data_layer/data_layer.ex +++ b/lib/ash/data_layer/data_layer.ex @@ -17,6 +17,7 @@ defmodule Ash.DataLayer do | {:filter, filter_type} | {:filter_related, Ash.relationship_cardinality()} | :upsert + | :composite_primary_key @callback custom_filters(Ash.resource()) :: map() @callback filter(Ash.data_layer_query(), Ash.filter(), resource :: Ash.resource()) :: diff --git a/lib/ash/data_layer/ets.ex b/lib/ash/data_layer/ets/ets.ex similarity index 95% rename from lib/ash/data_layer/ets.ex rename to lib/ash/data_layer/ets/ets.ex index 1bed4c289..6ebc44d66 100644 --- a/lib/ash/data_layer/ets.ex +++ b/lib/ash/data_layer/ets/ets.ex @@ -5,28 +5,29 @@ defmodule Ash.DataLayer.Ets do This is used for testing. *Do not use this data layer in production* """ - @behaviour Ash.DataLayer - alias Ash.Filter.{And, Eq, In, NotEq, NotIn, Or} - @callback ets_private?() :: boolean - - defmacro __using__(opts) do - quote bind_quoted: [opts: opts] do - @data_layer Ash.DataLayer.Ets - @behaviour Ash.DataLayer.Ets - - @ets_private? Keyword.get(opts, :private?, false) + @behaviour Ash.DataLayer - def ets_private? do - @ets_private? - end - end - end + @ets %Ash.Dsl.Section{ + name: :ets, + describe: """ + A section for configuring the ets data layer + """, + schema: [ + private?: [ + type: :boolean, + default: false + ] + ] + } + + use Ash.Dsl.Extension, sections: [@ets] + alias Ash.Dsl.Extension @spec private?(Ash.resource()) :: boolean def private?(resource) do - resource.ets_private?() + Extension.get_opt(resource, [:ets], :private?) end defmodule Query do diff --git a/lib/ash/dsl/dsl.ex b/lib/ash/dsl/dsl.ex new file mode 100644 index 000000000..8ffc9d4ab --- /dev/null +++ b/lib/ash/dsl/dsl.ex @@ -0,0 +1,266 @@ +defmodule Ash.Dsl do + @moduledoc """ + The built in resource DSL. The three core DSL components of a resource are: + + * attributes - `attributes/1` + * relationships - `relationships/1` + * actions - `actions/1` + """ + + @attribute %Ash.Dsl.Entity{ + name: :attribute, + describe: """ + Declares an attribute on the resource + + Type can be either a built in type (see `Ash.Type`) for more, or a module + implementing the `Ash.Type` behaviour. + """, + examples: [ + "attribute :first_name, :string, primary_key?: true" + ], + target: Ash.Resource.Attribute, + args: [:name, :type], + schema: Ash.Resource.Attribute.attribute_schema() + } + + @create_timestamp %Ash.Dsl.Entity{ + name: :create_timestamp, + describe: """ + Declares a non-writable attribute with a create default of `&DateTime.utc_now/0` + """, + examples: [ + "create_timestamp :inserted_at" + ], + target: Ash.Resource.Attribute, + args: [:name], + schema: Ash.Resource.Attribute.create_timestamp_schema() + } + + @update_timestamp %Ash.Dsl.Entity{ + name: :update_timestamp, + describe: """ + Declares a non-writable attribute with a create and update default of `&DateTime.utc_now/0` + """, + examples: [ + "update_timestamp :inserted_at" + ], + target: Ash.Resource.Attribute, + schema: Ash.Resource.Attribute.update_timestamp_schema(), + args: [:name] + } + + @attributes %Ash.Dsl.Section{ + name: :attributes, + describe: """ + A section for declaring attributes on the resource. + + Attributes are fields on an instance of a resource. The two required + pieces of knowledge are the field name, and the type. + """, + entities: [ + @attribute, + @create_timestamp, + @update_timestamp + ] + } + + @has_one %Ash.Dsl.Entity{ + name: :has_one, + describe: """ + Declares a has_one relationship. In a relationsal database, the foreign key would be on the *other* table. + + Generally speaking, a `has_one` also implies that the destination table is unique on that foreign key. + """, + examples: [ + """ + # In a resource called `Word` + has_one :dictionary_entry, DictionaryEntry, + source_field: :text, + destination_field: :word_text + """ + ], + target: Ash.Resource.Relationships.HasOne, + schema: Ash.Resource.Relationships.HasOne.opt_schema(), + args: [:name, :destination] + } + + @has_many %Ash.Dsl.Entity{ + name: :has_many, + describe: """ + Declares a has_many relationship. There can be any number of related entities. + """, + examples: [ + """ + # In a resource called `Word` + has_many :definitions, DictionaryDefinition, + source_field: :text, + destination_field: :word_text + """ + ], + target: Ash.Resource.Relationships.HasMany, + schema: Ash.Resource.Relationships.HasMany.opt_schema(), + args: [:name, :destination] + } + + @many_to_many %Ash.Dsl.Entity{ + name: :many_to_many, + describe: """ + Declares a many_to_many relationship. Many to many relationships require a join table. + + A join table is typically a table who's primary key consists of one foreign key to each resource. + """, + examples: [ + """ + # In a resource called `Word` + many_to_many :books, Book, + through: BookWord, + source_field: :text, + source_field_on_join_table: :word_text, + destination_field: :id, + destination_field_on_join_table: :book_id + """ + ], + target: Ash.Resource.Relationships.ManyToMany, + schema: Ash.Resource.Relationships.ManyToMany.opt_schema(), + args: [:name, :destination] + } + + @belongs_to %Ash.Dsl.Entity{ + name: :belongs_to, + describe: """ + Declares a belongs_to relationship. In a relational database, the foreign key would be on the *source* table. + + This creates a field on the resource with the corresponding name and type, unless `define_field?: false` is provided. + """, + examples: [ + """ + # In a resource called `Word` + belongs_to :dictionary_entry, DictionaryEntry, + source_field: :text, + destination_field: :word_text + """ + ], + target: Ash.Resource.Relationships.BelongsTo, + schema: Ash.Resource.Relationships.BelongsTo.opt_schema(), + args: [:name, :destination] + } + + @relationships %Ash.Dsl.Section{ + name: :relationships, + describe: """ + A section for declaring relationships on the resource. + + Relationships are a core component of resource oriented design. Many components of Ash + will use these relationships. A simple use case is side_loading (done via the `Ash.Query.side_load/2`). + """, + entities: [ + @has_one, + @has_many, + @many_to_many, + @belongs_to + ] + } + + @create %Ash.Dsl.Entity{ + name: :create, + describe: """ + Declares a `create` action. For calling this action, see the `Ash.Api` documentation. + """, + examples: [ + "create :register, primary?: true" + ], + target: Ash.Resource.Actions.Create, + schema: Ash.Resource.Actions.Create.opt_schema(), + args: [:name] + } + + @read %Ash.Dsl.Entity{ + name: :read, + describe: """ + Declares a `read` action. For calling this action, see the `Ash.Api` documentation. + """, + examples: [ + "read :read_all, primary?: true" + ], + target: Ash.Resource.Actions.Read, + schema: Ash.Resource.Actions.Read.opt_schema(), + args: [:name] + } + + @update %Ash.Dsl.Entity{ + name: :update, + describe: """ + Declares a `update` action. For calling this action, see the `Ash.Api` documentation. + """, + examples: [ + "update :flag_for_review, primary?: true" + ], + target: Ash.Resource.Actions.Update, + schema: Ash.Resource.Actions.Update.opt_schema(), + args: [:name] + } + + @destroy %Ash.Dsl.Entity{ + name: :destroy, + describe: """ + Declares a `destroy` action. For calling this action, see the `Ash.Api` documentation. + """, + examples: [ + "destroy :soft_delete, primary?: true" + ], + target: Ash.Resource.Actions.Destroy, + schema: Ash.Resource.Actions.Destroy.opt_schema(), + args: [:name] + } + + @actions %Ash.Dsl.Section{ + name: :actions, + describe: """ + A section for declaring resource actions. + + All manipulation of data through the underlying data layer happens through actions. + There are four types of action: `create`, `read`, `update`, and `destroy`. You may + recognize these from the acronym `CRUD`. You can have multiple actions of the same + type, as long as they have different names. This is the primary mechanism for customizing + your resources to conform to your business logic. It is normal and expected to have + multiple actions of each type in a large application. + + If you have multiple actions of the same type, one of them must be designated as the + primary action for that type, via: `primary?: true`. This tells the ash what to do + if an action of that type is requested, but no specific action name is given. + """, + entities: [ + @create, + @read, + @update, + @destroy + ] + } + + @resource %Ash.Dsl.Section{ + name: :resource, + describe: """ + Resource-wide configuration + """, + schema: [ + description: [ + type: :string + ] + ] + } + + @sections [@attributes, @relationships, @actions, @resource] + + @transformers [ + Ash.Resource.Transformers.SetRelationshipSource, + Ash.Resource.Transformers.BelongsToAttribute, + Ash.Resource.Transformers.BelongsToSourceField, + Ash.Resource.Transformers.CreateJoinRelationship, + Ash.Resource.Transformers.CachePrimaryKey, + Ash.Resource.Transformers.SetPrimaryActions + ] + + use Ash.Dsl.Extension, + sections: @sections, + transformers: @transformers +end diff --git a/lib/ash/dsl/entity.ex b/lib/ash/dsl/entity.ex new file mode 100644 index 000000000..ef3841439 --- /dev/null +++ b/lib/ash/dsl/entity.ex @@ -0,0 +1,124 @@ +defmodule Ash.Dsl.Entity do + @moduledoc """ + Declares a DSL entity. + + A dsl entity represents a dsl constructor who's resulting value is a struct. + This lets the user create complex objects with arbitrary(mostly) validation rules. + + The lifecycle of creating entities is complex, happening as Elixir is compiling + the modules in question. Some of the patterns around validating/transforming entities + have not yet solidified. If you aren't careful and don't follow the guidelines listed + here, you can have subtle and strange bugs during compilation. Anything not isolated to + simple value validations should be done in `transformers`. See `Ash.Dsl.Transformer`. + + An entity has a `target` indicating which struct will ultimately be built. An entity + also has a `schema`. This schema is used for documentation, and the options are validated + against it before continuing on with the DSL. + + To create positional arguments to the builder, use `args`. The values provided to + `args` need to be in the provided schema as well. They will be positional arguments + in the same order that they are provided in the `args` key. + + `auto_set_fields` will set the provided values into the produced struct (they do not need + to be included in the schema). + + `transform` is a function that takes a created struct and can alter it. This happens immediately + after handling the DSL options, and can be useful for setting field values on a struct based on + other values in that struct. If you need things that aren't contained in that struct, use an + `Ash.Dsl.Transformer`. + + For a full example, see `Ash.Dsl.Extension`. + """ + defstruct [ + :name, + :examples, + :target, + :transform, + describe: "", + args: [], + schema: [], + auto_set_fields: [] + ] + + @type t :: %__MODULE__{ + name: atom, + describe: String.t(), + target: module, + examples: [String.t()], + transform: mfa | nil, + args: [atom], + auto_set_fields: Keyword.t(), + schema: NimbleOptions.schema() + } + + def build( + %{target: target, schema: schema, auto_set_fields: auto_set_fields, transform: transform}, + opts + ) do + with {:ok, opts} <- NimbleOptions.validate(opts, schema), + opts <- Keyword.merge(opts, auto_set_fields || []), + built <- struct(target, opts), + {:ok, built} <- + transform(transform, built) do + {:ok, built} + end + end + + defp transform(nil, built), do: {:ok, built} + + defp transform({module, function, args}, built) do + apply(module, function, [built | args]) + end + + def validate(%{target: target}, built) do + target.validate(built) + end + + def describe(entity, depth \\ 2) do + args_description = + case Keyword.take(entity.schema, entity.args) do + [] -> + "" + + args_schema -> + args_schema = + Enum.map(args_schema, fn {key, value} -> + {key, Keyword.drop(value, [:required, :default])} + end) + + "\n" <> + header("Arguments", depth) <> + NimbleOptions.docs(args_schema) + end + + opts_description = + case Keyword.drop(entity.schema, entity.args) do + [] -> + "" + + opts_schema -> + "\n" <> + header("Options", depth) <> + NimbleOptions.docs(opts_schema) + end + + example_docs = + case entity.examples do + [] -> + "" + + examples -> + "\n" <> + header("Examples", depth) <> + Enum.map_join(examples, "\n", fn example -> + "```elixir\n" <> example <> "\n```\n" + end) + end + + entity.describe <> "\n" <> example_docs <> args_description <> opts_description + end + + defp header(header, depth) do + String.duplicate("#", depth) <> " " <> header <> "\n\n" + end +end diff --git a/lib/ash/dsl/extension.ex b/lib/ash/dsl/extension.ex new file mode 100644 index 000000000..3d0b4ee6e --- /dev/null +++ b/lib/ash/dsl/extension.ex @@ -0,0 +1,676 @@ +defmodule Ash.Dsl.Extension do + @moduledoc """ + An extension to the Ash DSL. + + This allows configuring custom DSL components, whos configurations + can then be read back. + + The example at the bottom shows how you might build a (not very contextually + relevant) DSL extension that would be used like so: + + defmodule MyApp.MyResource do + use Ash.Resource, + extensions: [MyApp.CarExtension] + + cars do + car :mazda, "6", trim: :touring + car :toyota, "corolla" + end + end + + For (a not very contextually relevant) example: + + defmodule MyApp.CarExtension do + @car_schema [ + make: [ + type: :atom, + required: true, + doc: "The make of the car" + ], + model: [ + type: :atom, + required: true, + doc: "The model of the car" + ], + type: [ + type: :atom, + required: true, + doc: "The type of the car", + default: :sedan + ] + ] + + @car %Ash.Dsl.Entity{ + name: :car, + describe: "Adds a car", + examples: [ + "car :mazda, \"6\"" + ], + target: MyApp.Car, + args: [:make, :model], + schema: @car_schema + } + + @cars %Ash.Dsl.Section{ + name: :cars, # The DSL constructor will be `cars` + describe: \"\"\" + Configure what cars are available. + + More, deeper explanation. Always have a short one liner explanation, + an empty line, and then a longer explanation. + \"\"\", + entities: [ + @car # See `Ash.Dsl.Entity` docs + ], + schema: [ + default_manufacturer: [ + type: :atom, + doc: "The default manufacturer" + ] + ] + } + + use Ash.Dsl.Extension, sections: [@cars] + end + + + Often, we will need to do complex validation/validate based on the configuration + of other resources. Due to the nature of building compile time DSLs, there are + many restrictions around that process. To support these complex use cases, extensions + can include `transformers` which can validate/transform the DSL state after all basic + sections/entities have been created. See `Ash.Dsl.Transformer` for more information. + Transformers are provided as an option to `use`, like so: + + use Ash.Dsl.Extension, sections: [@cars], transformers: [ + MyApp.Transformers.ValidateNoOverlappingMakesAndModels + ] + + To expose the configuration of your DSL, define functions that use the + helpers like `get_entities/2` and `get_opt/3`. For example: + + defmodule MyApp.Cars do + def cars(resource) do + Ash.Dsl.Extension.get_entities(resource, [:cars]) + end + end + + MyApp.Cars.cars(MyResource) + # [%MyApp.Car{...}, %MyApp.Car{...}] + """ + + @callback sections() :: [Ash.Dsl.Section.t()] + @callback transformers() :: [module] + + @doc "Get the entities configured for a given section" + def get_entities(resource, path) do + :persistent_term.get({resource, :ash, path}, %{entities: []}).entities + end + + @doc "Get an option value for a section at a given path" + def get_opt(resource, path, value) do + :persistent_term.get({resource, :ash, path}, %{opts: []}).opts[value] + end + + @doc false + defmacro __using__(opts) do + quote bind_quoted: [ + sections: opts[:sections] || [], + transformers: opts[:transformers] || [] + ] do + alias Ash.Dsl.Extension + + @behaviour Extension + Extension.build(__MODULE__, sections) + @_sections sections + @_transformers transformers + + @doc false + def sections, do: @_sections + + @doc false + def transformers, do: @_transformers + end + end + + @doc false + def prepare(extensions) do + body = + quote location: :keep do + @extensions unquote(extensions) + # Due to a few strange stateful bugs I've seen, + # we clear the process of any potentially related state + Process.get() + |> Enum.filter(fn key -> + is_tuple(key) and elem(key, 0) == __MODULE__ + end) + |> Enum.each(&Process.delete/1) + + :persistent_term.get() + |> Enum.filter(fn key -> + is_tuple(key) and elem(key, 0) == __MODULE__ + end) + |> Enum.each(&Process.delete/1) + end + + imports = + for extension <- extensions || [] do + extension = Macro.expand_once(extension, __ENV__) + + quote location: :keep do + require Ash.Dsl.Extension + alias Ash.Dsl.Extension + Extension.import_extension(unquote(extension)) + end + end + + [body | imports] + end + + @doc false + defmacro set_state(runtime? \\ false) do + quote bind_quoted: [runtime?: runtime?], location: :keep do + alias Ash.Dsl.Transformer + + ash_dsl_config = + if runtime? do + @ash_dsl_config + else + {__MODULE__, :ash_sections} + |> Process.get([]) + |> Enum.map(fn {extension, section_path} -> + {{section_path, extension}, + Process.get( + {__MODULE__, :ash, section_path}, + [] + )} + end) + |> Enum.into(%{}) + end + + :persistent_term.put({__MODULE__, :extensions}, @extensions) + + new_dsl_config = + if runtime? do + Enum.each(ash_dsl_config, fn {{section_path, _extension}, value} -> + :persistent_term.put({__MODULE__, :ash, section_path}, value) + end) + + :persistent_term.put({__MODULE__, :ash, :complete?}, true) + + ash_dsl_config + else + Module.register_attribute(__MODULE__, :transformers, accumulate: true) + + transformed = + @extensions + |> Enum.flat_map(& &1.transformers()) + |> Transformer.sort() + |> Enum.reduce(ash_dsl_config, fn transformer, dsl -> + transformers_run = :persistent_term.get({__MODULE__, :ash, :transformers}, []) + + result = + try do + transformer.transform(__MODULE__, dsl) + rescue + e -> + reraise "Raised exception while running transformer #{inspect(transformer)}: #{ + Exception.message(e) + }", + __STACKTRACE__ + end + + case result do + {:ok, new_dsl} -> + new_dsl + |> Enum.each(fn {{section_path, _extension}, value} -> + :persistent_term.put({__MODULE__, :ash, section_path}, value) + end) + + unless runtime? do + Module.put_attribute(__MODULE__, :transformers, transformer) + end + + :persistent_term.put({__MODULE__, :ash, :transformers}, [ + transformer | transformers_run + ]) + + new_dsl + + {:error, error} -> + :persistent_term.put({__MODULE__, :ash, :transformers}, [ + transformer | transformers_run + ]) + + unless runtime? do + Module.put_attribute(__MODULE__, :transformers, transformer) + end + + if Exception.exception?(error) do + raise error + else + raise "Error while running transformer #{inspect(transformer)}: #{ + inspect(error) + }" + end + end + end) + + :persistent_term.put({__MODULE__, :ash, :complete?}, true) + + transformed + end + + if runtime? do + @persist_to_runtime + |> Enum.each(fn {key, value} -> + :persistent_term.put(key, value) + end) + else + to_persist = + {__MODULE__, :persist_to_runtime} + |> :persistent_term.get([]) + |> Enum.map(fn key -> + {key, :persistent_term.get(key, nil)} + end) + + Module.put_attribute(__MODULE__, :persist_to_runtime, to_persist) + end + + unless runtime? do + Module.put_attribute(__MODULE__, :ash_dsl_config, new_dsl_config) + end + end + end + + @doc false + def all_section_paths(path, prior \\ []) + def all_section_paths(nil, _), do: [] + def all_section_paths([], _), do: [] + + def all_section_paths(sections, prior) do + Enum.flat_map(sections, fn section -> + nested = all_section_paths(section.sections(), [section.name, prior]) + + [Enum.reverse(prior) ++ [section.name] | nested] + end) + |> Enum.uniq() + end + + @doc false + def all_section_config_paths(path, prior \\ []) + def all_section_config_paths(nil, _), do: [] + def all_section_config_paths([], _), do: [] + + def all_section_config_paths(sections, prior) do + Enum.flat_map(sections, fn section -> + nested = all_section_config_paths(section.sections(), [section.name, prior]) + + fields = + Enum.map(section.schema, fn {key, _} -> + {Enum.reverse(prior) ++ [section.name], key} + end) + + fields ++ nested + end) + |> Enum.uniq() + end + + @doc false + defmacro import_extension(extension) do + quote do + import unquote(extension), only: :macros + end + end + + @doc false + defmacro build(extension, sections) do + quote bind_quoted: [sections: sections, extension: extension] do + alias Ash.Dsl.Extension + + for section <- sections do + Extension.build_section(extension, section) + end + end + end + + @doc false + defmacro build_section(extension, section, path \\ []) do + quote bind_quoted: [section: section, path: path, extension: extension] do + alias Ash.Dsl + + {section_modules, entity_modules, opts_module} = + Dsl.Extension.do_build_section(__MODULE__, extension, section, path) + + @doc Dsl.Section.describe(__MODULE__, section) + + # This macro argument is only called `body` so that it looks nicer + # in the DSL docs + + defmacro unquote(section.name)(body) do + opts_module = unquote(opts_module) + section_path = unquote(path ++ [section.name]) + section = unquote(Macro.escape(section)) + + entity_imports = + for module <- unquote(entity_modules) do + quote do + import unquote(module) + end + end + + section_imports = + for module <- unquote(section_modules) do + quote do + import unquote(module) + end + end + + opts_import = + if Map.get(unquote(Macro.escape(section)), :schema, []) == [] do + [] + else + [ + quote do + import unquote(opts_module) + end + ] + end + + entity_unimports = + for module <- unquote(entity_modules) do + quote do + import unquote(module), only: [] + end + end + + section_unimports = + for module <- unquote(section_modules) do + quote do + import unquote(module), only: [] + end + end + + opts_unimport = + if Map.get(unquote(Macro.escape(section)), :schema, []) == [] do + [] + else + [ + quote do + import unquote(opts_module), only: [] + end + ] + end + + entity_imports ++ + section_imports ++ + opts_import ++ + [ + quote do + unquote(body[:do]) + + current_config = + Process.get( + {__MODULE__, :ash, unquote(section_path)}, + %{entities: [], opts: []} + ) + + opts = + case NimbleOptions.validate( + current_config.opts, + Map.get(unquote(Macro.escape(section)), :schema, []) + ) do + {:ok, opts} -> + opts + + {:error, error} -> + raise Ash.Error.ResourceDslError, + message: error, + path: unquote(section_path) + end + + Process.put({__MODULE__, :ash, unquote(section_path)}, %{ + entities: current_config.entities, + opts: opts + }) + end + ] ++ opts_unimport ++ entity_unimports ++ section_unimports + end + end + end + + @doc false + def do_build_section(mod, extension, section, path) do + entity_modules = + Enum.map(section.entities, fn entity -> + build_entity(mod, extension, path ++ [section.name], entity) + end) + + section_modules = + Enum.map(section.sections, fn nested_section -> + nested_mod_name = + path + |> Enum.drop(1) + |> Enum.map(fn nested_section_name -> + Macro.camelize(to_string(nested_section_name)) + end) + + mod_name = + Module.concat( + [mod | nested_mod_name] ++ [Macro.camelize(to_string(nested_section.name))] + ) + + {:module, module, _, _} = + defmodule mod_name do + alias Ash.Dsl + @moduledoc Dsl.Section.describe(__MODULE__, nested_section) + + require Dsl.Extension + + Dsl.Extension.build_section( + extension, + nested_section, + path ++ [section.name] + ) + end + + module + end) + + opts_mod_name = + if section.schema == [] do + nil + else + opts_mod_name = Module.concat([mod, Macro.camelize(to_string(section.name)), Options]) + + Module.create( + opts_mod_name, + quote bind_quoted: [ + section: Macro.escape(section), + section_path: path ++ [section.name], + extension: extension + ] do + @moduledoc false + + for {field, _opts} <- section.schema do + defmacro unquote(field)(value) do + section_path = unquote(Macro.escape(section_path)) + field = unquote(Macro.escape(field)) + extension = unquote(extension) + + quote do + current_sections = Process.get({__MODULE__, :ash_sections}, []) + + unless {unquote(extension), unquote(section_path)} in current_sections do + Process.put({__MODULE__, :ash_sections}, [ + {unquote(extension), unquote(section_path)} | current_sections + ]) + end + + current_config = + Process.get( + {__MODULE__, :ash, unquote(section_path)}, + %{entities: [], opts: []} + ) + + Process.put( + {__MODULE__, :ash, unquote(section_path)}, + %{ + entities: current_config.entities, + opts: Keyword.put(current_config.opts, unquote(field), unquote(value)) + } + ) + end + end + end + end, + Macro.Env.location(__ENV__) + ) + + opts_mod_name + end + + {section_modules, entity_modules, opts_mod_name} + end + + @doc false + def build_entity(mod, extension, section_path, entity) do + mod_name = Module.concat(mod, Macro.camelize(to_string(entity.name))) + + options_mod_name = Module.concat([mod, Macro.camelize(to_string(entity.name)), "Options"]) + + Ash.Dsl.Extension.build_entity_options(options_mod_name, entity.schema) + + args = Enum.map(entity.args, &Macro.var(&1, mod_name)) + + Module.create( + mod_name, + quote bind_quoted: [ + extension: extension, + entity: Macro.escape(entity), + args: Macro.escape(args), + section_path: Macro.escape(section_path), + options_mod_name: Macro.escape(options_mod_name) + ] do + @doc Ash.Dsl.Entity.describe(entity) + defmacro unquote(entity.name)(unquote_splicing(args), opts \\ []) do + section_path = unquote(section_path) + entity_schema = unquote(Macro.escape(entity.schema)) + entity = unquote(Macro.escape(entity)) + entity_name = unquote(entity.name) + entity_args = unquote(entity.args) + options_mod_name = unquote(options_mod_name) + source = unquote(__MODULE__) + extension = unquote(extension) + + arg_values = unquote(args) + + quote do + alias Ash.Dsl.Entity + section_path = unquote(section_path) + entity_name = unquote(entity_name) + extension = unquote(extension) + + current_config = + Process.get( + {__MODULE__, :ash, section_path}, + %{entities: [], opts: []} + ) + + current_sections = Process.get({__MODULE__, :ash_sections}, []) + + opts_without_do = + Keyword.merge( + unquote(Keyword.delete(opts, :do)), + Enum.zip(unquote(entity_args), unquote(arg_values)) + ) + + import unquote(options_mod_name) + + unquote(opts[:do]) + + import unquote(options_mod_name), only: [] + + all_opts = + case Process.delete(:builder_opts) do + nil -> + opts_without_do + + opts -> + Keyword.merge(opts, opts_without_do) + end + + built = + case Entity.build(unquote(Macro.escape(entity)), all_opts) do + {:ok, built} -> + Map.put(built, :__entity_name__, unquote(entity_name)) + + {:error, error} -> + additional_path = + if all_opts[:name] do + [unquote(entity.name), all_opts[:name]] + else + [unquote(entity.name)] + end + + message = + cond do + Exception.exception?(error) -> + Exception.message(error) + + is_binary(error) -> + error + + true -> + inspect(error) + end + + raise Ash.Error.ResourceDslError, + message: message, + path: section_path ++ additional_path + end + + new_config = %{opts: current_config.opts, entities: [built | current_config.entities]} + + unless {extension, section_path} in current_sections do + Process.put({__MODULE__, :ash_sections}, [ + {extension, section_path} | current_sections + ]) + end + + Process.put( + {__MODULE__, :ash, section_path}, + new_config + ) + end + end + end, + Macro.Env.location(__ENV__) + ) + + mod_name + end + + @doc false + def build_entity_options(module_name, schema) do + Module.create( + module_name, + quote bind_quoted: [schema: Macro.escape(schema)] do + @moduledoc false + for {key, value} <- schema do + defmacro unquote(key)(value) do + key = unquote(key) + + quote do + current_opts = Process.get(:builder_opts, []) + + Process.put(:builder_opts, Keyword.put(current_opts, unquote(key), unquote(value))) + end + end + end + end, + file: __ENV__.file + ) + + module_name + end +end diff --git a/lib/ash/dsl/section.ex b/lib/ash/dsl/section.ex new file mode 100644 index 000000000..3357dfaa6 --- /dev/null +++ b/lib/ash/dsl/section.ex @@ -0,0 +1,76 @@ +defmodule Ash.Dsl.Section do + @moduledoc """ + Declares a DSL section. + + A dsl section allows you to organize related configurations. All extensions + configure sections, they cannot add DSL builders to the top level. This + keeps things organized, and concerns separated. + + A section may have nested sections, which will be configured the same as other sections. + Getting the options/entities of a section is done by providing a path, so you would + use the nested path to retrieve that configuration. See `Ash.Dsl.Extension.get_entities/2` + and `Ash.Dsl.Extension.get_opt/3`. + + A section may have entities, which are constructors that produce instances of structs. + For more on entities, see `Ash.Dsl.Entity`. + + A section may also have a `schema`, which is a `NimbleOptions` schema. Ash will produce + builders for those options, so that they may be configured. They are retrived with + `Ash.Dsl.Extension.get_opt/3`. + + For a full example, see `Ash.Dsl.Extension`. + """ + defstruct [:name, schema: [], describe: "", entities: [], sections: []] + + @type t :: %__MODULE__{ + name: atom, + describe: String.t(), + entities: [Ash.Dsl.Entity.t()], + sections: [%__MODULE__{}], + schema: NimbleOptions.schema() + } + + def describe(mod, section, depth \\ 2) do + options_doc = + if section.schema && section.schema != [] do + "\n" <> header("Options", depth) <> "\n" <> NimbleOptions.docs(section.schema) + else + "" + end + + entity_doc = + case section.entities do + [] -> + "" + + entities -> + "\n" <> + header("Constructors", depth) <> + Enum.map_join(entities, "\n", fn entity -> + nested_module_name = Module.concat(mod, Macro.camelize(to_string(entity.name))) + + "* " <> to_string(entity.name) <> " - " <> "`#{inspect(nested_module_name)}`" + end) + end + + section_doc = + case section.sections do + [] -> + "" + + sections -> + "\n" <> + header("Sections", depth) <> + Enum.map_join(sections, "\n", fn section -> + nested_module_name = Module.concat(mod, Macro.camelize(to_string(section.name))) + "* " <> to_string(section.name) <> " - " <> "`#{inspect(nested_module_name)}`" + end) + end + + section.describe <> options_doc <> entity_doc <> section_doc + end + + defp header(header, depth) do + String.duplicate("#", depth) <> " " <> header <> "\n\n" + end +end diff --git a/lib/ash/dsl/transformer.ex b/lib/ash/dsl/transformer.ex new file mode 100644 index 000000000..f48bf2deb --- /dev/null +++ b/lib/ash/dsl/transformer.ex @@ -0,0 +1,155 @@ +defmodule Ash.Dsl.Transformer do + @moduledoc """ + A transformer manipulates and/or validates the entire DSL state of a resource. + + It's `transform/2` takes a `map`, which is just the values/configurations at each point + of the DSL. Don't manipulate it directly, if possible, instead use functions like + `get_entities/3` and `replace_entity/5` to manipulate it. + + Use the `after?/1` and `before?/1` callbacks to ensure that your transformer + runs either before or after some other transformer. + + The pattern for requesting information from other modules that use the DSL and are + also currently compiling has not yet been determined. If you have that requirement + you will need extra utilities to ensure that some other DSL based module has either + completed or reached a certain point in its transformers. These utilities have not + yet been written. + """ + @callback transform(Ash.resource(), map) :: {:ok, map} | {:error, term} + @callback before?(module) :: boolean + @callback after?(module) :: boolean + + defmacro __using__(_) do + quote do + @behaviour Ash.Dsl.Transformer + + def before?(_), do: false + def after?(_), do: false + + defoverridable before?: 1, after?: 1 + end + end + + def build_entity(extension, path, name, opts) do + do_build_entity(extension.sections(), path, name, opts) + end + + defp do_build_entity(sections, [section_name], name, opts) do + section = Enum.find(sections, &(&1.name == section_name)) + entity = Enum.find(section.entities, &(&1.name == name)) + + case NimbleOptions.validate(opts, entity.schema) do + {:ok, opts} -> {:ok, struct(entity.target, opts)} + {:error, error} -> {:error, error} + end + end + + defp do_build_entity(sections, [section_name | rest], name, opts) do + section = Enum.find(sections, &(&1.name == section_name)) + + do_build_entity(section.sections, rest, name, opts) + end + + def add_entity(dsl_state, path, extension, entity) do + Map.update(dsl_state, {path, extension}, %{entities: [entity], opts: []}, fn config -> + Map.update(config, :entities, [entity], fn entities -> + [entity | entities] + end) + end) + end + + def get_entities(dsl_state, path, extension) do + dsl_state + |> Map.get({path, extension}, %{entities: []}) + |> Map.get(:entities, []) + end + + @doc """ + Store a value in a special persistent term key, that will be copied to the runtime + """ + def persist_to_runtime(module, key, value) do + :persistent_term.put(key, value) + current_persisted = :persistent_term.get({module, :persist_to_runtime}, []) + + unless key in current_persisted do + :persistent_term.put({module, :persist_to_runtime}, [key | current_persisted]) + end + + :ok + end + + def replace_entity(dsl_state, path, extension, replacement, matcher) do + Map.update(dsl_state, {path, extension}, %{entities: [replacement], opts: []}, fn config -> + Map.update(config, :entities, [replacement], fn entities -> + replace_match(entities, replacement, matcher) + end) + end) + end + + defp replace_match(entities, replacement, matcher) do + Enum.map(entities, fn entity -> + if matcher.(entity) do + replacement + else + entity + end + end) + end + + def sort(transformers) do + transformers + |> Enum.map(&build_before_after_list(&1, transformers)) + |> Enum.sort_by(& &1, __MODULE__) + |> Enum.map(&elem(&1, 0)) + end + + def build_before_after_list(transformer, transformers, already_touched \\ []) do + transformers + |> Enum.reject(&(&1 in already_touched)) + |> Enum.reject(&(&1 == transformer)) + |> Enum.reduce({transformer, [], []}, fn other_transformer, + {transformer, before_list, after_list} -> + {_, other_befores, other_afters} = + build_before_after_list(other_transformer, transformers, [transformer | already_touched]) + + cond do + transformer.before?(other_transformer) -> + {transformer, [other_transformer | before_list ++ other_befores], after_list} + + transformer.after?(other_transformer) -> + {transformer, before_list, [other_transformer | after_list ++ other_afters]} + + true -> + {transformer, before_list, after_list} + end + end) + end + + def compare({left, after_left, before_left}, {right, after_right, before_right}) do + cond do + right in after_left -> + :lt + + right in before_left -> + :gt + + left in after_right -> + :gt + + left in before_right -> + :lt + + true -> + cond do + left == right -> + :eq + + inspect(left) < inspect(right) -> + :lt + + true -> + :gt + end + end + end +end diff --git a/lib/ash/dsl_builder.ex b/lib/ash/dsl_builder.ex deleted file mode 100644 index 71ada2fba..000000000 --- a/lib/ash/dsl_builder.ex +++ /dev/null @@ -1,16 +0,0 @@ -defmodule Ash.DslBuilder do - @moduledoc false - defmacro build_dsl(keys) do - quote bind_quoted: [keys: keys] do - for key <- keys do - defmacro unquote(key)(value) do - key = unquote(key) - - quote do - @dsl_opts {unquote(key), unquote(value)} - end - end - end - end - end -end diff --git a/lib/ash/engine/request.ex b/lib/ash/engine/request.ex index e340b09bc..b220060dc 100644 --- a/lib/ash/engine/request.ex +++ b/lib/ash/engine/request.ex @@ -62,7 +62,7 @@ defmodule Ash.Engine.Request do require Logger alias Ash.Actions.PrimaryKeyHelpers - alias Ash.Engine.Authorizer + alias Ash.Authorizer def resolve(dependencies \\ [], optional_dependencies \\ [], func) do UnresolvedField.new(dependencies, optional_dependencies, func) diff --git a/lib/ash/error/interface/no_such_resource.ex b/lib/ash/error/no_such_resource.ex similarity index 81% rename from lib/ash/error/interface/no_such_resource.ex rename to lib/ash/error/no_such_resource.ex index 2a300b249..c0ed958e5 100644 --- a/lib/ash/error/interface/no_such_resource.ex +++ b/lib/ash/error/no_such_resource.ex @@ -1,4 +1,4 @@ -defmodule Ash.Error.Interface.NoSuchResource do +defmodule Ash.Error.NoSuchResource do @moduledoc "Used when a resource or alias is provided that doesn't exist" use Ash.Error @@ -7,7 +7,7 @@ defmodule Ash.Error.Interface.NoSuchResource do defimpl Ash.ErrorKind do def id(_), do: Ecto.UUID.generate() - def code(_), do: "interface_no_such_resource" + def code(_), do: "no_such_resource" def message(%{resource: resource}) do "No such resource #{inspect(resource)}" diff --git a/lib/ash/options_helpers.ex b/lib/ash/options_helpers.ex index a4d40a5c7..2cb8c72dd 100644 --- a/lib/ash/options_helpers.ex +++ b/lib/ash/options_helpers.ex @@ -11,4 +11,42 @@ defmodule Ash.OptionsHelpers do def map(value) when is_map(value), do: {:ok, value} def map(_), do: {:error, "must be a map"} + + def ash_type(type) do + type = Ash.Type.get_type(type) + + if type in Ash.Type.builtins() or Ash.Type.ash_type?(type) do + {:ok, type} + else + {:error, "Attribute type must be a built in type or a type module, got: #{inspect(type)}"} + end + end + + def make_required!(options, field) do + Keyword.update!(options, field, &Keyword.put(&1, :required, true)) + end + + def make_optional!(options, field) do + Keyword.update!(options, field, &Keyword.put(&1, :required, false)) + end + + def set_type!(options, field, type) do + Keyword.update!(options, field, &Keyword.put(&1, :type, type)) + end + + def set_default!(options, field, value) do + Keyword.update!(options, field, fn config -> + config + |> Keyword.put(:default, value) + |> Keyword.put(:required, false) + end) + end + + def append_doc!(options, field, to_append) do + Keyword.update!(options, field, fn opt_config -> + Keyword.update(opt_config, :doc, to_append, fn existing -> + existing <> " - " <> to_append + end) + end) + end end diff --git a/lib/ash/query.ex b/lib/ash/query.ex index cfaffa295..1961f6e21 100644 --- a/lib/ash/query.ex +++ b/lib/ash/query.ex @@ -55,7 +55,7 @@ defmodule Ash.Query do @doc false def new(api, resource) when is_atom(api) and is_atom(resource) do - case api.get_resource(resource) do + case Ash.Api.resource(api, resource) do {:ok, resource} -> %__MODULE__{ api: api, diff --git a/lib/ash/resource.ex b/lib/ash/resource.ex index 1388f7000..9f70afd25 100644 --- a/lib/ash/resource.ex +++ b/lib/ash/resource.ex @@ -2,190 +2,64 @@ defmodule Ash.Resource do @moduledoc """ A resource is a static definition of an entity in your system. - Resource DSL documentation: `Ash.Resource.DSL` + Resource DSL documentation: `Ash.Dsl` - For more information on the resource DSL, see `Ash.Resource.DSL` - - Note: - *Do not* call the functions on a resource, as in `MyResource.type()` as this is a *private* - API and can change at any time. Instead, use the `Ash` module, for example: `Ash.type(MyResource)` + For more information on the resource DSL, see `Ash.Dsl` """ - @doc "A list of attribute names that make up the primary key, e.g [:class, :group]" - @callback primary_key() :: [atom] - @doc "A list of relationships to other resources" - @callback relationships() :: [Ash.relationship()] - @doc "A list of actions available for the resource" - @callback actions() :: [Ash.action()] - @doc "A list of attributes on the resource" - @callback attributes() :: [Ash.attribute()] - @doc "A list of extensions implemented by the resource" - @callback extensions() :: [module] - @doc "The data_layer in use by the resource, or nil if there is not one" - @callback data_layer() :: module | nil - @doc "A description of the resource, to be showed in generated documentation" - @callback describe() :: String.t() - @doc "A list of authorizers to be used when accessing the resource" - @callback authorizers() :: [module] - - defmacro __using__(_opts) do - quote do - @before_compile Ash.Resource - @after_compile Ash.Resource - @behaviour Ash.Resource + alias Ash.Dsl.Extension - Ash.Resource.define_resource_module_attributes(__MODULE__) + defmacro __using__(opts) do + extensions = + if opts[:data_layer] do + [opts[:data_layer], Ash.Dsl] + else + [Ash.Dsl] + end - use Ash.Resource.DSL - end - end + extensions = Enum.concat([extensions, opts[:extensions] || [], opts[:authorizers] || []]) - @doc false - def define_resource_module_attributes(mod) do - Module.register_attribute(mod, :before_compile_hooks, accumulate: true) - Module.register_attribute(mod, :actions, accumulate: true) - Module.register_attribute(mod, :attributes, accumulate: true) - Module.register_attribute(mod, :relationships, accumulate: true) - Module.register_attribute(mod, :extensions, accumulate: true) - Module.register_attribute(mod, :authorizers, accumulate: true) - - Module.put_attribute(mod, :data_layer, nil) - Module.put_attribute(mod, :description, nil) - end + body = + quote bind_quoted: [opts: opts] do + @before_compile Ash.Resource + @on_load :build_dsl - defmacro __after_compile__(env, bytecode) do - quote do - case @ash_primary_key do - [] -> - :ok - - [_] -> - :ok - - primary_key -> - if @data_layer && not @data_layer.can?(unquote(env.module), :composite_primary_key) do - raise """ - Resource #{inspect(__MODULE__)} has a composite primary key: #{ - Enum.join(primary_key, ", ") - }, - but its data layer doesnt support composite primary keys - """ - end + @authorizers opts[:authorizers] || [] + @data_layer opts[:data_layer] end - @extensions - |> List.wrap() - |> Enum.filter(&:erlang.function_exported(&1, :after_compile_hook, 1)) - |> Enum.each(fn extension -> - code = extension.after_compile_hook(unquote(Macro.escape(env)), unquote(bytecode)) - Module.eval_quoted(__MODULE__, code) - end) - end + preparations = Extension.prepare(extensions) + + [body | preparations] end # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity - defmacro __before_compile__(env) do + defmacro __before_compile__(_env) do quote do - case Ash.Resource.mark_primaries(@actions) do - {:ok, actions} -> - @sanitized_actions actions - - {:error, {:no_primary, type}} -> - raise Ash.Error.ResourceDslError, - message: - "Multiple actions of type #{type} defined, one must be designated as `primary?: true`", - path: [:actions, type] - - {:error, {:duplicate_primaries, type}} -> - raise Ash.Error.ResourceDslError, - message: - "Multiple actions of type #{type} configured as `primary?: true`, but only one action per type can be the primary", - path: [:actions, type] - end - - @ash_primary_key Ash.Resource.primary_key(@attributes) - - require Ash.Schema - - Ash.Schema.define_schema() - - def relationships do - @relationships - end + @doc false + alias Ash.Dsl.Extension - def actions do - @sanitized_actions - end + :persistent_term.put({__MODULE__, :data_layer}, @data_layer) + :persistent_term.put({__MODULE__, :ash, :authorizers}, @authorizers) - def attributes do - @attributes - end + Extension.set_state(false) - def primary_key do - @ash_primary_key + def raw_dsl do + @ash_dsl_config end - def extensions do - @extensions - end - - def data_layer do - @data_layer - end + @doc false + def build_dsl do + :persistent_term.put({__MODULE__, :data_layer}, @data_layer) + :persistent_term.put({__MODULE__, :ash, :authorizers}, @authorizers) + Extension.set_state(true) - def describe do - @description + :ok end - def authorizers do - @authorizers - end - - @extensions - |> List.wrap() - |> Enum.filter(&:erlang.function_exported(&1, :before_compile_hook, 1)) - |> Enum.each(fn extension -> - code = extension.before_compile_hook(unquote(Macro.escape(env))) - Module.eval_quoted(__MODULE__, code) - end) - end - end - - @doc false - def primary_key(attributes) do - attributes - |> Enum.filter(& &1.primary_key?) - |> Enum.map(& &1.name) - end - - @doc false - def mark_primaries(all_actions) do - actions = - all_actions - |> Enum.group_by(& &1.type) - |> Enum.flat_map(fn {type, actions} -> - case actions do - [action] -> - [%{action | primary?: true}] - - actions -> - check_primaries(actions, type) - end - end) - - Enum.find(actions, fn action -> match?({:error, _}, action) end) || {:ok, actions} - end - - defp check_primaries(actions, type) do - case Enum.count(actions, & &1.primary?) do - 0 -> - [{:error, {:no_primary, type}}] - - 1 -> - actions + require Ash.Schema - _ -> - [{:error, {:duplicate_primaries, type}}] + Ash.Schema.define_schema() end end end diff --git a/lib/ash/resource/actions/actions.ex b/lib/ash/resource/actions/actions.ex deleted file mode 100644 index 45b6feb55..000000000 --- a/lib/ash/resource/actions/actions.ex +++ /dev/null @@ -1,236 +0,0 @@ -defmodule Ash.Resource.Actions do - @moduledoc """ - DSL components for declaring resource actions. - - All manipulation of data through the underlying data layer happens through actions. - There are four types of action: `create`, `read`, `update`, and `destroy`. You may - recognize these from the acronym `CRUD`. You can have multiple actions of the same - type, as long as they have different names. This is the primary mechanism for customizing - your resources to conform to your business logic. It is normal and expected to have - multiple actions of each type in a large application. - - If you have multiple actions of the same type, one of them must be designated as the - primary action for that type, via: `primary?: true`. This tells the ash what to do - if an action of that type is requested, but no specific action name is given. - """ - - @doc false - defmacro actions(do: block) do - quote do - import Ash.Resource.Actions - - unquote(block) - import Ash.Resource.Actions, only: [actions: 1] - end - end - - defmodule CreateDsl do - @moduledoc false - alias Ash.Resource.Actions.Create - require Ash.DslBuilder - keys = Keyword.keys(Create.opt_schema()) -- [:name] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a `create` action. For calling this action, see the `Ash.Api` documentation. - - #{NimbleOptions.docs(Ash.Resource.Actions.Create.opt_schema())} - - ## Examples - ```elixir - create :register, primary?: true - ``` - """ - defmacro create(name, opts \\ []) do - quote do - name = unquote(name) - opts = unquote(Keyword.delete(opts, :do)) - alias Ash.Resource.Actions.Create - - unless is_atom(name) do - raise Ash.Error.ResourceDslError, - message: "action name must be an atom", - path: [:actions, :create] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).CreateDsl - unquote(opts[:do]) - import unquote(__MODULE__).CreateDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - case Create.new(__MODULE__, name, opts) do - {:ok, action} -> - @actions action - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:actions, :create, name] - end - end - end - - defmodule ReadDsl do - @moduledoc false - alias Ash.Resource.Actions.Read - require Ash.DslBuilder - keys = Keyword.keys(Read.opt_schema()) -- [:name] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a `read` action. For calling this action, see the `Ash.Api` documentation. - - #{NimbleOptions.docs(Ash.Resource.Actions.Read.opt_schema())} - - ## Examples - ```elixir - read :read_all, primary?: true - ``` - """ - defmacro read(name, opts \\ []) do - quote do - name = unquote(name) - opts = unquote(Keyword.delete(opts, :do)) - alias Ash.Resource.Actions.Read - - unless is_atom(name) do - raise Ash.Error.ResourceDslError, - message: "action name must be an atom", - path: [:actions, :read] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).ReadDsl - unquote(opts[:do]) - import unquote(__MODULE__).ReadDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - case Read.new(__MODULE__, name, opts) do - {:ok, action} -> - @actions action - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:actions, :read, name] - end - end - end - - defmodule UpdateDsl do - @moduledoc false - alias Ash.Resource.Actions.Update - require Ash.DslBuilder - keys = Keyword.keys(Update.opt_schema()) -- [:name] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares an `update` action. For calling this action, see the `Ash.Api` documentation. - - #{NimbleOptions.docs(Ash.Resource.Actions.Update.opt_schema())} - - ## Examples - ```elixir - update :flag_for_review, primary?: true - ``` - """ - defmacro update(name, opts \\ []) do - quote do - name = unquote(name) - opts = unquote(Keyword.delete(opts, :do)) - alias Ash.Resource.Actions.Update - - unless is_atom(name) do - raise Ash.Error.ResourceDslError, - message: "action name must be an atom", - path: [:actions, :update] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).UpdateDsl - unquote(opts[:do]) - import unquote(__MODULE__).UpdateDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - case Update.new(__MODULE__, name, opts) do - {:ok, action} -> - @actions action - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:actions, :update, name] - end - end - end - - defmodule DestroyDsl do - @moduledoc false - - alias Ash.Resource.Actions.Destroy - require Ash.DslBuilder - keys = Keyword.keys(Destroy.opt_schema()) -- [:name] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares an `destroy` action. For calling this action, see the `Ash.Api` documentation. - - #{NimbleOptions.docs(Ash.Resource.Actions.Destroy.opt_schema())} - - ## Examples - ```elixir - destroy :soft_delete, primary?: true - ``` - """ - defmacro destroy(name, opts \\ []) do - quote do - name = unquote(name) - opts = unquote(Keyword.delete(opts, :do)) - - alias Ash.Resource.Actions.Destroy - - unless is_atom(name) do - raise Ash.Error.ResourceDslError, - message: "action name must be an atom", - path: [:actions, :destroy] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).DestroyDsl - unquote(opts[:do]) - import unquote(__MODULE__).DestroyDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - case Destroy.new(__MODULE__, name, opts) do - {:ok, action} -> - @actions action - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:actions, :destroy, name] - end - end - end -end diff --git a/lib/ash/resource/actions/create.ex b/lib/ash/resource/actions/create.ex index 3fdb0bfdc..ac887e33f 100644 --- a/lib/ash/resource/actions/create.ex +++ b/lib/ash/resource/actions/create.ex @@ -1,6 +1,6 @@ defmodule Ash.Resource.Actions.Create do @moduledoc false - defstruct [:type, :name, :primary?] + defstruct [:name, :primary?, type: :create] @type t :: %__MODULE__{ type: :create, @@ -9,6 +9,11 @@ defmodule Ash.Resource.Actions.Create do } @opt_schema [ + name: [ + type: :atom, + required: true, + doc: "The name of the action" + ], primary?: [ type: :boolean, default: false, @@ -18,20 +23,4 @@ defmodule Ash.Resource.Actions.Create do @doc false def opt_schema, do: @opt_schema - - @spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term} - def new(_resource, name, opts \\ []) do - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :create, - primary?: opts[:primary?] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/actions/destroy.ex b/lib/ash/resource/actions/destroy.ex index 727f74041..ce067c925 100644 --- a/lib/ash/resource/actions/destroy.ex +++ b/lib/ash/resource/actions/destroy.ex @@ -1,7 +1,7 @@ defmodule Ash.Resource.Actions.Destroy do @moduledoc false - defstruct [:type, :name, :primary?] + defstruct [:name, :primary?, type: :destroy] @type t :: %__MODULE__{ type: :destroy, @@ -10,6 +10,10 @@ defmodule Ash.Resource.Actions.Destroy do } @opt_schema [ + name: [ + type: :atom, + doc: "The name of the action" + ], primary?: [ type: :boolean, default: false, @@ -19,21 +23,4 @@ defmodule Ash.Resource.Actions.Destroy do @doc false def opt_schema, do: @opt_schema - - @spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term} - def new(_resource, name, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :destroy, - primary?: opts[:primary?] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/actions/read.ex b/lib/ash/resource/actions/read.ex index 92ced11dc..a5c82409c 100644 --- a/lib/ash/resource/actions/read.ex +++ b/lib/ash/resource/actions/read.ex @@ -1,7 +1,7 @@ defmodule Ash.Resource.Actions.Read do - @moduledoc "The representation of a `read` action" + @moduledoc false - defstruct [:type, :name, :primary?] + defstruct [:name, :primary?, type: :read] @type t :: %__MODULE__{ type: :read, @@ -10,6 +10,10 @@ defmodule Ash.Resource.Actions.Read do } @opt_schema [ + name: [ + type: :atom, + doc: "The name of the action" + ], primary?: [ type: :boolean, default: false, @@ -19,21 +23,4 @@ defmodule Ash.Resource.Actions.Read do @doc false def opt_schema, do: @opt_schema - - @spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term} - def new(_resource, name, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :read, - primary?: opts[:primary?] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/actions/update.ex b/lib/ash/resource/actions/update.ex index 472f771e8..ece7c48a3 100644 --- a/lib/ash/resource/actions/update.ex +++ b/lib/ash/resource/actions/update.ex @@ -1,7 +1,7 @@ defmodule Ash.Resource.Actions.Update do @moduledoc false - defstruct [:type, :name, :primary?] + defstruct [:name, :primary?, type: :update] @type t :: %__MODULE__{ type: :update, @@ -10,6 +10,10 @@ defmodule Ash.Resource.Actions.Update do } @opt_schema [ + name: [ + type: :atom, + doc: "The name of the action" + ], primary?: [ type: :boolean, default: false, @@ -19,21 +23,4 @@ defmodule Ash.Resource.Actions.Update do @doc false def opt_schema, do: @opt_schema - - @spec new(Ash.resource(), atom, Keyword.t()) :: {:ok, t()} | {:error, term} - def new(_resource, name, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :update, - primary?: opts[:primary?] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/attributes/attribute.ex b/lib/ash/resource/attribute.ex similarity index 55% rename from lib/ash/resource/attributes/attribute.ex rename to lib/ash/resource/attribute.ex index e97bc55b6..1315379c8 100644 --- a/lib/ash/resource/attributes/attribute.ex +++ b/lib/ash/resource/attribute.ex @@ -1,4 +1,4 @@ -defmodule Ash.Resource.Attributes.Attribute do +defmodule Ash.Resource.Attribute do @moduledoc false defstruct [ @@ -21,7 +21,17 @@ defmodule Ash.Resource.Attributes.Attribute do writable?: boolean } + alias Ash.OptionsHelpers + @schema [ + name: [ + type: :atom, + doc: "The name of the attribute." + ], + type: [ + type: {:custom, OptionsHelpers, :ash_type, []}, + doc: "The type of the attribute." + ], primary_key?: [ type: :boolean, default: false, @@ -55,6 +65,17 @@ defmodule Ash.Resource.Attributes.Attribute do ] ] + @create_timestamp_schema @schema + |> OptionsHelpers.set_default!(:writable?, false) + |> OptionsHelpers.set_default!(:default, &DateTime.utc_now/0) + |> OptionsHelpers.set_default!(:type, :utc_datetime) + + @update_timestamp_schema @schema + |> OptionsHelpers.set_default!(:writable?, false) + |> OptionsHelpers.set_default!(:default, &DateTime.utc_now/0) + |> OptionsHelpers.set_default!(:update_default, &DateTime.utc_now/0) + |> OptionsHelpers.set_default!(:type, :utc_datetime) + def validate_default(value, _) when is_function(value, 0), do: {:ok, value} def validate_default({:constant, value}, _), do: {:ok, {:constant, value}} @@ -62,56 +83,17 @@ defmodule Ash.Resource.Attributes.Attribute do when is_atom(module) and is_atom(function) and is_list(args), do: {:ok, {module, function, args}} - @doc false - def attribute_schema, do: @schema + def validate_default(nil, _), do: {:ok, nil} - @spec new(Ash.resource(), atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term} - def new(_resource, name, type, opts) do - # Don't call functions on the resource! We don't want it to compile here - with :ok <- validate_type(type), - {:ok, opts} <- NimbleOptions.validate(opts, @schema), - {:default, {:ok, default}} <- {:default, cast_default(type, opts)} do - {:ok, - %__MODULE__{ - name: name, - type: type, - generated?: opts[:generated?], - writable?: opts[:writable?], - allow_nil?: opts[:allow_nil?], - primary_key?: opts[:primary_key?], - update_default: opts[:update_default], - default: default - }} - else - {:error, error} -> {:error, error} - {:default, _} -> {:error, [{:default, "is not a valid default for type #{inspect(type)}"}]} - end + def validate_default(other, _) do + {:error, + "#{inspect(other)} is not a valid default. To provide a constant value, use `{:constant, #{ + inspect(other) + }}`"} end - defp validate_type(type) do - if Ash.Type.ash_type?(type) do - :ok - else - {:error, "#{inspect(type)} is not a valid type"} - end - end - - defp cast_default(type, opts) do - case Keyword.fetch(opts, :default) do - {:ok, default} when is_function(default, 0) -> - {:ok, default} - - {:ok, {mod, func, args}} when is_atom(mod) and is_atom(func) -> - {:ok, {mod, func, args}} - - {:ok, {:constant, default}} -> - case Ash.Type.cast_input(type, default) do - {:ok, value} -> {:ok, {:constant, value}} - :error -> :error - end - - :error -> - {:ok, nil} - end - end + @doc false + def attribute_schema, do: @schema + def create_timestamp_schema, do: @create_timestamp_schema + def update_timestamp_schema, do: @update_timestamp_schema end diff --git a/lib/ash/resource/attributes/attributes.ex b/lib/ash/resource/attributes/attributes.ex deleted file mode 100644 index 3385c930e..000000000 --- a/lib/ash/resource/attributes/attributes.ex +++ /dev/null @@ -1,166 +0,0 @@ -defmodule Ash.Resource.Attributes do - @moduledoc """ - A DSL component for declaring attributes - - Attributes are fields on an instance of a resource. The two required - pieces of knowledge are the field name, and the type. - """ - - @doc false - defmacro attributes(do: block) do - quote do - import Ash.Resource.Attributes - - unquote(block) - - import Ash.Resource.Attributes, only: [attributes: 1] - end - end - - defmodule AttributeDsl do - @moduledoc false - alias Ash.Resource.Attributes.Attribute - require Ash.DslBuilder - keys = Keyword.keys(Attribute.attribute_schema()) -- [:name, :type] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares an attribute on the resource - - Type can be either a built in type (see `Ash.Type`) for more, or a module - implementing the `Ash.Type` behaviour. - - #{NimbleOptions.docs(Ash.Resource.Attributes.Attribute.attribute_schema())} - - ## Examples - ```elixir - attribute :first_name, :string, primary_key?: true - ``` - """ - defmacro attribute(name, type, opts \\ []) do - quote do - name = unquote(name) - type = unquote(type) - opts = unquote(Keyword.delete(opts, :do)) - - alias Ash.Resource.Attributes.Attribute - - unless is_atom(name) do - raise Ash.Error.ResourceDslError, - message: "Attribute name must be an atom, got: #{inspect(name)}", - path: [:attributes, :attribute] - end - - unless is_atom(type) do - raise Ash.Error.ResourceDslError, - message: - "Attribute type must be a built in type or a type module, got: #{inspect(type)}", - path: [:attributes, :attribute, name] - end - - type = Ash.Type.get_type(type) - - unless type in Ash.Type.builtins() or Ash.Type.ash_type?(type) do - raise Ash.Error.ResourceDslError, - message: - "Attribute type must be a built in type or a type module, got: #{inspect(type)}", - path: [:attributes, :attribute, name] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).AttributeDsl - unquote(opts[:do]) - import unquote(__MODULE__).AttributeDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - case Attribute.new(__MODULE__, name, type, opts) do - {:ok, attribute} -> - @attributes attribute - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:attributes, :attribute] - end - end - end - - @timestamp_schema [ - inserted_at_field: [ - type: :atom, - default: :inserted_at, - doc: "The name to use for the inserted at field" - ], - updated_at_field: [ - type: :atom, - default: :updated_at, - doc: "The name to use for the updated at field" - ] - ] - - timestamp_schema = @timestamp_schema - - defmodule TimestampDsl do - @moduledoc false - require Ash.DslBuilder - keys = Keyword.keys(timestamp_schema) - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Adds auto updating timestamp fields - - The field names default to `:inserted_at` and `:updated_at`, but can be overwritten via - passing overrides in the opts, e.g `timestamps(inserted_at: :created_at, updated_at: :last_touched)` - - #{NimbleOptions.docs(@timestamp_schema)} - - ## Examples - ```elixir - attribute :first_name, :string, primary_key?: true - ``` - """ - defmacro timestamps(opts \\ []) do - opts = - case NimbleOptions.validate(opts, @timestamp_schema) do - {:ok, opts} -> - opts - - {:error, message} -> - raise Ash.Error.ApiDslError, - message: message, - path: [:attributes, :timestamps], - message: message - end - - quote do - opts = unquote(Keyword.delete(opts, :do)) - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).TimestampDsl - unquote(opts[:do]) - import unquote(__MODULE__).TimestampDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - inserted_at_name = opts[:inserted_at_field] - updated_at_name = opts[:updated_at_field] - - attribute(inserted_at_name, :utc_datetime, writable?: false, default: &DateTime.utc_now/0) - - attribute(updated_at_name, :utc_datetime, - writable?: false, - default: &DateTime.utc_now/0, - update_default: &DateTime.utc_now/0 - ) - end - end -end diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex deleted file mode 100644 index 259bb4c81..000000000 --- a/lib/ash/resource/dsl.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Ash.Resource.DSL do - @moduledoc """ - The entrypoint for the Ash DSL documentation and interface. - - Available DSL sections: - - * `actions` - `Ash.Resource.Actions` - * `attributes` - `Ash.Resource.Attributes` - * `relationships` - `Ash.Resource.Relationships` - - See the relevant module documentation. To use sections in your resource: - - ```elixir - defmodule MyModule do - use Ash.Resource, name: "foos", type: "foo" - - actions do - ... - # see actions documentation - end - - attributes do - ... - # see attributes documentation - end - - relationships do - ... - # see relationships documentation - end - end - ``` - """ - - defmacro __using__(_) do - quote do - import Ash.Resource.Actions, only: [actions: 1] - import Ash.Resource.Attributes, only: [attributes: 1] - import Ash.Resource.Relationships, only: [relationships: 1] - - defmacro describe(description) do - quote do - @description unquote(description) - end - end - end - end -end diff --git a/lib/ash/resource/relationships/belongs_to.ex b/lib/ash/resource/relationships/belongs_to.ex index cb5a6f775..38450894e 100644 --- a/lib/ash/resource/relationships/belongs_to.ex +++ b/lib/ash/resource/relationships/belongs_to.ex @@ -2,8 +2,6 @@ defmodule Ash.Resource.Relationships.BelongsTo do @moduledoc false defstruct [ :name, - :cardinality, - :type, :destination, :primary_key?, :define_field?, @@ -11,7 +9,9 @@ defmodule Ash.Resource.Relationships.BelongsTo do :destination_field, :source_field, :source, - :reverse_relationship + :reverse_relationship, + cardinality: :one, + type: :belongs_to ] @type t :: %__MODULE__{ @@ -27,10 +27,13 @@ defmodule Ash.Resource.Relationships.BelongsTo do source_field: atom | nil } - import Ash.Resource.Relationships.SharedOptions + import Ash.Resource.Relationships.SharedOptions, only: [shared_options: 0] + + alias Ash.OptionsHelpers @global_opts shared_options() - |> set_default!(:destination_field, :id) + |> OptionsHelpers.set_default!(:destination_field, :id) + |> OptionsHelpers.append_doc!(:source_field, "Defaults to _id") @opt_schema Ash.OptionsHelpers.merge_schemas( [ @@ -46,7 +49,7 @@ defmodule Ash.Resource.Relationships.BelongsTo do "If set to `false` a field is not created on the resource for this relationship, and one must be manually added in `attributes`." ], field_type: [ - type: :any, + type: {:custom, OptionsHelpers, :ash_type, []}, default: :uuid, doc: "The field type of the automatically created field." ] @@ -57,37 +60,4 @@ defmodule Ash.Resource.Relationships.BelongsTo do @doc false def opt_schema, do: @opt_schema - - @spec new( - resource :: Ash.resource(), - name :: atom, - related_resource :: Ash.resource(), - opts :: Keyword.t() - ) :: {:ok, t()} | {:error, term} - - # sobelow_skip ["DOS.BinToAtom"] - def new(resource, name, related_resource, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - source: resource, - type: :belongs_to, - cardinality: :one, - field_type: opts[:field_type], - define_field?: opts[:define_field?], - primary_key?: opts[:primary_key?], - destination: related_resource, - destination_field: opts[:destination_field], - source_field: opts[:source_field] || :"#{name}_id", - reverse_relationship: opts[:reverse_relationship] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/relationships/has_many.ex b/lib/ash/resource/relationships/has_many.ex index 639a9747a..e4c062546 100644 --- a/lib/ash/resource/relationships/has_many.ex +++ b/lib/ash/resource/relationships/has_many.ex @@ -2,13 +2,13 @@ defmodule Ash.Resource.Relationships.HasMany do @moduledoc false defstruct [ :name, - :type, - :cardinality, :destination, :destination_field, :source_field, :source, - :reverse_relationship + :reverse_relationship, + cardinality: :many, + type: :has_many ] @type t :: %__MODULE__{ @@ -24,10 +24,11 @@ defmodule Ash.Resource.Relationships.HasMany do } import Ash.Resource.Relationships.SharedOptions + alias Ash.OptionsHelpers @global_opts shared_options() - |> make_required!(:destination_field) - |> set_default!(:source_field, :id) + |> OptionsHelpers.make_required!(:destination_field) + |> OptionsHelpers.set_default!(:source_field, :id) @opt_schema Ash.OptionsHelpers.merge_schemas( [], @@ -37,32 +38,4 @@ defmodule Ash.Resource.Relationships.HasMany do @doc false def opt_schema, do: @opt_schema - - @spec new( - resource :: Ash.resource(), - name :: atom, - related_resource :: Ash.resource(), - opts :: Keyword.t() - ) :: {:ok, t()} | {:error, term} - # sobelow_skip ["DOS.BinToAtom"] - def new(resource, name, related_resource, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - source: resource, - type: :has_many, - cardinality: :many, - destination: related_resource, - destination_field: opts[:destination_field], - source_field: opts[:source_field], - reverse_relationship: opts[:reverse_relationship] - }} - - {:error, error} -> - {:error, error} - end - end end diff --git a/lib/ash/resource/relationships/has_one.ex b/lib/ash/resource/relationships/has_one.ex index a122f08fa..c59530c42 100644 --- a/lib/ash/resource/relationships/has_one.ex +++ b/lib/ash/resource/relationships/has_one.ex @@ -1,16 +1,15 @@ defmodule Ash.Resource.Relationships.HasOne do @moduledoc false - @doc false defstruct [ :name, - :type, :source, - :cardinality, :destination, :destination_field, :source_field, :reverse_relationship, - :allow_orphans? + :allow_orphans?, + cardinality: :one, + type: :has_one ] @type t :: %__MODULE__{ @@ -27,10 +26,11 @@ defmodule Ash.Resource.Relationships.HasOne do } import Ash.Resource.Relationships.SharedOptions + alias Ash.OptionsHelpers @global_opts shared_options() - |> make_required!(:destination_field) - |> set_default!(:source_field, :id) + |> OptionsHelpers.make_required!(:destination_field) + |> OptionsHelpers.set_default!(:source_field, :id) @opt_schema Ash.OptionsHelpers.merge_schemas( [], @@ -40,32 +40,4 @@ defmodule Ash.Resource.Relationships.HasOne do @doc false def opt_schema, do: @opt_schema - - @spec new( - resource :: Ash.resource(), - name :: atom, - related_resource :: Ash.resource(), - opts :: Keyword.t() - ) :: {:ok, t()} | {:error, term} - # sobelow_skip ["DOS.BinToAtom"] - def new(resource, name, related_resource, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - source: resource, - type: :has_one, - cardinality: :one, - destination: related_resource, - destination_field: opts[:destination_field], - source_field: opts[:source_field], - reverse_relationship: opts[:reverse_relationship] - }} - - {:error, errors} -> - {:error, errors} - end - end end diff --git a/lib/ash/resource/relationships/many_to_many.ex b/lib/ash/resource/relationships/many_to_many.ex index 6045b5d11..d51467c1f 100644 --- a/lib/ash/resource/relationships/many_to_many.ex +++ b/lib/ash/resource/relationships/many_to_many.ex @@ -2,16 +2,16 @@ defmodule Ash.Resource.Relationships.ManyToMany do @moduledoc false defstruct [ :name, - :type, :source, :through, - :cardinality, :destination, :source_field, :destination_field, :source_field_on_join_table, :destination_field_on_join_table, - :reverse_relationship + :reverse_relationship, + cardinality: :many, + type: :many_to_many ] @type t :: %__MODULE__{ @@ -29,10 +29,11 @@ defmodule Ash.Resource.Relationships.ManyToMany do } import Ash.Resource.Relationships.SharedOptions + alias Ash.OptionsHelpers @global_opts shared_options() - |> set_default!(:destination_field, :id) - |> set_default!(:source_field, :id) + |> OptionsHelpers.set_default!(:destination_field, :id) + |> OptionsHelpers.set_default!(:source_field, :id) @opt_schema Ash.OptionsHelpers.merge_schemas( [ @@ -60,35 +61,4 @@ defmodule Ash.Resource.Relationships.ManyToMany do @doc false def opt_schema, do: @opt_schema - - @spec new( - resource :: Ash.resource(), - name :: atom, - related_resource :: Ash.resource(), - opts :: Keyword.t() - ) :: {:ok, t()} | {:error, term} - # sobelow_skip ["DOS.BinToAtom"] - def new(resource, name, related_resource, opts \\ []) do - # Don't call functions on the resource! We don't want it to compile here - case NimbleOptions.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :many_to_many, - source: resource, - cardinality: :many, - through: opts[:through], - destination: related_resource, - reverse_relationship: opts[:reverse_relationship], - source_field: opts[:source_field], - destination_field: opts[:destination_field], - source_field_on_join_table: opts[:source_field_on_join_table], - destination_field_on_join_table: opts[:destination_field_on_join_table] - }} - - {:error, errors} -> - {:error, errors} - end - end end diff --git a/lib/ash/resource/relationships/relationships.ex b/lib/ash/resource/relationships/relationships.ex deleted file mode 100644 index 4cc8ac69a..000000000 --- a/lib/ash/resource/relationships/relationships.ex +++ /dev/null @@ -1,353 +0,0 @@ -defmodule Ash.Resource.Relationships do - @moduledoc """ - DSL components for declaring relationships. - - Relationships are a core component of resource oriented design. Many components of Ash - will use these relationships. A simple use case is side_loading (done via the `side_load` - option, given to an api action). - """ - - @doc false - defmacro relationships(do: block) do - quote do - import Ash.Resource.Relationships - - unquote(block) - - import Ash.Resource.Relationships, only: [relationships: 1] - end - end - - defmodule HasOneDsl do - @moduledoc false - alias Ash.Resource.Relationships.HasOne - require Ash.DslBuilder - keys = Keyword.keys(HasOne.opt_schema()) -- [:name, :destination] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a has_one relationship. In a relationsal database, the foreign key would be on the *other* table. - - Generally speaking, a `has_one` also implies that the destination table is unique on that foreign key. - - Practically speaking, a has_one and a belongs_to are interchangable in every way. - - #{NimbleOptions.docs(Ash.Resource.Relationships.HasOne.opt_schema())} - - ## Examples - ```elixir - # In a resource called `Word` - has_one :dictionary_entry, DictionaryEntry, - source_field: :text, - destination_field: :word_text - ``` - - """ - defmacro has_one(relationship_name, destination, opts \\ []) do - quote do - relationship_name = unquote(relationship_name) - destination = unquote(destination) - opts = unquote(Keyword.delete(opts, :do)) - - alias Ash.Resource.Relationships.HasOne - - unless is_atom(relationship_name) do - raise Ash.Error.ResourceDslError, - message: "relationship_name must be an atom", - path: [:relationships, :has_one] - end - - unless is_atom(destination) do - raise Ash.Error.ResourceDslError, - message: "related resource must be a module representing a resource", - path: [:relationships, :has_one, relationship_name] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).HasOneDsl - unquote(opts[:do]) - import unquote(__MODULE__).HasOneDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - relationship = - HasOne.new( - __MODULE__, - relationship_name, - destination, - opts - ) - - case relationship do - {:ok, relationship} -> - @relationships relationship - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :has_one, relationship_name] - end - end - end - - defmodule BelongsToDsl do - @moduledoc false - require Ash.DslBuilder - - alias Ash.Resource.Relationships.BelongsTo - - keys = Keyword.keys(BelongsTo.opt_schema()) -- [:name, :destination] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a belongs_to relationship. In a relational database, the foreign key would be on the *source* table. - - This creates a field on the resource with the corresponding name, unless `define_field?: false` is provided. - - Practically speaking, a belongs_to and a has_one are interchangable in every way. - - #{NimbleOptions.docs(Ash.Resource.Relationships.BelongsTo.opt_schema())} - - ## Examples - ```elixir - # In a resource called `Word` - belongs_to :dictionary_entry, DictionaryEntry, - source_field: :text, - destination_field: :word_text - ``` - - """ - defmacro belongs_to(relationship_name, destination, opts \\ []) do - quote do - relationship_name = unquote(relationship_name) - destination = unquote(destination) - opts = unquote(Keyword.delete(opts, :do)) - - alias Ash.Resource.Relationships - - unless is_atom(relationship_name) do - raise Ash.Error.ResourceDslError, - message: "relationship_name must be an atom", - path: [:relationships, :belongs_to] - end - - unless is_atom(destination) do - raise Ash.Error.ResourceDslError, - message: "related resource must be a module representing a resource", - path: [:relationships, :belongs_to, relationship_name] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).BelongsTo - unquote(opts[:do]) - import unquote(__MODULE__).BelongsTo, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - relationship = - Relationships.BelongsTo.new( - __MODULE__, - relationship_name, - destination, - opts - ) - - case relationship do - {:ok, relationship} -> - Relationships.maybe_define_attribute(relationship, relationship_name) - - @relationships relationship - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :belongs_to, relationship_name] - end - end - end - - defmacro maybe_define_attribute(relationship, relationship_name) do - quote bind_quoted: [relationship: relationship, relationship_name: relationship_name] do - alias Ash.Resource.Attributes.Attribute - - if relationship.define_field? do - case Attribute.new( - __MODULE__, - relationship.source_field, - relationship.field_type, - primary_key?: relationship.primary_key? - ) do - {:ok, attribute} -> - @attributes attribute - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :belongs_to, relationship_name] - end - end - end - end - - defmodule HasManyDsl do - @moduledoc false - require Ash.DslBuilder - alias Ash.Resource.Relationships.HasMany - keys = Keyword.keys(HasMany.opt_schema()) -- [:name, :destination] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a has_many relationship. There can be any number of related entities. - - #{NimbleOptions.docs(Ash.Resource.Relationships.HasMany.opt_schema())} - - ## Examples - ```elixir - # In a resource called `Word` - has_many :definitions, DictionaryDefinition, - source_field: :text, - destination_field: :word_text - ``` - """ - defmacro has_many(relationship_name, destination, opts \\ []) do - quote do - relationship_name = unquote(relationship_name) - destination = unquote(destination) - opts = unquote(Keyword.delete(opts, :do)) - alias Ash.Resource.Relationships - - unless is_atom(relationship_name) do - raise Ash.Error.ResourceDslError, - message: "relationship_name must be an atom", - path: [:relationships, :has_many] - end - - unless is_atom(destination) do - raise Ash.Error.ResourceDslError, - message: "related resource must be a module representing a resource", - path: [:relationships, :has_many, relationship_name] - end - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).HasManyDsl - unquote(opts[:do]) - import unquote(__MODULE__).HasManyDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - relationship = - Relationships.HasMany.new( - __MODULE__, - relationship_name, - destination, - opts - ) - - case relationship do - {:ok, relationship} -> - @relationships relationship - - {:error, message} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :has_many, relationship_name] - end - end - end - - defmodule ManyToManyDsl do - @moduledoc false - require Ash.DslBuilder - alias Ash.Resource.Relationships.ManyToMany - - keys = Keyword.keys(ManyToMany.opt_schema()) -- [:name, :destination] - - Ash.DslBuilder.build_dsl(keys) - end - - @doc """ - Declares a many_to_many relationship. Many to many relationships require a join table. - - A join table is typically a table who's primary key consists of one foreign key to each resource. - - You can specify a join table as a string or as another resource. - - #{NimbleOptions.docs(Ash.Resource.Relationships.ManyToMany.opt_schema())} - - ## Examples - ```elixir - # In a resource called `Word` - many_to_many :books, Book, - through: BookWord, - source_field: :text, - source_field_on_join_table: :word_text, - destination_field: :id, - destination_field_on_join_table: :book_id - ``` - """ - defmacro many_to_many(relationship_name, destination, opts \\ []) do - quote do - relationship_name = unquote(relationship_name) - destination = unquote(destination) - opts = unquote(Keyword.delete(opts, :do)) - - alias Ash.Resource.Relationships - - Module.register_attribute(__MODULE__, :dsl_opts, accumulate: true) - import unquote(__MODULE__).ManyToManyDsl - unquote(opts[:do]) - import unquote(__MODULE__).ManyToManyDsl, only: [] - - opts = Keyword.merge(opts, @dsl_opts) - - Module.delete_attribute(__MODULE__, :dsl_opts) - - many_to_many = - Relationships.ManyToMany.new( - __MODULE__, - relationship_name, - destination, - opts - ) - - has_many_name = String.to_atom(to_string(relationship_name) <> "_join_assoc") - - has_many = - Relationships.HasMany.new( - __MODULE__, - has_many_name, - opts[:through], - destination_field: opts[:source_field_on_join_table], - source_field: opts[:source_field] || :id - ) - - with {:many_to_many, {:ok, many_to_many}} <- {:many_to_many, many_to_many}, - {:has_many, {:ok, has_many}} <- {:has_many, has_many} do - @relationships many_to_many - @relationships has_many - else - {:many_to_many, {:error, message}} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :many_to_many, relationship_name] - - {:has_many, {:error, message}} -> - raise Ash.Error.ResourceDslError, - message: message, - path: [:relationships, :many_to_many, has_many_name] - end - end - end -end diff --git a/lib/ash/resource/relationships/shared_options.ex b/lib/ash/resource/relationships/shared_options.ex index 7b730fd39..b3d1ada7e 100644 --- a/lib/ash/resource/relationships/shared_options.ex +++ b/lib/ash/resource/relationships/shared_options.ex @@ -2,6 +2,14 @@ defmodule Ash.Resource.Relationships.SharedOptions do @moduledoc false @shared_options [ + name: [ + type: :atom, + doc: "The name of the relationship" + ], + destination: [ + type: :atom, + doc: "The destination resource" + ], destination_field: [ type: :atom, doc: @@ -22,12 +30,4 @@ defmodule Ash.Resource.Relationships.SharedOptions do def shared_options do @shared_options end - - def make_required!(options, field) do - Keyword.update!(options, field, &Keyword.put(&1, :required, true)) - end - - def set_default!(options, field, value) do - Keyword.update!(options, field, &Keyword.put(&1, :default, value)) - end end diff --git a/lib/ash/resource/schema.ex b/lib/ash/resource/schema.ex index a7babfb73..b9e5d37a6 100644 --- a/lib/ash/resource/schema.ex +++ b/lib/ash/resource/schema.ex @@ -7,12 +7,12 @@ defmodule Ash.Schema do # schema for persistence. defmacro define_schema do - quote do + quote unquote: false do use Ecto.Schema @primary_key false schema "" do - for attribute <- @attributes do + for attribute <- Ash.attributes(__MODULE__) do read_after_writes? = attribute.generated? and is_nil(attribute.default) field(attribute.name, Ash.Type.ecto_type(attribute.type), @@ -21,7 +21,9 @@ defmodule Ash.Schema do ) end - for relationship <- Enum.filter(@relationships, &(&1.type == :belongs_to)) do + relationships = Ash.relationships(__MODULE__) + + for relationship <- Enum.filter(relationships, &(&1.type == :belongs_to)) do belongs_to(relationship.name, relationship.destination, define_field: false, foreign_key: relationship.source_field, @@ -29,21 +31,21 @@ defmodule Ash.Schema do ) end - for relationship <- Enum.filter(@relationships, &(&1.type == :has_one)) do + for relationship <- Enum.filter(relationships, &(&1.type == :has_one)) do has_one(relationship.name, relationship.destination, foreign_key: relationship.destination_field, references: relationship.source_field ) end - for relationship <- Enum.filter(@relationships, &(&1.type == :has_many)) do + for relationship <- Enum.filter(relationships, &(&1.type == :has_many)) do has_many(relationship.name, relationship.destination, foreign_key: relationship.destination_field, references: relationship.source_field ) end - for relationship <- Enum.filter(@relationships, &(&1.type == :many_to_many)) do + for relationship <- Enum.filter(relationships, &(&1.type == :many_to_many)) do many_to_many(relationship.name, relationship.destination, join_through: relationship.through, join_keys: [ diff --git a/lib/ash/resource/transformers/belongs_to_attribute.ex b/lib/ash/resource/transformers/belongs_to_attribute.ex new file mode 100644 index 000000000..18347fcc2 --- /dev/null +++ b/lib/ash/resource/transformers/belongs_to_attribute.ex @@ -0,0 +1,45 @@ +defmodule Ash.Resource.Transformers.BelongsToAttribute do + @moduledoc """ + Creates the attribute for belongs_to relationships that have `define_field?: true` + """ + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + alias Ash.Error.ResourceDslError + + @extension Ash.Dsl + + def transform(_resource, dsl_state) do + dsl_state + |> Transformer.get_entities([:relationships], @extension) + |> Enum.filter(&(&1.type == :belongs_to)) + |> Enum.filter(& &1.define_field?) + |> Enum.reject(fn relationship -> + dsl_state + |> Transformer.get_entities([:attributes], @extension) + |> Enum.find(&(Map.get(&1, :name) == relationship.source_field)) + end) + |> Enum.reduce_while({:ok, dsl_state}, fn relationship, {:ok, dsl_state} -> + case Transformer.build_entity(@extension, [:attributes], :attribute, + name: relationship.source_field, + type: relationship.field_type, + primary_key?: relationship.primary_key? + ) do + {:ok, attribute} -> + {:cont, {:ok, Transformer.add_entity(dsl_state, [:attributes], @extension, attribute)}} + + {:error, error} -> + {:halt, + {:error, + ResourceDslError.exception( + message: + "Could not create attribute for belongs_to #{relationship.name}: #{inspect(error)}", + path: [:relationships, relationship.name] + )}} + end + end) + end + + def after?(Ash.Resource.Transformers.BelongsToSourceField), do: true + def after?(_), do: false +end diff --git a/lib/ash/resource/transformers/belongs_to_source_field.ex b/lib/ash/resource/transformers/belongs_to_source_field.ex new file mode 100644 index 000000000..7484ee751 --- /dev/null +++ b/lib/ash/resource/transformers/belongs_to_source_field.ex @@ -0,0 +1,30 @@ +defmodule Ash.Resource.Transformers.BelongsToSourceField do + @moduledoc """ + Sets the default `source_field` for belongs_to attributes + """ + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + + @extension Ash.Dsl + + # sobelow_skip ["DOS.BinToAtom"] + def transform(_resource, dsl_state) do + dsl_state + |> Transformer.get_entities([:relationships], @extension) + |> Enum.filter(&(&1.type == :belongs_to)) + |> Enum.reject(& &1.source_field) + |> Enum.reduce({:ok, dsl_state}, fn relationship, {:ok, dsl_state} -> + new_dsl_state = + Transformer.replace_entity( + dsl_state, + [:relationships], + @extension, + %{relationship | source_field: :"#{relationship.name}_id"}, + &(&1.name == relationship.name) + ) + + {:ok, new_dsl_state} + end) + end +end diff --git a/lib/ash/resource/transformers/cache_primary_key.ex b/lib/ash/resource/transformers/cache_primary_key.ex new file mode 100644 index 000000000..eeeedb900 --- /dev/null +++ b/lib/ash/resource/transformers/cache_primary_key.ex @@ -0,0 +1,39 @@ +defmodule Ash.Resource.Transformers.CachePrimaryKey do + @moduledoc "Validates the primary key of a resource, and caches it in `:persistent_term` for fast access" + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + + @extension Ash.Dsl + + def transform(resource, dsl_state) do + primary_key = + dsl_state + |> Transformer.get_entities([:attributes], @extension) + |> Enum.filter(& &1.primary_key?) + |> Enum.map(& &1.name) + + case primary_key do + [] -> + {:ok, dsl_state} + + [field] -> + Transformer.persist_to_runtime(resource, {resource, :primary_key}, [field]) + + {:ok, dsl_state} + + fields -> + if Ash.data_layer(resource) && Ash.data_layer_can?(resource, :composite_primary_key) do + Transformer.persist_to_runtime(resource, {resource, :primary_key}, fields) + + {:ok, dsl_state} + else + {:error, "Data layer does not support composite primary keys"} + end + end + end + + def after?(Ash.Resource.Transformers.BelongsToAttribute), do: true + + def after?(_), do: false +end diff --git a/lib/ash/resource/transformers/create_join_relationship.ex b/lib/ash/resource/transformers/create_join_relationship.ex new file mode 100644 index 000000000..9f266c64a --- /dev/null +++ b/lib/ash/resource/transformers/create_join_relationship.ex @@ -0,0 +1,43 @@ +defmodule Ash.Resource.Transformers.CreateJoinRelationship do + @moduledoc """ + Creates an automatically named `has_many` relationship for each many_to_many. + + This will likely not be around for long, as our logic around many to many relationships + will update soon. + """ + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + + @extension Ash.Dsl + + # sobelow_skip ["DOS.StringToAtom"] + def transform(_resource, dsl_state) do + dsl_state + |> Transformer.get_entities([:relationships], @extension) + |> Enum.filter(&(&1.type == :many_to_many)) + |> Enum.reject(fn relationship -> + has_many_name = to_string(relationship.name) <> "_join_assoc" + + dsl_state + |> Transformer.get_entities([:relationships], @extension) + |> Enum.find(&(to_string(&1.name) == has_many_name)) + end) + |> Enum.reduce({:ok, dsl_state}, fn relationship, {:ok, dsl_state} -> + has_many_name = String.to_atom(to_string(relationship.name) <> "_join_assoc") + + {:ok, relationship} = + Transformer.build_entity(@extension, [:relationships], :has_many, + name: has_many_name, + destination: relationship.through, + destination_field: relationship.source_field_on_join_table, + source_field: relationship.source_field + ) + + {:ok, Transformer.add_entity(dsl_state, [:relationships], @extension, relationship)} + end) + end + + def before?(Ash.Resource.Transformers.SetRelationshipSource), do: true + def before?(_), do: false +end diff --git a/lib/ash/resource/transformers/set_primary_actions.ex b/lib/ash/resource/transformers/set_primary_actions.ex new file mode 100644 index 000000000..0819ac593 --- /dev/null +++ b/lib/ash/resource/transformers/set_primary_actions.ex @@ -0,0 +1,59 @@ +defmodule Ash.Resource.Transformers.SetPrimaryActions do + @moduledoc """ + Creates/validates the primary action configuration + + If only one action of a given type is defined, it is marked + as primary. If multiple exist, and one is not primary, + this results in an error. + """ + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + alias Ash.Error.ResourceDslError + + @extension Ash.Dsl + + def transform(_resource, dsl_state) do + dsl_state + |> Transformer.get_entities([:actions], @extension) + |> Enum.group_by(& &1.type) + |> Enum.reduce_while({:ok, dsl_state}, fn + {type, [action]}, {:ok, dsl_state} -> + {:cont, + {:ok, + Transformer.replace_entity( + dsl_state, + [:actions], + @extension, + %{action | primary?: true}, + fn replacing_action -> + replacing_action.name == action.name && replacing_action.type == type + end + )}} + + {type, actions}, {:ok, dsl_state} -> + case Enum.count(actions, & &1.primary?) do + 0 -> + {:halt, + {:error, + ResourceDslError.exception( + message: + "Multiple actions of type create defined, one must be designated as `primary?: true`", + path: [:actions, type] + )}} + + 1 -> + {:cont, {:ok, dsl_state}} + + 2 -> + {:halt, + {:error, + ResourceDslError.exception( + message: + "Multiple actions of type #{type} configured as `primary?: true`, but only one action per type can be the primary", + path: [:actions, type] + )}} + end + end) + end +end diff --git a/lib/ash/resource/transformers/set_relationship_source.ex b/lib/ash/resource/transformers/set_relationship_source.ex new file mode 100644 index 000000000..e0203ed5b --- /dev/null +++ b/lib/ash/resource/transformers/set_relationship_source.ex @@ -0,0 +1,29 @@ +defmodule Ash.Resource.Transformers.SetRelationshipSource do + @moduledoc "Sets the `source` key on relationships to be the resource they were defined on" + use Ash.Dsl.Transformer + + alias Ash.Dsl.Transformer + + @extension Ash.Dsl + + def transform(resource, dsl_state) do + dsl_state + |> Transformer.get_entities([:relationships], @extension) + |> Enum.reduce({:ok, dsl_state}, fn relationship, {:ok, dsl_state} -> + new_relationship = %{relationship | source: resource} + + new_dsl_state = + Transformer.replace_entity( + dsl_state, + [:relationships], + @extension, + new_relationship, + fn replacing -> + replacing.name == relationship.name + end + ) + + {:ok, new_dsl_state} + end) + end +end diff --git a/lib/ash/type/type.ex b/lib/ash/type/type.ex index 7f3bace58..0470abae0 100644 --- a/lib/ash/type/type.ex +++ b/lib/ash/type/type.ex @@ -41,13 +41,17 @@ defmodule Ash.Type do @type t :: module | atom @spec get_type(atom | module) :: atom | module - def get_type(value) do + def get_type(value) when is_atom(value) do case Keyword.fetch(@short_names, value) do {:ok, mod} -> mod :error -> value end end + def get_type(value) do + value + end + @spec supports_filter?(Ash.resource(), t(), Ash.DataLayer.filter_type(), Ash.data_layer()) :: boolean def supports_filter?(resource, type, filter_type, _data_layer) when type in @builtin_names do diff --git a/mix.exs b/mix.exs index 24e0985c7..782076d8a 100644 --- a/mix.exs +++ b/mix.exs @@ -33,7 +33,32 @@ defmodule Ash.MixProject do defp docs do # The main page in the docs - [main: "readme", extras: ["README.md"]] + [ + main: "Ash", + source_ref: "v#{@version}", + extras: ["documentation/Getting Started.md"], + logo: "logos/small-logo.png", + groups_for_modules: [ + entrypoint: [ + Ash, + Ash.Api, + Ash.Resource, + Ash.Dsl, + Ash.Query + ], + type: ~r/Ash.Type/, + data_layer: ~r/Ash.DataLayer/, + authorizer: ~r/Ash.Authorizer/, + extension: [ + Ash.Dsl.Entity, + Ash.Dsl.Extension, + Ash.Dsl.Section + ], + "resource dsl transformers": ~r/Ash.Resource.Transformers/, + "resource dsl": ~r/Ash.Dsl/, + "api dsl": ~r/Ash.Api.Dsl/ + ] + ] end defp package do @@ -55,17 +80,18 @@ defmodule Ash.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ecto, "~> 3.0"}, + {:ecto, "~> 3.4"}, {:ets, "~> 0.8.0"}, - {:ex_doc, "~> 0.21", only: :dev, runtime: false}, + {:ex_doc, "~> 0.22", only: :dev, runtime: false}, {:ex_check, "~> 0.11.0", only: :dev}, {:credo, ">= 0.0.0", only: :dev, runtime: false}, {:dialyxir, ">= 0.0.0", only: :dev, runtime: false}, {:sobelow, ">= 0.0.0", only: :dev, runtime: false}, {:git_ops, "~> 2.0.0", only: :dev}, - {:picosat_elixir, github: "zachdaniel/picosat_elixir", ref: "patch-1"}, + {:picosat_elixir, "~> 0.1.4"}, {:nimble_options, "~> 0.2.1"}, - {:excoveralls, "~> 0.13.0", only: [:dev, :test]} + {:excoveralls, "~> 0.13.0", only: [:dev, :test]}, + {:benchee, "~> 1.0.1", only: [:dev]} ] end diff --git a/mix.lock b/mix.lock index f458b33c7..39d712fb8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,42 +1,44 @@ %{ "ashton": {:hex, :ashton, "0.4.1", "d0f7782ac44fa22da7ce544028ee3d2078592a834d8adf3e5b4b6aeb94413a55", [:mix], [], "hexpm", "24db667932517fdbc3f2dae777f28b8d87629271387d4490bc4ae8d9c46ff3d3"}, + "benchee": {:hex, :benchee, "1.0.1", "66b211f9bfd84bd97e6d1beaddf8fc2312aaabe192f776e8931cb0c16f53a521", [:mix], [{:deep_merge, "~> 1.0", [hex: :deep_merge, repo: "hexpm", optional: false]}], "hexpm", "3ad58ae787e9c7c94dd7ceda3b587ec2c64604563e049b2a0e8baafae832addb"}, "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, "certifi": {:hex, :certifi, "2.5.2", "b7cfeae9d2ed395695dd8201c57a2d019c0c43ecaf8b8bcb9320b40d6662f340", [:rebar3], [{:parse_trans, "~>3.3", [hex: :parse_trans, repo: "hexpm", optional: false]}], "hexpm", "3b3b5f36493004ac3455966991eaf6e768ce9884693d9968055aeeeb1e575040"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "credo": {:hex, :credo, "1.4.0", "92339d4cbadd1e88b5ee43d427b639b68a11071b6f73854e33638e30a0ea11f5", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "1fd3b70dce216574ce3c18bdf510b57e7c4c85c2ec9cad4bff854abaf7e58658"}, "dataloader": {:hex, :dataloader, "1.0.6", "fb724d6d3fb6acb87d27e3b32dea3a307936ad2d245faf9cf5221d1323d6a4ba", [:mix], [{:ecto, ">= 0.0.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm"}, "db_connection": {:hex, :db_connection, "2.1.1", "a51e8a2ee54ef2ae6ec41a668c85787ed40cb8944928c191280fe34c15b76ae5", [:mix], [{:connection, "~> 1.0.2", [hex: :connection, repo: "hexpm", optional: false]}], "hexpm"}, - "decimal": {:hex, :decimal, "1.8.0", "ca462e0d885f09a1c5a342dbd7c1dcf27ea63548c65a65e67334f4b61803822e", [:mix], [], "hexpm", "52694ef56e60108e5012f8af9673874c66ed58ac1c4fae9b5b7ded31786663f5"}, + "decimal": {:hex, :decimal, "1.8.1", "a4ef3f5f3428bdbc0d35374029ffcf4ede8533536fa79896dd450168d9acdf3c", [:mix], [], "hexpm", "3cb154b00225ac687f6cbd4acc4b7960027c757a5152b369923ead9ddbca7aec"}, + "deep_merge": {:hex, :deep_merge, "1.0.0", "b4aa1a0d1acac393bdf38b2291af38cb1d4a52806cf7a4906f718e1feb5ee961", [:mix], [], "hexpm", "ce708e5f094b9cd4e8f2be4f00d2f4250c4095be93f8cd6d018c753894885430"}, "dialyxir": {:hex, :dialyxir, "1.0.0", "6a1fa629f7881a9f5aaf3a78f094b2a51a0357c843871b8bc98824e7342d00a5", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "aeb06588145fac14ca08d8061a142d52753dbc2cf7f0d00fc1013f53f8654654"}, - "earmark": {:hex, :earmark, "1.4.3", "364ca2e9710f6bff494117dbbd53880d84bebb692dafc3a78eb50aa3183f2bfd", [:mix], [], "hexpm", "8cf8a291ebf1c7b9539e3cddb19e9cef066c2441b1640f13c34c1d3cfc825fec"}, - "ecto": {:hex, :ecto, "3.2.5", "76c864b77948a479e18e69cc1d0f0f4ee7cced1148ffe6a093ff91eba644f0b5", [:mix], [{:decimal, "~> 1.6", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "01251d9b28081b7e0af02a1875f9b809b057f064754ca3b274949d5216ea6f5f"}, + "earmark": {:hex, :earmark, "1.4.5", "62ffd3bd7722fb7a7b1ecd2419ea0b458c356e7168c1f5d65caf09b4fbdd13c8", [:mix], [], "hexpm", "b7d0e6263d83dc27141a523467799a685965bf8b13b6743413f19a7079843f4f"}, + "ecto": {:hex, :ecto, "3.4.4", "a2c881e80dc756d648197ae0d936216c0308370332c5e77a2325a10293eef845", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cc4bd3ad62abc3b21fb629f0f7a3dab23a192fca837d257dd08449fba7373561"}, "elixir_make": {:hex, :elixir_make, "0.6.0", "38349f3e29aff4864352084fc736fa7fa0f2995a819a737554f7ebd28b85aaab", [:mix], [], "hexpm", "d522695b93b7f0b4c0fcb2dfe73a6b905b1c301226a5a55cb42e5b14d509e050"}, "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, "ets": {:hex, :ets, "0.8.0", "90153faafd289bb0801a537d5b05661f46d5e70b2bb55cccf5ab7f0d41d07832", [:mix], [], "hexpm", "bda4e05b16eada36798cfda16db551dc5243c0adc9a6dfe655b1bc1279b99cb8"}, "etso": {:hex, :etso, "0.1.1", "bfc5e30483d397774a64981fc93511d3ed0dcb9d19bc3cba03df7b9555a68636", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm"}, "ex_check": {:hex, :ex_check, "0.11.0", "6d878d9ae30d19168157bcbf346b527825284e14e77a07ec0492b19cf0036479", [:mix], [], "hexpm", "d41894aa6193f089a05e3abb43ca457e289619fcfbbdd7b60d070b7a62b26832"}, - "ex_doc": {:hex, :ex_doc, "0.21.2", "caca5bc28ed7b3bdc0b662f8afe2bee1eedb5c3cf7b322feeeb7c6ebbde089d6", [:mix], [{:earmark, "~> 1.3.3 or ~> 1.4", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "f1155337ae17ff7a1255217b4c1ceefcd1860b7ceb1a1874031e7a861b052e39"}, + "ex_doc": {:hex, :ex_doc, "0.22.1", "9bb6d51508778193a4ea90fa16eac47f8b67934f33f8271d5e1edec2dc0eee4c", [:mix], [{:earmark, "~> 1.4.0", [hex: :earmark, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}], "hexpm", "d957de1b75cb9f78d3ee17820733dc4460114d8b1e11f7ee4fd6546e69b1db60"}, "excoveralls": {:hex, :excoveralls, "0.13.0", "4e1b7cc4e0351d8d16e9be21b0345a7e165798ee5319c7800b9138ce17e0b38e", [:mix], [{:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "fe2a56c8909564e2e6764765878d7d5e141f2af3bc8ff3b018a68ee2a218fced"}, "git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"}, "git_ops": {:hex, :git_ops, "2.0.0", "d720b54de2ce9ca242164c57c982e4f05c1b6c020db2785e338f93b6190980aa", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 0.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "9aa270ea1cd4500eac4f38cac9b24019eee0aa524b96c95ad2f90cb0010840db"}, "hackney": {:hex, :hackney, "1.16.0", "5096ac8e823e3a441477b2d187e30dd3fff1a82991a806b2003845ce72ce2d84", [:rebar3], [{:certifi, "2.5.2", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "6.0.1", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "1.0.1", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.0", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "1.1.6", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "3bf0bebbd5d3092a3543b783bf065165fa5d3ad4b899b836810e513064134e18"}, "idna": {:hex, :idna, "6.0.1", "1d038fb2e7668ce41fbf681d2c45902e52b3cb9e9c77b55334353b222c2ee50c", [:rebar3], [{:unicode_util_compat, "0.5.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "a02c8a1c4fd601215bb0b0324c8a6986749f807ce35f25449ec9e69758708122"}, - "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fdf843bca858203ae1de16da2ee206f53416bbda5dc8c9e78f43243de4bc3afe"}, + "jason": {:hex, :jason, "1.2.1", "12b22825e22f468c02eb3e4b9985f3d0cb8dc40b9bd704730efa11abd2708c44", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b659b8571deedf60f79c5a608e15414085fa141344e2716fbd6988a084b5f993"}, "machinery": {:hex, :machinery, "1.0.0", "df6968d84c651b9971a33871c78c10157b6e13e4f3390b0bee5b0e8bdea8c781", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}], "hexpm", "4f6eb4185a48e7245360bedf653af4acc6fa6ae8ff4690619395543fa1a8395f"}, - "makeup": {:hex, :makeup, "1.0.0", "671df94cf5a594b739ce03b0d0316aa64312cee2574b6a44becb83cd90fb05dc", [:mix], [{:nimble_parsec, "~> 0.5.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "a10c6eb62cca416019663129699769f0c2ccf39428b3bb3c0cb38c718a0c186d"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.14.0", "cf8b7c66ad1cff4c14679698d532f0b5d45a3968ffbcbfd590339cb57742f1ae", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "d4b316c7222a85bbaa2fd7c6e90e37e953257ad196dc229505137c5e505e9eff"}, + "makeup": {:hex, :makeup, "1.0.3", "e339e2f766d12e7260e6672dd4047405963c5ec99661abdc432e6ec67d29ef95", [:mix], [{:nimble_parsec, "~> 0.5", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2e9b4996d11832947731f7608fed7ad2f9443011b3b479ae288011265cdd3dad"}, + "makeup_elixir": {:hex, :makeup_elixir, "0.14.1", "4f0e96847c63c17841d42c08107405a005a2680eb9c7ccadfd757bd31dabccfb", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f2438b1a80eaec9ede832b5c41cd4f373b38fd7aa33e3b22d9db79e640cbde11"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, "nimble_options": {:hex, :nimble_options, "0.2.1", "7eac99688c2544d4cc3ace36ee8f2bf4d738c14d031bd1e1193aab096309d488", [:mix], [], "hexpm", "ca48293609306791ce2634818d849b7defe09330adb7e4e1118a0bc59bed1cf4"}, - "nimble_parsec": {:hex, :nimble_parsec, "0.5.2", "1d71150d5293d703a9c38d4329da57d3935faed2031d64bc19e77b654ef2d177", [:mix], [], "hexpm", "51aa192e0941313c394956718bdb1e59325874f88f45871cff90345b97f60bba"}, + "nimble_parsec": {:hex, :nimble_parsec, "0.6.0", "32111b3bf39137144abd7ba1cce0914533b2d16ef35e8abc5ec8be6122944263", [:mix], [], "hexpm", "27eac315a94909d4dc68bc07a4a83e06c8379237c5ea528a9acff4ca1c873c52"}, "parse_trans": {:hex, :parse_trans, "3.3.0", "09765507a3c7590a784615cfd421d101aec25098d50b89d7aa1d66646bc571c1", [:rebar3], [], "hexpm", "17ef63abde837ad30680ea7f857dd9e7ced9476cdd7b0394432af4bfc241b960"}, - "picosat_elixir": {:git, "https://github.com/zachdaniel/picosat_elixir.git", "9f6777076a78215bd990008572dff8cb7e5dec5b", [ref: "patch-1"]}, + "picosat_elixir": {:hex, :picosat_elixir, "0.1.4", "d259219ae27148c07c4aa3fdee61b1a14f4bc7f83b0ebdf2752558d06b302c62", [:make, :mix], [{:elixir_make, "~> 0.4", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "eb41cb16053a45c8556de32f065084af98ea0b13a523fb46dfb4f9cff4152474"}, "plug": {:hex, :plug, "1.8.3", "12d5f9796dc72e8ac9614e94bda5e51c4c028d0d428e9297650d09e15a684478", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm"}, "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.15.1", "23ce3417de70f4c0e9e7419ad85bdabcc6860a6925fe2c6f3b1b5b1e8e47bf2f", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "sobelow": {:hex, :sobelow, "0.10.2", "00e91208046d3b434f9f08779fe0ca7c6d6595b7fa33b289e792dffa6dde8081", [:mix], [], "hexpm", "e30fc994330cf6f485c1c4f2fb7c4b2d403557d0e101c6e5329fd17a58e55a7e"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, - "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, + "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm", "e9e3cacfd37c1531c0ca70ca7c0c30ce2dbb02998a4f7719de180fe63f8d41e4"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.5.0", "8516502659002cec19e244ebd90d312183064be95025a319a6c7e89f4bccd65b", [:rebar3], [], "hexpm", "d48d002e15f5cc105a696cf2f1bbb3fc72b4b770a184d8420c8db20da2674b38"}, } diff --git a/test/actions/create_test.exs b/test/actions/create_test.exs index 89fb61c9d..8329dbdef 100644 --- a/test/actions/create_test.exs +++ b/test/actions/create_test.exs @@ -4,8 +4,12 @@ defmodule Ash.Test.Actions.CreateTest do defmodule Profile do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -26,8 +30,11 @@ defmodule Ash.Test.Actions.CreateTest do defmodule Author do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -57,8 +64,12 @@ defmodule Ash.Test.Actions.CreateTest do defmodule PostLink do @moduledoc false - use Ash.Resource, name: "post_links", type: "post_link" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, + data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -75,8 +86,11 @@ defmodule Ash.Test.Actions.CreateTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -108,7 +122,12 @@ defmodule Ash.Test.Actions.CreateTest do @moduledoc false use Ash.Api - resources [Author, Post, Profile, PostLink] + resources do + resource(Author) + resource(Post) + resource(Profile) + resource(PostLink) + end end describe "simple creates" do diff --git a/test/actions/destroy_test.exs b/test/actions/destroy_test.exs index 78a67d08f..dd8f10a46 100644 --- a/test/actions/destroy_test.exs +++ b/test/actions/destroy_test.exs @@ -4,8 +4,11 @@ defmodule Ash.Test.Actions.DestroyTest do defmodule Profile do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -26,8 +29,11 @@ defmodule Ash.Test.Actions.DestroyTest do defmodule Author do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -56,8 +62,11 @@ defmodule Ash.Test.Actions.DestroyTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -84,7 +93,11 @@ defmodule Ash.Test.Actions.DestroyTest do @moduledoc false use Ash.Api - resources [Author, Post, Profile] + resources do + resource(Author) + resource(Post) + resource(Profile) + end end describe "simple destroy" do diff --git a/test/actions/read_test.exs b/test/actions/read_test.exs index e98646626..0815f8795 100644 --- a/test/actions/read_test.exs +++ b/test/actions/read_test.exs @@ -4,8 +4,11 @@ defmodule Ash.Test.Actions.ReadTest do defmodule Author do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -24,8 +27,11 @@ defmodule Ash.Test.Actions.ReadTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -48,7 +54,10 @@ defmodule Ash.Test.Actions.ReadTest do @moduledoc false use Ash.Api - resources [Post, Author] + resources do + resource(Post) + resource(Author) + end end describe "api.get/3" do diff --git a/test/actions/side_load_test.exs b/test/actions/side_load_test.exs index 119c79d4d..a89af59fa 100644 --- a/test/actions/side_load_test.exs +++ b/test/actions/side_load_test.exs @@ -4,8 +4,11 @@ defmodule Ash.Test.Actions.SideLoadTest do defmodule Author do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -26,8 +29,11 @@ defmodule Ash.Test.Actions.SideLoadTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -49,7 +55,10 @@ defmodule Ash.Test.Actions.SideLoadTest do @moduledoc false use Ash.Api - resources [Author, Post] + resources do + resource(Author) + resource(Post) + end end describe "side_loads" do diff --git a/test/actions/update_test.exs b/test/actions/update_test.exs index fd88d4240..9a314be2e 100644 --- a/test/actions/update_test.exs +++ b/test/actions/update_test.exs @@ -4,8 +4,11 @@ defmodule Ash.Test.Actions.UpdateTest do defmodule Profile do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -25,8 +28,11 @@ defmodule Ash.Test.Actions.UpdateTest do defmodule Author do @moduledoc false - use Ash.Resource, name: "authors", type: "author" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -50,8 +56,11 @@ defmodule Ash.Test.Actions.UpdateTest do defmodule PostLink do @moduledoc false - use Ash.Resource, name: "post_links", type: "post_link" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -68,8 +77,11 @@ defmodule Ash.Test.Actions.UpdateTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -98,7 +110,12 @@ defmodule Ash.Test.Actions.UpdateTest do @moduledoc false use Ash.Api - resources [Author, Post, Profile, PostLink] + resources do + resource(Author) + resource(Post) + resource(Profile) + resource(PostLink) + end end describe "simple updates" do @@ -285,7 +302,7 @@ defmodule Ash.Test.Actions.UpdateTest do assert Api.get!(Author, author2.id, side_load: [:posts]).posts == [Api.get!(Post, post.id)] end - test "it respons with the relationship field filled in" do + test "it responds with the relationship field filled in" do author = Api.create!(Author, attributes: %{bio: "best dude"}) author2 = Api.create!(Author, attributes: %{bio: "best dude"}) diff --git a/test/api/api_test.exs b/test/api/api_test.exs index 09b5b8d71..c1ead4cb8 100644 --- a/test/api/api_test.exs +++ b/test/api/api_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.ApiTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end diff --git a/test/filter/filter_test.exs b/test/filter/filter_test.exs index a68f06397..3ec1c7a01 100644 --- a/test/filter/filter_test.exs +++ b/test/filter/filter_test.exs @@ -4,8 +4,11 @@ defmodule Ash.Test.Filter.FilterTest do defmodule Profile do @moduledoc false - use Ash.Resource, name: "profiles", type: "profile" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -25,8 +28,11 @@ defmodule Ash.Test.Filter.FilterTest do defmodule User do @moduledoc false - use Ash.Resource, name: "users", type: "user" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -55,8 +61,11 @@ defmodule Ash.Test.Filter.FilterTest do defmodule PostLink do @moduledoc false - use Ash.Resource, name: "post_links", type: "post_link" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -73,8 +82,11 @@ defmodule Ash.Test.Filter.FilterTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end actions do read :default @@ -112,7 +124,12 @@ defmodule Ash.Test.Filter.FilterTest do @moduledoc false use Ash.Api - resources [Post, User, Profile, PostLink] + resources do + resource(Post) + resource(User) + resource(Profile) + resource(PostLink) + end end describe "simple attribute filters" do diff --git a/test/resource/actions/actions_test.exs b/test/resource/actions/actions_test.exs index e39a2d97a..b8b5ec7aa 100644 --- a/test/resource/actions/actions_test.exs +++ b/test/resource/actions/actions_test.exs @@ -4,9 +4,11 @@ defmodule Ash.Test.Dsl.Resource.Actions.ActionsTest do defmacrop defposts(do: body) do quote do + # Process.flag(:trap_exit, true) + defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end diff --git a/test/resource/actions/create_test.exs b/test/resource/actions/create_test.exs index 5a2d38d36..53acfc2ad 100644 --- a/test/resource/actions/create_test.exs +++ b/test/resource/actions/create_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -35,7 +35,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do test "it fails if `name` is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "actions -> create:\n action name must be an atom", + "actions -> create -> default:\n expected :name to be an atom, got: \"default\"", fn -> defposts do actions do diff --git a/test/resource/actions/destroy_test.exs b/test/resource/actions/destroy_test.exs index 99c3f5dc0..0ca13451f 100644 --- a/test/resource/actions/destroy_test.exs +++ b/test/resource/actions/destroy_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -35,7 +35,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do test "it fails if `name` is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "actions -> destroy:\n action name must be an atom", + "actions -> destroy -> default:\n expected :name to be an atom, got: \"default\"", fn -> defposts do actions do diff --git a/test/resource/actions/read_test.exs b/test/resource/actions/read_test.exs index a5aceefa8..76971bb12 100644 --- a/test/resource/actions/read_test.exs +++ b/test/resource/actions/read_test.exs @@ -4,7 +4,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do defmacrop defposts(do: body) do quote do defmodule Post do - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -33,7 +33,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do test "it fails if `name` is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "actions -> read:\n action name must be an atom", + "actions -> read -> default:\n expected :name to be an atom, got: \"default\"", fn -> defposts do actions do diff --git a/test/resource/actions/update_test.exs b/test/resource/actions/update_test.exs index ef2232604..6dfd3407a 100644 --- a/test/resource/actions/update_test.exs +++ b/test/resource/actions/update_test.exs @@ -4,7 +4,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do defmacrop defposts(do: body) do quote do defmodule Post do - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -33,7 +33,7 @@ defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do test "it fails if `name` is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "actions -> update:\n action name must be an atom", + "actions -> update -> default:\n expected :name to be an atom, got: \"default\"", fn -> defposts do actions do diff --git a/test/resource/attributes_test.exs b/test/resource/attributes_test.exs index d44a905c4..443611723 100644 --- a/test/resource/attributes_test.exs +++ b/test/resource/attributes_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.AttributesTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -21,7 +21,7 @@ defmodule Ash.Test.Resource.AttributesTest do end end - assert [%Ash.Resource.Attributes.Attribute{name: :foo, type: :string, primary_key?: false}] = + assert [%Ash.Resource.Attribute{name: :foo, type: :string, primary_key?: false}] = Ash.attributes(Post) end end @@ -30,7 +30,7 @@ defmodule Ash.Test.Resource.AttributesTest do test "raises if the attribute name is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "attributes -> attribute:\n Attribute name must be an atom, got: 10", + "attributes -> attribute -> 10:\n expected :name to be an atom, got: 10", fn -> defposts do attributes do @@ -58,7 +58,7 @@ defmodule Ash.Test.Resource.AttributesTest do test "raises if you pass an invalid value for `primary_key?`" do assert_raise( Ash.Error.ResourceDslError, - "attributes -> attribute:\n expected :primary_key? to be an boolean, got: 10", + "attributes -> attribute -> foo:\n expected :primary_key? to be an boolean, got: 10", fn -> defposts do attributes do @@ -69,70 +69,4 @@ defmodule Ash.Test.Resource.AttributesTest do ) end end - - describe "timestamps" do - test "it adds utc_datetime attributes" do - defposts do - attributes do - timestamps() - end - end - - default = &DateTime.utc_now/0 - - assert [ - %Ash.Resource.Attributes.Attribute{ - allow_nil?: true, - default: ^default, - generated?: false, - name: :updated_at, - primary_key?: false, - type: :utc_datetime, - update_default: ^default, - writable?: false - }, - %Ash.Resource.Attributes.Attribute{ - allow_nil?: true, - default: ^default, - generated?: false, - name: :inserted_at, - primary_key?: false, - type: :utc_datetime, - update_default: nil, - writable?: false - } - ] = Ash.attributes(Post) - end - - test "it allows overwriting the field names" do - defposts do - attributes do - timestamps(inserted_at_field: :created_at, updated_at_field: :last_visited) - end - end - - default = &DateTime.utc_now/0 - - assert [ - %Ash.Resource.Attributes.Attribute{ - allow_nil?: true, - default: ^default, - name: :last_visited, - primary_key?: false, - type: :utc_datetime, - update_default: ^default, - writable?: false - }, - %Ash.Resource.Attributes.Attribute{ - allow_nil?: true, - default: ^default, - name: :created_at, - primary_key?: false, - type: :utc_datetime, - update_default: nil, - writable?: false - } - ] = Ash.attributes(Post) - end - end end diff --git a/test/resource/relationships/belongs_to_test.exs b/test/resource/relationships/belongs_to_test.exs index aeeb31449..5130d78cf 100644 --- a/test/resource/relationships/belongs_to_test.exs +++ b/test/resource/relationships/belongs_to_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource unquote(body) end @@ -22,7 +22,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do end assert [ - %Ash.Resource.Attributes.Attribute{ + %Ash.Resource.Attribute{ name: :foobar_id, primary_key?: false, type: :uuid @@ -85,7 +85,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do test "fails if the destination is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> belongs_to -> foobar:\n related resource must be a module representing a resource", + "relationships -> belongs_to -> foobar:\n expected :destination to be an atom, got: \"foobar\"", fn -> defposts do relationships do @@ -99,7 +99,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do test "fails if the relationship name is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> belongs_to:\n relationship_name must be an atom", + "relationships -> belongs_to -> foobar:\n expected :name to be an atom, got: \"foobar\"", fn -> defposts do relationships do @@ -142,7 +142,7 @@ defmodule Ash.Test.Resource.Relationships.BelongsToTest do test "fails if `field_type` is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> belongs_to -> foobar:\n \"foo\" is not a valid type", + "relationships -> belongs_to -> foobar:\n Attribute type must be a built in type or a type module, got: \"foo\"", fn -> defposts do relationships do diff --git a/test/resource/relationships/has_many_test.exs b/test/resource/relationships/has_many_test.exs index 4e1c6cb8e..7a2ad1346 100644 --- a/test/resource/relationships/has_many_test.exs +++ b/test/resource/relationships/has_many_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasManyTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource attributes do attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0 @@ -70,7 +70,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasManyTest do test "fails if the destination is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> has_many -> foobar:\n related resource must be a module representing a resource", + "relationships -> has_many -> foobar:\n expected :destination to be an atom, got: \"foobar\"", fn -> defposts do relationships do @@ -84,7 +84,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasManyTest do test "fails if the relationship name is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> has_many:\n relationship_name must be an atom", + "relationships -> has_many -> foobar:\n expected :name to be an atom, got: \"foobar\"", fn -> defposts do relationships do diff --git a/test/resource/relationships/has_one_test.exs b/test/resource/relationships/has_one_test.exs index 2e0d692b4..b27d7324e 100644 --- a/test/resource/relationships/has_one_test.exs +++ b/test/resource/relationships/has_one_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasOneTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource attributes do attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0 @@ -70,7 +70,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasOneTest do test "fails if the destination is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> has_one -> foobar:\n related resource must be a module representing a resource", + "relationships -> has_one -> foobar:\n expected :destination to be an atom, got: \"foobar\"", fn -> defposts do relationships do @@ -84,7 +84,7 @@ defmodule Ash.Test.Resource.Relationshihps.HasOneTest do test "fails if the relationship name is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "relationships -> has_one:\n relationship_name must be an atom", + "relationships -> has_one -> foobar:\n expected :name to be an atom, got: \"foobar\"", fn -> defposts do relationships do diff --git a/test/resource/relationships/many_to_many_test.exs b/test/resource/relationships/many_to_many_test.exs index 7c026a047..8ada94352 100644 --- a/test/resource/relationships/many_to_many_test.exs +++ b/test/resource/relationships/many_to_many_test.exs @@ -6,7 +6,7 @@ defmodule Ash.Test.Resource.Relationships.ManyToManyTest do quote do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource attributes do attribute :id, :uuid, primary_key?: true, default: &Ecto.UUID.generate/0 diff --git a/test/type/type_test.exs b/test/type/type_test.exs index e8f4fcfc2..080cff444 100644 --- a/test/type/type_test.exs +++ b/test/type/type_test.exs @@ -34,8 +34,11 @@ defmodule Ash.Test.Type.TypeTest do defmodule Post do @moduledoc false - use Ash.Resource, name: "posts", type: "post" - use Ash.DataLayer.Ets, private?: true + use Ash.Resource, data_layer: Ash.DataLayer.Ets + + ets do + private?(true) + end attributes do attribute :title, PostTitle @@ -51,7 +54,9 @@ defmodule Ash.Test.Type.TypeTest do @moduledoc false use Ash.Api - resources [Post] + resources do + resource(Post) + end end test "it accepts valid data" do