From 05e84102dd4c63b16010070f56d664edf6ff3e67 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Fri, 6 Dec 2019 02:00:26 -0500 Subject: [PATCH] test and docs --- .gitignore | 4 +- lib/ash/authorization/authorization.ex | 3 + lib/ash/error/resource_dsl_error.ex | 22 +- lib/ash/resource.ex | 37 ++-- lib/ash/resource/actions/actions.ex | 188 +++++++++++++----- lib/ash/resource/actions/create.ex | 48 ++++- lib/ash/resource/actions/destroy.ex | 49 ++++- lib/ash/resource/actions/read.ex | 52 ++++- lib/ash/resource/actions/update.ex | 49 ++++- lib/ash/resource/attributes/attribute.ex | 62 ++---- lib/ash/resource/attributes/attributes.ex | 40 +++- lib/ash/resource/relationships/belongs_to.ex | 65 +++--- lib/ash/resource/relationships/has_many.ex | 57 +++--- lib/ash/resource/relationships/has_one.ex | 56 +++--- .../resource/relationships/many_to_many.ex | 19 +- .../resource/relationships/relationships.ex | 114 +++++++---- lib/ash/type/type.ex | 8 +- mix.exs | 2 +- mix.lock | 2 +- test/ash_test.exs | 2 +- test/dsl/resource/actions/create_test.exs | 90 +++++++++ test/dsl/resource/actions/destroy_test.exs | 90 +++++++++ test/dsl/resource/actions/read_test.exs | 90 +++++++++ test/dsl/resource/actions/update_test.exs | 90 +++++++++ test/dsl/resource/attributes_test.exs | 21 +- test/dsl/resource/dsl_test.exs | 0 .../relationships/belongs_to_test.exs | 153 ++++++++++++++ .../resource/relationships/has_many_test.exs | 92 +++++++++ .../resource/relationships/has_one_test.exs | 92 +++++++++ .../relationships/many_to_many_test.exs | 117 +++++++++++ test/test_helper.exs | 3 + 31 files changed, 1424 insertions(+), 293 deletions(-) create mode 100644 lib/ash/authorization/authorization.ex create mode 100644 test/dsl/resource/actions/create_test.exs create mode 100644 test/dsl/resource/actions/destroy_test.exs create mode 100644 test/dsl/resource/actions/read_test.exs create mode 100644 test/dsl/resource/actions/update_test.exs delete mode 100644 test/dsl/resource/dsl_test.exs create mode 100644 test/dsl/resource/relationships/belongs_to_test.exs create mode 100644 test/dsl/resource/relationships/has_many_test.exs create mode 100644 test/dsl/resource/relationships/has_one_test.exs create mode 100644 test/dsl/resource/relationships/many_to_many_test.exs diff --git a/.gitignore b/.gitignore index a4acd2dc..64a2e2f3 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,6 @@ erl_crash.dump ash-*.tar # Ignoring Elixir Language Server -.elixir_ls/ \ No newline at end of file +.elixir_ls/ + +.DS_Store diff --git a/lib/ash/authorization/authorization.ex b/lib/ash/authorization/authorization.ex new file mode 100644 index 00000000..d47adb8a --- /dev/null +++ b/lib/ash/authorization/authorization.ex @@ -0,0 +1,3 @@ +defmodule Ash.Authorization do + @moduledoc "The main documentation for authorization in ash." +end diff --git a/lib/ash/error/resource_dsl_error.ex b/lib/ash/error/resource_dsl_error.ex index 579f8038..fc402d45 100644 --- a/lib/ash/error/resource_dsl_error.ex +++ b/lib/ash/error/resource_dsl_error.ex @@ -1,22 +1,22 @@ defmodule Ash.Error.ResourceDslError do - defexception [:message, :path, :option, :resource, :using] + defexception [:message, :path, :option, :using] - def message(%{message: message, path: nil, option: option, resource: resource, using: using}) do - "#{inspect(resource)}: `use #{inspect(using)}, ...` #{option} #{message} " + def message(%{message: message, path: nil, option: option, using: using}) do + "`use #{inspect(using)}, ...` #{option} #{message} " end - def message(%{message: message, path: nil, option: option, resource: resource}) do - "#{inspect(resource)}: #{option} #{message}" + def message(%{message: message, path: nil, option: option}) do + "#{option} #{message}" end - def message(%{message: message, path: dsl_path, option: nil, resource: resource}) do - dsl_path = Enum.join(dsl_path, "->") - "#{inspect(resource)}: #{message} at #{dsl_path}" + def message(%{message: message, path: dsl_path, option: nil}) do + dsl_path = Enum.join(dsl_path, " -> ") + "#{message} at #{dsl_path}" end - def message(%{message: message, path: dsl_path, option: option, resource: resource}) do - dsl_path = Enum.join(dsl_path, "->") + def message(%{message: message, path: dsl_path, option: option}) do + dsl_path = Enum.join(dsl_path, " -> ") - "#{inspect(resource)}: option #{option} at #{dsl_path} #{message}" + "option #{option} at #{dsl_path} #{message}" end end diff --git a/lib/ash/resource.ex b/lib/ash/resource.ex index 81a4d046..389a99a0 100644 --- a/lib/ash/resource.ex +++ b/lib/ash/resource.ex @@ -86,7 +86,18 @@ defmodule Ash.Resource do quote do @before_compile Ash.Resource - opts = Ash.Resource.validate_use_opts(__MODULE__, unquote(opts)) + opts = + case Ashton.validate(unquote(opts), Ash.Resource.resource_opts_schema()) do + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + using: __MODULE__, + option: key, + message: message + + {:ok, opts} -> + opts + end + Ash.Resource.define_resource_module_attributes(__MODULE__, opts) Ash.Resource.define_primary_key(__MODULE__, opts) @@ -113,33 +124,23 @@ defmodule Ash.Resource do def define_primary_key(mod, opts) do case opts[:primary_key] do true -> - attribute = Ash.Resource.Attributes.Attribute.new(mod, :id, :uuid, primary_key?: true) + {:ok, attribute} = Ash.Resource.Attributes.Attribute.new(:id, :uuid, primary_key?: true) Module.put_attribute(mod, :attributes, attribute) false -> :ok opts -> - attribute = - Ash.Resource.Attributes.Attribute.new(mod, opts[:field], opts[:type], primary_key?: true) + {:ok, attribute} = + Ash.Resource.Attributes.Attribute.new(opts[:field], opts[:type], primary_key?: true) Module.put_attribute(mod, :attributes, attribute) end end @doc false - def validate_use_opts(mod, opts) do - case Ashton.validate(opts, @resource_opts_schema) do - {:error, [{key, message} | _]} -> - raise Ash.Error.ResourceDslError, - resource: mod, - using: __MODULE__, - option: key, - message: message - - {:ok, opts} -> - opts - end + def resource_opts_schema() do + @resource_opts_schema end defmacro __before_compile__(env) do @@ -147,10 +148,6 @@ defmodule Ash.Resource do @sanitized_actions Ash.Resource.mark_primaries(@actions) @ash_primary_key Ash.Resource.primary_key(@attributes) - unless @ash_primary_key do - raise "Must have a primary key for a resource: #{__MODULE__}" - end - require Ash.Schema Ash.Schema.define_schema(@name) diff --git a/lib/ash/resource/actions/actions.ex b/lib/ash/resource/actions/actions.ex index 3b6b0540..7c71ed8a 100644 --- a/lib/ash/resource/actions/actions.ex +++ b/lib/ash/resource/actions/actions.ex @@ -1,4 +1,23 @@ 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 `delete`. 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. + + Authorization in ash is done via supplying a list of rules to actions in the + `rules` option. To understand rules and authorization, see the documentation in `Ash.Authorization` + """ + + @doc false defmacro actions(do: block) do quote do import Ash.Resource.Actions @@ -25,85 +44,144 @@ defmodule Ash.Resource.Actions do end end - defmacro defaults(:all) do - quote do - defaults([:create, :update, :destroy, :read]) - end - end + @doc """ + Sets up simple defaults for the supplied list of action types. - defmacro defaults(defaults, opts \\ []) do + These defaults will have the name `:default`. If you need to configure your actions, + you will have to declare them each separately. + """ + defmacro defaults(defaults) do quote do - opts = unquote(opts) - for default <- unquote(defaults) do case default do + :all -> + create(:default) + read(:default) + update(:default) + destroy(:default) + :create -> - create(:default, opts) - - :update -> - update(:default, opts) - - :destroy -> - destroy(:default, opts) + create(:default) :read -> - read(:default, opts) + read(:default) + + :update -> + update(:default) + + :destroy -> + destroy(:default) action -> - raise "Invalid action type #{action} listed in defaults list for resource: #{ - __MODULE__ - }" + raise Ash.Error.ResourceDslError, + path: [:actions, :defaults], + message: "Invalid default #{action}" end end end end + @doc """ + Declares a `create` action. For calling this action, see the `Ash.Api` documentation. + + #{Ashton.document(Ash.Resource.Actions.Create.opt_schema())} + """ defmacro create(name, opts \\ []) do quote bind_quoted: [name: name, opts: opts] do - action = - Ash.Resource.Actions.Create.new(name, - primary?: opts[:primary?] || false, - rules: opts[:rules] || [] - ) + unless is_atom(name) do + raise Ash.Error.ResourceDslError, + message: "action name must be an atom", + path: [:actions, :create] + end - @actions action + case Ash.Resource.Actions.Create.new(name, opts) do + {:ok, action} -> + @actions action + + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + message: message, + option: key, + path: [:actions, :create, name] + end end end - defmacro update(name, opts \\ []) do - quote bind_quoted: [name: name, opts: opts] do - action = - Ash.Resource.Actions.Update.new(name, - primary?: opts[:primary?] || false, - rules: opts[:rules] || [] - ) - - @actions action - end - end - - defmacro destroy(name, opts \\ []) do - quote bind_quoted: [name: name, opts: opts] do - action = - Ash.Resource.Actions.Destroy.new(name, - primary?: opts[:primary?] || false, - rules: opts[:rules] || [] - ) - - @actions action - end - end + @doc """ + Declares a `read` action. For calling this action, see the `Ash.Api` documentation. + #{Ashton.document(Ash.Resource.Actions.Read.opt_schema())} + """ defmacro read(name, opts \\ []) do quote bind_quoted: [name: name, opts: opts] do - action = - Ash.Resource.Actions.Read.new(name, - primary?: opts[:primary?] || false, - rules: opts[:rules] || [], - paginate?: Keyword.get(opts, :paginate?, true) - ) + unless is_atom(name) do + raise Ash.Error.ResourceDslError, + message: "action name must be an atom", + path: [:actions, :read] + end - @actions action + case Ash.Resource.Actions.Read.new(name, opts) do + {:ok, action} -> + @actions action + + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + message: message, + option: key, + path: [:actions, :read, name] + end + end + end + + @doc """ + Declares an `update` action. For calling this action, see the `Ash.Api` documentation. + + #{Ashton.document(Ash.Resource.Actions.Update.opt_schema())} + """ + defmacro update(name, opts \\ []) do + quote bind_quoted: [name: name, opts: opts] do + unless is_atom(name) do + raise Ash.Error.ResourceDslError, + message: "action name must be an atom", + path: [:actions, :update] + end + + case Ash.Resource.Actions.Update.new(name, opts) do + {:ok, action} -> + @actions action + + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + message: message, + option: key, + path: [:actions, :update, name] + end + end + end + + @doc """ + Declares an `destroy` action. For calling this action, see the `Ash.Api` documentation. + + #{Ashton.document(Ash.Resource.Actions.Destroy.opt_schema())} + """ + defmacro destroy(name, opts \\ []) do + quote bind_quoted: [name: name, opts: opts] do + unless is_atom(name) do + raise Ash.Error.ResourceDslError, + message: "action name must be an atom", + path: [:actions, :destroy] + end + + case Ash.Resource.Actions.Destroy.new(name, opts) do + {:ok, action} -> + @actions action + + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + message: message, + option: key, + 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 430ae61e..61339747 100644 --- a/lib/ash/resource/actions/create.ex +++ b/lib/ash/resource/actions/create.ex @@ -1,12 +1,48 @@ defmodule Ash.Resource.Actions.Create do + @moduledoc "The representation of a `create` action." defstruct [:type, :name, :primary?, :rules] + @type t :: %__MODULE__{ + type: :create, + name: atom, + primary?: boolean, + rules: list(Ash.Authorization.Rule.t()) + } + + @opt_schema Ashton.schema( + opts: [ + primary?: :boolean, + rules: {:list, {:struct, Ash.Authorization.Rule}} + ], + defaults: [ + primary?: false, + rules: [] + ], + describe: [ + primary?: + "Whether or not this action should be used when no action is specified by the caller.", + rules: + "A list of `Ash.Authorization.Rule`s declaring the authorization of the action." + ] + ) + + @doc false + def opt_schema(), do: @opt_schema + + @spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term} def new(name, opts \\ []) do - %__MODULE__{ - name: name, - type: :create, - primary?: opts[:primary?], - rules: opts[:rules] - } + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :create, + primary?: opts[:primary?], + rules: opts[:rules] + }} + + {:error, error} -> + {:error, error} + end end end diff --git a/lib/ash/resource/actions/destroy.ex b/lib/ash/resource/actions/destroy.ex index 3376d8a3..4996f8d0 100644 --- a/lib/ash/resource/actions/destroy.ex +++ b/lib/ash/resource/actions/destroy.ex @@ -1,12 +1,49 @@ defmodule Ash.Resource.Actions.Destroy do + @moduledoc "The representation of a `destroy` action" + defstruct [:type, :name, :primary?, :rules] + @type t :: %__MODULE__{ + type: :destroy, + name: atom, + primary?: boolean, + rules: list(Ash.Authorization.Rule.t()) + } + + @opt_schema Ashton.schema( + opts: [ + primary?: :boolean, + rules: {:list, {:struct, Ash.Authorization.Rule}} + ], + defaults: [ + primary?: false, + rules: [] + ], + describe: [ + primary?: + "Whether or not this action should be used when no action is specified by the caller.", + rules: + "A list of `Ash.Authorization.Rule`s declaring the authorization of the action." + ] + ) + + @doc false + def opt_schema(), do: @opt_schema + + @spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term} def new(name, opts \\ []) do - %__MODULE__{ - name: name, - type: :destroy, - primary?: opts[:primary?], - rules: opts[:rules] - } + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :destroy, + primary?: opts[:primary?], + rules: opts[:rules] + }} + + {:error, error} -> + {:error, error} + end end end diff --git a/lib/ash/resource/actions/read.ex b/lib/ash/resource/actions/read.ex index 3cb4dee2..eaab2942 100644 --- a/lib/ash/resource/actions/read.ex +++ b/lib/ash/resource/actions/read.ex @@ -1,13 +1,49 @@ defmodule Ash.Resource.Actions.Read do - defstruct [:type, :name, :primary?, :paginate?, :rules] + @moduledoc "The representation of a `read` action" + defstruct [:type, :name, :primary?, :rules] + + @type t :: %__MODULE__{ + type: :read, + name: atom, + primary?: boolean, + rules: list(Ash.Authorization.Rule.t()) + } + + @opt_schema Ashton.schema( + opts: [ + primary?: :boolean, + rules: {:list, {:struct, Ash.Authorization.Rule}} + ], + defaults: [ + primary?: false, + rules: [] + ], + describe: [ + primary?: + "Whether or not this action should be used when no action is specified by the caller.", + rules: + "A list of `Ash.Authorization.Rule`s declaring the authorization of the action." + ] + ) + + @doc false + def opt_schema(), do: @opt_schema + + @spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term} def new(name, opts \\ []) do - %__MODULE__{ - name: name, - type: :read, - primary?: opts[:primary?], - paginate?: opts[:paginate?], - rules: opts[:rules] - } + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :read, + primary?: opts[:primary?], + rules: opts[:rules] + }} + + {:error, error} -> + {:error, error} + end end end diff --git a/lib/ash/resource/actions/update.ex b/lib/ash/resource/actions/update.ex index 5a4b631c..227ac82f 100644 --- a/lib/ash/resource/actions/update.ex +++ b/lib/ash/resource/actions/update.ex @@ -1,12 +1,49 @@ defmodule Ash.Resource.Actions.Update do + @moduledoc "The representation of a `update` action" + defstruct [:type, :name, :primary?, :rules] + @type t :: %__MODULE__{ + type: :update, + name: atom, + primary?: boolean, + rules: list(Ash.Authorization.Rule.t()) + } + + @opt_schema Ashton.schema( + opts: [ + primary?: :boolean, + rules: {:list, {:struct, Ash.Authorization.Rule}} + ], + defaults: [ + primary?: false, + rules: [] + ], + describe: [ + primary?: + "Whether or not this action should be used when no action is specified by the caller.", + rules: + "A list of `Ash.Authorization.Rule`s declaring the authorization of the action." + ] + ) + + @doc false + def opt_schema(), do: @opt_schema + + @spec new(atom, Keyword.t()) :: {:ok, t()} | {:error, term} def new(name, opts \\ []) do - %__MODULE__{ - name: name, - type: :update, - primary?: opts[:primary?], - rules: opts[:rules] - } + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :update, + primary?: opts[:primary?], + rules: opts[:rules] + }} + + {:error, error} -> + {:error, error} + end end end diff --git a/lib/ash/resource/attributes/attribute.ex b/lib/ash/resource/attributes/attribute.ex index 4d806efb..33fc94e6 100644 --- a/lib/ash/resource/attributes/attribute.ex +++ b/lib/ash/resource/attributes/attribute.ex @@ -9,58 +9,30 @@ defmodule Ash.Resource.Attributes.Attribute do primary_key?: boolean() } - @builtins Ash.Type.builtins() - @schema Ashton.schema(opts: [primary_key?: :boolean], defaults: [primary_key?: false]) + @schema Ashton.schema( + opts: [primary_key?: :boolean], + defaults: [primary_key?: false], + describe: [ + primary_key?: "Whether this field is, or is part of, the primary key of a resource." + ] + ) @doc false def attribute_schema(), do: @schema - def new(resource, name, type, opts \\ []) - - def new(resource, name, _, _) when not is_atom(name) do - raise Ash.Error.ResourceDslError, - resource: resource, - message: "Attribute name must be an atom, got: #{inspect(name)}", - path: [:attributes, :attribute] - end - - def new(resource, _name, type, _opts) when not is_atom(type) do - raise Ash.Error.ResourceDslError, - resource: resource, - message: "Attribute type must be a built in type or a type module, got: #{inspect(type)}", - path: [:attributes, :attribute] - end - - def new(resource, name, type, opts) when type in @builtins do + @spec new(atom, Ash.Type.t(), Keyword.t()) :: {:ok, t()} | {:error, term} + def new(name, type, opts) do case Ashton.validate(opts, @schema) do - {:error, [{key, message} | _]} -> - raise Ash.Error.ResourceDslError, - resource: resource, - message: message, - path: [:attributes, :attribute], - option: key - {:ok, opts} -> - %__MODULE__{ - name: name, - type: type, - primary_key?: opts[:primary_key?] || false - } - end - end + {:ok, + %__MODULE__{ + name: name, + type: type, + primary_key?: opts[:primary_key?] || false + }} - def new(resource, name, type, opts) do - if Ash.Type.ash_type?(type) do - %__MODULE__{ - name: name, - type: type, - primary_key?: opts[:primary_key?] || false - } - else - raise Ash.Error.ResourceDslError, - resource: resource, - message: "Attribute type must be a built in type or a type module, got: #{inspect(type)}", - path: [:attributes, :attribute] + {:error, error} -> + {:error, error} end end end diff --git a/lib/ash/resource/attributes/attributes.ex b/lib/ash/resource/attributes/attributes.ex index 32810a97..89622cb1 100644 --- a/lib/ash/resource/attributes/attributes.ex +++ b/lib/ash/resource/attributes/attributes.ex @@ -1,4 +1,12 @@ 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 @@ -7,9 +15,39 @@ defmodule Ash.Resource.Attributes do end 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. + + #{Ashton.document(Ash.Resource.Attributes.Attribute.attribute_schema())} + """ defmacro attribute(name, type, opts \\ []) do quote bind_quoted: [type: type, name: name, opts: opts] do - @attributes Ash.Resource.Attributes.Attribute.new(__MODULE__, name, type, opts) + unless is_atom(name) do + raise Ash.Error.ResourceDslError, + message: "Attribute name must be an atom, got: #{inspect(name)}", + path: [:attributes, :attribute] + end + + 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 + + case Ash.Resource.Attributes.Attribute.new(name, type, opts) do + {:ok, attribute} -> + @attributes attribute + + {:error, [{key, message} | _]} -> + raise Ash.Error.ResourceDslError, + message: message, + path: [:attributes, :attribute], + option: key + end end end end diff --git a/lib/ash/resource/relationships/belongs_to.ex b/lib/ash/resource/relationships/belongs_to.ex index 0bdeb28c..6e46e81e 100644 --- a/lib/ash/resource/relationships/belongs_to.ex +++ b/lib/ash/resource/relationships/belongs_to.ex @@ -1,36 +1,51 @@ defmodule Ash.Resource.Relationships.BelongsTo do - @doc false - + @moduledoc "The representation of a `belongs_to` relationship" defstruct [ :name, :cardinality, :type, :destination, :primary_key?, + :define_field?, + :field_type, :destination_field, :source_field ] @type t :: %__MODULE__{ type: :belongs_to, - cardinality: :one + cardinality: :one, + name: atom, + destination: Ash.resource(), + primary_key?: boolean, + define_field?: boolean, + field_type: Ash.Type.t(), + destination_field: atom, + source_field: atom } @opt_schema Ashton.schema( opts: [ destination_field: :atom, source_field: :atom, - primary_key?: :boolean + primary_key?: :boolean, + define_field?: :boolean, + field_type: :atom ], defaults: [ destination_field: :id, - primary_key?: false + primary_key?: false, + define_field?: true, + field_type: :uuid ], describe: [ + define_field?: + "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: "The field type of the automatically created field.", destination_field: "The field on the related resource that should match the `source_field` on this resource.", source_field: - "The field on this resource that should match the `destination_field` on the related resource. Default: _id", + "The field on this resource that should match the `destination_field` on the related resource. Default: [relationship_name]_id", primary_key?: "Whether this field is, or is part of, the primary key of a resource." ] @@ -40,28 +55,28 @@ defmodule Ash.Resource.Relationships.BelongsTo do def opt_schema(), do: @opt_schema @spec new( - resource_name :: String.t(), name :: atom, related_resource :: Ash.resource(), opts :: Keyword.t() - ) :: t() - def new(resource_name, name, related_resource, opts \\ []) do - opts = - case Ashton.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :belongs_to, - cardinality: :one, - primary_key?: opts[:primary_key?], - destination: related_resource, - destination_field: opts[:destination_field], - source_field: opts[:source_field] || :"#{name}_id" - }} + ) :: {:ok, t()} | {:error, term} + def new(name, related_resource, opts \\ []) do + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + 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" + }} - {:error, error} -> - {:error, error} - end + {: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 40e957a5..d64d677f 100644 --- a/lib/ash/resource/relationships/has_many.ex +++ b/lib/ash/resource/relationships/has_many.ex @@ -5,32 +5,32 @@ defmodule Ash.Resource.Relationships.HasMany do :cardinality, :destination, :destination_field, - :source_field, - :primary_key? + :source_field ] @type t :: %__MODULE__{ type: :has_many, - cardinality: :many + cardinality: :many, + name: atom, + type: Ash.Type.t(), + destination: Ash.resource(), + destination_field: atom, + source_field: atom } @opt_schema Ashton.schema( opts: [ destination_field: :atom, - source_field: :atom, - primary_key?: :boolean + source_field: :atom ], defaults: [ - source_field: :id, - primary_key?: false + source_field: :id ], describe: [ destination_field: - "The field on the related resource that should match the `source_field` on this resource. Default: _id", + "The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id", source_field: - "The field on this resource that should match the `destination_field` on the related resource.", - primary_key?: - "Whether this field is, or is part of, the primary key of a resource." + "The field on this resource that should match the `destination_field` on the related resource." ] ) @@ -38,28 +38,25 @@ defmodule Ash.Resource.Relationships.HasMany do def opt_schema(), do: @opt_schema @spec new( - resource_name :: String.t(), name :: atom, related_resource :: Ash.resource(), opts :: Keyword.t() - ) :: t() - def new(resource_name, resource_type, name, related_resource, opts \\ []) do - opts = - case Ashton.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :has_many, - cardinality: :many, - primary_key?: opts[:primary_key?], - destination: related_resource, - destination_field: opts[:destination_field] || :"#{resource_type}_id", - source_field: opts[:source_field] - }} + ) :: {:ok, t()} | {:error, term} + def new(resource_type, name, related_resource, opts \\ []) do + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :has_many, + cardinality: :many, + destination: related_resource, + destination_field: opts[:destination_field] || :"#{resource_type}_id", + source_field: opts[:source_field] + }} - {:error, error} -> - {:error, error} - end + {: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 99eece83..f4e12e87 100644 --- a/lib/ash/resource/relationships/has_one.ex +++ b/lib/ash/resource/relationships/has_one.ex @@ -5,33 +5,33 @@ defmodule Ash.Resource.Relationships.HasOne do :type, :cardinality, :destination, - :primary_key?, :destination_field, :source_field ] @type t :: %__MODULE__{ type: :has_one, - cardinality: :one + cardinality: :one, + name: atom, + type: Ash.Type.t(), + destination: Ash.resource(), + destination_field: atom, + source_field: atom } @opt_schema Ashton.schema( opts: [ destination_field: :atom, - source_field: :atom, - primary_key?: :boolean + source_field: :atom ], defaults: [ - source_field: :id, - primary_key?: false + source_field: :id ], describe: [ destination_field: - "The field on the related resource that should match the `source_field` on this resource. Default: _id", + "The field on the related resource that should match the `source_field` on this resource. Default: [resource.name]_id", source_field: - "The field on this resource that should match the `destination_field` on the related resource.", - primary_key?: - "Whether this field is, or is part of, the primary key of a resource." + "The field on this resource that should match the `destination_field` on the related resource." ] ) @@ -39,29 +39,27 @@ defmodule Ash.Resource.Relationships.HasOne do def opt_schema(), do: @opt_schema @spec new( - resource_name :: String.t(), + resource_type :: String.t(), name :: atom, related_resource :: Ash.resource(), opts :: Keyword.t() - ) :: t() + ) :: {:ok, t()} | {:error, term} @doc false - def new(resource_name, name, related_resource, opts \\ []) do - opts = - case Ashton.validate(opts, @opt_schema) do - {:ok, opts} -> - {:ok, - %__MODULE__{ - name: name, - type: :has_one, - cardinality: :one, - destination: related_resource, - primary_key?: opts[:primary_key?], - destination_field: opts[:destination_field] || :"#{resource_name}_id", - source_field: opts[:source_field] - }} + def new(resource_type, name, related_resource, opts \\ []) do + case Ashton.validate(opts, @opt_schema) do + {:ok, opts} -> + {:ok, + %__MODULE__{ + name: name, + type: :has_one, + cardinality: :one, + destination: related_resource, + destination_field: opts[:destination_field] || :"#{resource_type}_id", + source_field: opts[:source_field] + }} - {:error, errors} -> - {:error, errors} - end + {: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 3b618b0f..74d2cb8c 100644 --- a/lib/ash/resource/relationships/many_to_many.ex +++ b/lib/ash/resource/relationships/many_to_many.ex @@ -13,7 +13,14 @@ defmodule Ash.Resource.Relationships.ManyToMany do @type t :: %__MODULE__{ type: :many_to_many, - cardinality: :many + cardinality: :many, + name: atom, + through: Ash.resource() | String.t(), + destination: Ash.resource(), + source_field: atom, + destination_field: atom, + source_field_on_join_table: atom, + destination_field_on_join_table: atom } @opt_schema Ashton.schema( @@ -32,10 +39,12 @@ defmodule Ash.Resource.Relationships.ManyToMany do :through ], describe: [ + through: + "Either a string representing a table/generic name for the join table or a module name of a resource.", source_field_on_join_table: - "The field on the join table that should line up with `source_field` on this resource. Default: _id", + "The field on the join table that should line up with `source_field` on this resource. Default: [resource_name]_id", destination_field_on_join_table: - "The field on the join table that should line up with `destination_field` on the related resource. Default: _id", + "The field on the join table that should line up with `destination_field` on the related resource. Default: [relationshihp_name]_id", source_field: "The field on this resource that should line up with `source_field_on_join_table` on the join table.", destination_field: @@ -51,7 +60,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do name :: atom, related_resource :: Ash.resource(), opts :: Keyword.t() - ) :: t() + ) :: {:ok, t()} | {:error, term} def new(resource_name, name, related_resource, opts \\ []) do case Ashton.validate(opts, @opt_schema) do {:ok, opts} -> @@ -67,7 +76,7 @@ defmodule Ash.Resource.Relationships.ManyToMany do source_field_on_join_table: opts[:source_field_on_join_table] || :"#{resource_name}_id", destination_field_on_join_table: - opts[:destination_field_on_join_table] || ":#{name}_id" + opts[:destination_field_on_join_table] || :"#{name}_id" }} {:error, errors} -> diff --git a/lib/ash/resource/relationships/relationships.ex b/lib/ash/resource/relationships/relationships.ex index 65d28873..035a0db4 100644 --- a/lib/ash/resource/relationships/relationships.ex +++ b/lib/ash/resource/relationships/relationships.ex @@ -35,14 +35,30 @@ defmodule Ash.Resource.Relationships do #{Ashton.document(Ash.Resource.Relationships.HasOne.opt_schema(), header_depth: 2)} """ - defmacro has_one(relationship_name, resource, opts \\ []) do - quote do + defmacro has_one(relationship_name, destination, opts \\ []) do + quote bind_quoted: [ + relationship_name: relationship_name, + destination: destination, + opts: opts + ] do + 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 + relationship = Ash.Resource.Relationships.HasOne.new( - @name, - unquote(relationship_name), - unquote(resource), - unquote(opts) + @resource_type, + relationship_name, + destination, + opts ) case relationship do @@ -53,14 +69,15 @@ defmodule Ash.Resource.Relationships do raise Ash.Error.ResourceDslError, message: message, option: key, - resource: __MODULE__, - path: [:relationships, :has_one, unquote(relationship_name)] + path: [:relationships, :has_one, relationship_name] end end end @doc """ - Declares a belongs_to relationship. In a relationsal database, the foreign key would be on the *source* table. + 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. @@ -75,33 +92,47 @@ defmodule Ash.Resource.Relationships do #{Ashton.document(Ash.Resource.Relationships.BelongsTo.opt_schema(), header_depth: 2)} """ - defmacro belongs_to(relationship_name, resource, config \\ []) do - quote do + defmacro belongs_to(relationship_name, destination, config \\ []) do + quote bind_quoted: [ + relationship_name: relationship_name, + destination: destination, + config: config + ] do + 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 + relationship = - Ash.Resource.Relationships.BelongsTo.new( - @name, - unquote(relationship_name), - unquote(resource), - unquote(config) - ) + Ash.Resource.Relationships.BelongsTo.new(relationship_name, destination, config) case relationship do {:ok, relationship} -> - # TODO: This assumes binary_id - @attributes Ash.Resource.Attributes.Attribute.new( - __MODULE__, - relationship.source_field, - :uuid, - primary_key?: relationship.primary_key? - ) + if relationship.define_field? do + {:ok, attribute} = + Ash.Resource.Attributes.Attribute.new( + relationship.source_field, + relationship.field_type, + primary_key?: relationship.primary_key? + ) + + @attributes attribute + end + @relationships relationship {:error, [{key, message}]} -> raise Ash.Error.ResourceDslError, message: message, option: key, - resource: __MODULE__, - path: [:relationships, :belongs_to, unquote(relationship_name)] + path: [:relationships, :belongs_to, relationship_name] end end end @@ -120,15 +151,30 @@ defmodule Ash.Resource.Relationships do #{Ashton.document(Ash.Resource.Relationships.HasMany.opt_schema(), header_depth: 2)} """ - defmacro has_many(relationship_name, resource, config \\ []) do - quote do + defmacro has_many(relationship_name, destination, opts \\ []) do + quote bind_quoted: [ + relationship_name: relationship_name, + destination: destination, + opts: opts + ] do + 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 + relationship = Ash.Resource.Relationships.HasMany.new( - @name, @resource_type, - unquote(relationship_name), - unquote(resource), - unquote(config) + relationship_name, + destination, + opts ) case relationship do @@ -139,8 +185,7 @@ defmodule Ash.Resource.Relationships do raise Ash.Error.ResourceDslError, message: message, option: key, - resource: __MODULE__, - path: [:relationships, :has_many, unquote(relationship_name)] + path: [:relationships, :has_many, relationship_name] end end end @@ -184,7 +229,6 @@ defmodule Ash.Resource.Relationships do raise Ash.Error.ResourceDslError, message: message, option: key, - resource: __MODULE__, path: [:relationships, :many_to_many, unquote(relationship_name)] end end diff --git a/lib/ash/type/type.ex b/lib/ash/type/type.ex index bdc1775f..ef87bb9d 100644 --- a/lib/ash/type/type.ex +++ b/lib/ash/type/type.ex @@ -76,7 +76,7 @@ defmodule Ash.Type do """ @spec cast_input(t(), term) :: {:ok, term} | {:error, keyword()} | :error def cast_input(type, term) when type in @builtin_names do - Ecto.Type.cast(@builtins[term][:ecto_type], term) + Ecto.Type.cast(@builtins[type][:ecto_type], term) end def cast_input(type, term) do @@ -189,10 +189,12 @@ defmodule Ash.Type do @doc "Returns true if the value is a builtin type or adopts the `Ash.Type` behaviour" def ash_type?(atom) when atom in @builtin_names, do: true - def ash_type?(module) do - :erlang.function_exported(module, :module_info, 0) and ash_type_module?(module) + def ash_type?(module) when is_atom(module) do + :erlang.function_exported(module, :__info__, 1) and ash_type_module?(module) end + def ash_type?(_), do: false + defp ash_type_module?(module) do :attributes |> module.module_info() diff --git a/mix.exs b/mix.exs index 7a672fc1..15af8cbd 100644 --- a/mix.exs +++ b/mix.exs @@ -49,7 +49,7 @@ defmodule Ash.MixProject do {:ecto, "~> 3.0"}, {:ets, github: "zachdaniel/ets", ref: "b96da05e75926e340e8a0fdfea9c095d97ed8d50"}, {:ex_doc, "~> 0.21", only: :dev, runtime: false}, - {:ashton, "~> 0.3.9"} + {:ashton, "~> 0.3.10"} ] end end diff --git a/mix.lock b/mix.lock index 911e520a..f9601759 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ashton": {:hex, :ashton, "0.3.9", "1c089d62d35a17c1f31db4e9130fb90f8d802c8c9078fd29138be7b6b93305b5", [:mix], [], "hexpm"}, + "ashton": {:hex, :ashton, "0.3.10", "ce0ab19f154c7fe8fefbc1486fdf7b601a0fa944555284182755197b1c073464", [:mix], [], "hexpm"}, "connection": {:hex, :connection, "1.0.4", "a1cae72211f0eef17705aaededacac3eb30e6625b04a6117c1b2db6ace7d5976", [:mix], [], "hexpm"}, "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"}, diff --git a/test/ash_test.exs b/test/ash_test.exs index e2abe84d..7a338e0c 100644 --- a/test/ash_test.exs +++ b/test/ash_test.exs @@ -1,4 +1,4 @@ defmodule AshTest do - use ExUnit.Case + use ExUnit.Case, async: true doctest Ash end diff --git a/test/dsl/resource/actions/create_test.exs b/test/dsl/resource/actions/create_test.exs new file mode 100644 index 00000000..47c7181e --- /dev/null +++ b/test/dsl/resource/actions/create_test.exs @@ -0,0 +1,90 @@ +defmodule Ash.Test.Dsl.Resource.Actions.CreateTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates an action" do + defposts do + actions do + create :default + end + end + + assert [ + %Ash.Resource.Actions.Create{ + name: :default, + primary?: true, + rules: [], + type: :create + } + ] = Ash.actions(Post) + end + end + + describe "validation" do + test "it fails if `name` is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "action name must be an atom at actions -> create", + fn -> + defposts do + actions do + create "default" + end + end + end + ) + end + + test "it fails if `primary?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option primary? at actions -> create -> default must be of type :boolean", + fn -> + defposts do + actions do + create :default, primary?: 10 + end + end + end + ) + end + + test "it fails if `rules` is not a list" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> create -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + create :default, rules: 10 + end + end + end + ) + end + + test "it fails if the elements of the rules list are not rules" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> create -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + create :default, rules: [10] + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/actions/destroy_test.exs b/test/dsl/resource/actions/destroy_test.exs new file mode 100644 index 00000000..86008796 --- /dev/null +++ b/test/dsl/resource/actions/destroy_test.exs @@ -0,0 +1,90 @@ +defmodule Ash.Test.Dsl.Resource.Actions.DestroyTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates an action" do + defposts do + actions do + destroy :default + end + end + + assert [ + %Ash.Resource.Actions.Destroy{ + name: :default, + primary?: true, + rules: [], + type: :destroy + } + ] = Ash.actions(Post) + end + end + + describe "validation" do + test "it fails if `name` is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "action name must be an atom at actions -> destroy", + fn -> + defposts do + actions do + destroy "default" + end + end + end + ) + end + + test "it fails if `primary?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option primary? at actions -> destroy -> default must be of type :boolean", + fn -> + defposts do + actions do + destroy :default, primary?: 10 + end + end + end + ) + end + + test "it fails if `rules` is not a list" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> destroy -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + destroy :default, rules: 10 + end + end + end + ) + end + + test "it fails if the elements of the rules list are not rules" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> destroy -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + destroy :default, rules: [10] + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/actions/read_test.exs b/test/dsl/resource/actions/read_test.exs new file mode 100644 index 00000000..6992aa28 --- /dev/null +++ b/test/dsl/resource/actions/read_test.exs @@ -0,0 +1,90 @@ +defmodule Ash.Test.Dsl.Resource.Actions.ReadTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates an action" do + defposts do + actions do + read :default + end + end + + assert [ + %Ash.Resource.Actions.Read{ + name: :default, + primary?: true, + rules: [], + type: :read + } + ] = Ash.actions(Post) + end + end + + describe "validation" do + test "it fails if `name` is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "action name must be an atom at actions -> read", + fn -> + defposts do + actions do + read "default" + end + end + end + ) + end + + test "it fails if `primary?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option primary? at actions -> read -> default must be of type :boolean", + fn -> + defposts do + actions do + read :default, primary?: 10 + end + end + end + ) + end + + test "it fails if `rules` is not a list" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> read -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + read :default, rules: 10 + end + end + end + ) + end + + test "it fails if the elements of the rules list are not rules" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> read -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + read :default, rules: [10] + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/actions/update_test.exs b/test/dsl/resource/actions/update_test.exs new file mode 100644 index 00000000..6530828f --- /dev/null +++ b/test/dsl/resource/actions/update_test.exs @@ -0,0 +1,90 @@ +defmodule Ash.Test.Dsl.Resource.Actions.UpdateTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates an action" do + defposts do + actions do + update :default + end + end + + assert [ + %Ash.Resource.Actions.Update{ + name: :default, + primary?: true, + rules: [], + type: :update + } + ] = Ash.actions(Post) + end + end + + describe "validation" do + test "it fails if `name` is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "action name must be an atom at actions -> update", + fn -> + defposts do + actions do + update "default" + end + end + end + ) + end + + test "it fails if `primary?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option primary? at actions -> update -> default must be of type :boolean", + fn -> + defposts do + actions do + update :default, primary?: 10 + end + end + end + ) + end + + test "it fails if `rules` is not a list" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> update -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + update :default, rules: 10 + end + end + end + ) + end + + test "it fails if the elements of the rules list are not rules" do + assert_raise( + Ash.Error.ResourceDslError, + "option rules at actions -> update -> default must be of type {:list, {:struct, Ash.Authorization.Rule}}", + fn -> + defposts do + actions do + update :default, rules: [10] + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/attributes_test.exs b/test/dsl/resource/attributes_test.exs index 061eca4f..851a8a2a 100644 --- a/test/dsl/resource/attributes_test.exs +++ b/test/dsl/resource/attributes_test.exs @@ -4,18 +4,31 @@ defmodule Ash.Test.Dsl.Resource.AttributesTest do defmacrop defposts(do: body) do quote do defmodule Post do - use Ash.Resource, name: "posts", type: "post" + use Ash.Resource, name: "posts", type: "post", primary_key: false unquote(body) end end end + describe "representation" do + test "attributes are persisted on the resource properly" do + defposts do + attributes do + attribute :foo, :string + end + end + + assert [%Ash.Resource.Attributes.Attribute{name: :foo, type: :string, primary_key?: false}] = + Ash.attributes(Post) + end + end + describe "validation" do test "raises if the attribute name is not an atom" do assert_raise( Ash.Error.ResourceDslError, - "Ash.Test.Dsl.Resource.AttributesTest.Post: Attribute name must be an atom, got: 10 at attributes->attribute", + "Attribute name must be an atom, got: 10 at attributes -> attribute", fn -> defposts do attributes do @@ -29,7 +42,7 @@ defmodule Ash.Test.Dsl.Resource.AttributesTest do test "raises if the type is not a known type" do assert_raise( Ash.Error.ResourceDslError, - "Ash.Test.Dsl.Resource.AttributesTest.Post: Attribute type must be a built in type or a type module, got: 10 at attributes->attribute", + "Attribute type must be a built in type or a type module, got: 10 at attributes -> attribute -> foo", fn -> defposts do attributes do @@ -43,7 +56,7 @@ defmodule Ash.Test.Dsl.Resource.AttributesTest do test "raises if you pass an invalid value for `primary_key?`" do assert_raise( Ash.Error.ResourceDslError, - "Ash.Test.Dsl.Resource.AttributesTest.Post: option primary_key? at attributes->attribute must be of type :boolean", + "option primary_key? at attributes -> attribute must be of type :boolean", fn -> defposts do attributes do diff --git a/test/dsl/resource/dsl_test.exs b/test/dsl/resource/dsl_test.exs deleted file mode 100644 index e69de29b..00000000 diff --git a/test/dsl/resource/relationships/belongs_to_test.exs b/test/dsl/resource/relationships/belongs_to_test.exs new file mode 100644 index 00000000..89876cd8 --- /dev/null +++ b/test/dsl/resource/relationships/belongs_to_test.exs @@ -0,0 +1,153 @@ +defmodule Ash.Test.Dsl.Resource.Relationships.BelongsToTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates an attribute" do + defposts do + relationships do + belongs_to :foobar, FooBar + end + end + + assert [ + %Ash.Resource.Attributes.Attribute{ + name: :foobar_id, + primary_key?: false, + type: :uuid + } + ] = Ash.attributes(Post) + end + + test "it creates a relationship" do + defposts do + relationships do + belongs_to :foobar, FooBar + end + end + + assert [ + %Ash.Resource.Relationships.BelongsTo{ + cardinality: :one, + define_field?: true, + destination: FooBar, + destination_field: :id, + field_type: :uuid, + name: :foobar, + primary_key?: false, + source_field: :foobar_id, + type: :belongs_to + } + ] = Ash.relationships(Post) + end + end + + describe "validations" do + test "fails if destination_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option destination_field at relationships -> belongs_to -> foobar must be of type :atom", + fn -> + defposts do + relationships do + belongs_to :foobar, FooBar, destination_field: "foo" + end + end + end + ) + end + + test "fails if source_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option source_field at relationships -> belongs_to -> foobar must be of type :atom", + fn -> + defposts do + relationships do + belongs_to :foobar, FooBar, source_field: "foo" + end + end + end + ) + end + + test "fails if the destination is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "related resource must be a module representing a resource at relationships -> belongs_to -> foobar", + fn -> + defposts do + relationships do + belongs_to :foobar, "foobar" + end + end + end + ) + end + + test "fails if the relationship name is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "relationship_name must be an atom at relationships -> belongs_to", + fn -> + defposts do + relationships do + belongs_to "foobar", Foobar + end + end + end + ) + end + + test "fails if `primary_key?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option primary_key? at relationships -> belongs_to -> foobar must be of type :boolean", + fn -> + defposts do + relationships do + belongs_to :foobar, Foobar, primary_key?: "blah" + end + end + end + ) + end + end + + test "fails if `define_field?` is not a boolean" do + assert_raise( + Ash.Error.ResourceDslError, + "option define_field? at relationships -> belongs_to -> foobar must be of type :boolean", + fn -> + defposts do + relationships do + belongs_to :foobar, Foobar, define_field?: "blah" + end + end + end + ) + end + + test "fails if `field_type` is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option field_type at relationships -> belongs_to -> foobar must be of type :atom", + fn -> + defposts do + relationships do + belongs_to :foobar, Foobar, field_type: "foo" + end + end + end + ) + end +end diff --git a/test/dsl/resource/relationships/has_many_test.exs b/test/dsl/resource/relationships/has_many_test.exs new file mode 100644 index 00000000..4c6f9e0e --- /dev/null +++ b/test/dsl/resource/relationships/has_many_test.exs @@ -0,0 +1,92 @@ +defmodule Ash.Test.Dsl.Resource.Relationshihps.HasManyTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates a relationship" do + defposts do + relationships do + has_many :foobar, FooBar + end + end + + assert [ + %Ash.Resource.Relationships.HasMany{ + cardinality: :many, + destination: FooBar, + destination_field: :post_id, + name: :foobar, + source_field: :id, + type: :has_many + } + ] = Ash.relationships(Post) + end + end + + describe "validations" do + test "fails if destination_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option destination_field at relationships -> has_many -> foobar must be of type :atom", + fn -> + defposts do + relationships do + has_many :foobar, FooBar, destination_field: "foo" + end + end + end + ) + end + + test "fails if source_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option source_field at relationships -> has_many -> foobar must be of type :atom", + fn -> + defposts do + relationships do + has_many :foobar, FooBar, source_field: "foo" + end + end + end + ) + end + + test "fails if the destination is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "related resource must be a module representing a resource at relationships -> has_many -> foobar", + fn -> + defposts do + relationships do + has_many :foobar, "foobar" + end + end + end + ) + end + + test "fails if the relationship name is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "relationship_name must be an atom at relationships -> has_many", + fn -> + defposts do + relationships do + has_many "foobar", Foobar + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/relationships/has_one_test.exs b/test/dsl/resource/relationships/has_one_test.exs new file mode 100644 index 00000000..8fad42e8 --- /dev/null +++ b/test/dsl/resource/relationships/has_one_test.exs @@ -0,0 +1,92 @@ +defmodule Ash.Test.Dsl.Resource.Relationshihps.HasOneTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates a relationship" do + defposts do + relationships do + has_one :foobar, FooBar + end + end + + assert [ + %Ash.Resource.Relationships.HasOne{ + cardinality: :one, + destination: FooBar, + destination_field: :post_id, + name: :foobar, + source_field: :id, + type: :has_one + } + ] = Ash.relationships(Post) + end + end + + describe "validations" do + test "fails if destination_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option destination_field at relationships -> has_one -> foobar must be of type :atom", + fn -> + defposts do + relationships do + has_one :foobar, FooBar, destination_field: "foo" + end + end + end + ) + end + + test "fails if source_field is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "option source_field at relationships -> has_one -> foobar must be of type :atom", + fn -> + defposts do + relationships do + has_one :foobar, FooBar, source_field: "foo" + end + end + end + ) + end + + test "fails if the destination is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "related resource must be a module representing a resource at relationships -> has_one -> foobar", + fn -> + defposts do + relationships do + has_one :foobar, "foobar" + end + end + end + ) + end + + test "fails if the relationship name is not an atom" do + assert_raise( + Ash.Error.ResourceDslError, + "relationship_name must be an atom at relationships -> has_one", + fn -> + defposts do + relationships do + has_one "foobar", Foobar + end + end + end + ) + end + end +end diff --git a/test/dsl/resource/relationships/many_to_many_test.exs b/test/dsl/resource/relationships/many_to_many_test.exs new file mode 100644 index 00000000..3ba55373 --- /dev/null +++ b/test/dsl/resource/relationships/many_to_many_test.exs @@ -0,0 +1,117 @@ +defmodule Ash.Test.Dsl.Resource.Relationships.ManyToManyTest do + use ExUnit.Case, async: true + + defmacrop defposts(do: body) do + quote do + defmodule Post do + use Ash.Resource, name: "posts", type: "post", primary_key: false + + unquote(body) + end + end + end + + describe "representation" do + test "it creates a relationship" do + defposts do + relationships do + many_to_many :foobars, Foobar, through: "some_table" + end + end + + assert [ + %Ash.Resource.Relationships.ManyToMany{ + cardinality: :many, + destination: Foobar, + destination_field: :id, + destination_field_on_join_table: :foobars_id, + name: :foobars, + source_field: :id, + source_field_on_join_table: :posts_id, + through: "some_table", + type: :many_to_many + } + ] = Ash.relationships(Post) + end + end + + describe "validation" do + test "you can pass a string to `through`" do + defposts do + relationships do + many_to_many :foobars, Foobar, through: "some_table" + end + end + end + + test "you can pass a module to `through`" do + defposts do + relationships do + many_to_many :foobars, Foobar, through: FooBars + end + end + end + + test "it fails if you dont pass an atom for `source_field_on_join_table`" do + assert_raise( + Ash.Error.ResourceDslError, + "option source_field_on_join_table at relationships -> many_to_many -> foobars must be of type :atom", + fn -> + defposts do + relationships do + many_to_many :foobars, Foobar, through: "table", source_field_on_join_table: "what" + end + end + end + ) + end + + test "it fails if you dont pass an atom for `destination_field_on_join_table`" do + assert_raise( + Ash.Error.ResourceDslError, + "option destination_field_on_join_table at relationships -> many_to_many -> foobars must be of type :atom", + fn -> + defposts do + relationships do + many_to_many :foobars, Foobar, + through: "table", + destination_field_on_join_table: "what" + end + end + end + ) + end + + test "it fails if you dont pass an atom for `source_field`" do + assert_raise( + Ash.Error.ResourceDslError, + "option source_field at relationships -> many_to_many -> foobars must be of type :atom", + fn -> + defposts do + relationships do + many_to_many :foobars, Foobar, + through: "table", + source_field: "what" + end + end + end + ) + end + + test "it fails if you dont pass an atom for `destination_field`" do + assert_raise( + Ash.Error.ResourceDslError, + "option destination_field at relationships -> many_to_many -> foobars must be of type :atom", + fn -> + defposts do + relationships do + many_to_many :foobars, Foobar, + through: "table", + destination_field: "what" + end + end + end + ) + end + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e7..b4953733 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,4 @@ ExUnit.start() + +# We compile modules with the same name often while testing the DSL +Code.compiler_options(ignore_module_conflict: true)