From 030c389225fad02b102a4403c066b1f777b573b8 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Thu, 5 Dec 2019 02:18:13 -0500 Subject: [PATCH] WIP on cleanup/documentation --- README.md | 2 + config/config.exs | 8 -- lib/ash.ex | 26 ++++ lib/ash/constraints.ex | 4 + lib/ash/error/resource_dsl_error.ex | 4 + lib/ash/resource.ex | 153 +++++++++++++++-------- lib/ash/resource/attributes/attribute.ex | 18 ++- lib/ash/resource/dsl.ex | 19 +++ lib/test.ex | 2 - mix.exs | 2 +- mix.lock | 2 +- test/actions/read_test.exs | 1 - test/dsl/resource/top_level_test.exs | 0 13 files changed, 174 insertions(+), 67 deletions(-) delete mode 100644 config/config.exs create mode 100644 lib/ash/constraints.ex create mode 100644 lib/ash/resource/dsl.ex delete mode 100644 lib/test.ex create mode 100644 test/dsl/resource/top_level_test.exs diff --git a/README.md b/README.md index 9145a467..d4b40dd7 100644 --- a/README.md +++ b/README.md @@ -31,5 +31,7 @@ * make ets dep optional * Bake in descriptions to the DSL * Contributor guideline and code of conduct +* Do branch analysis of each record after authorizing it, in authorizer +* consider moving `type` and `name` for resources out into json api (or perhaps just `name`) since only json api uses that diff --git a/config/config.exs b/config/config.exs deleted file mode 100644 index 190e4ebc..00000000 --- a/config/config.exs +++ /dev/null @@ -1,8 +0,0 @@ -use Mix.Config - -if Mix.env() == :test do - config :ash, - resources: [ - Ash.Test.Post - ] -end diff --git a/lib/ash.ex b/lib/ash.ex index a80ebd93..29be2e26 100644 --- a/lib/ash.ex +++ b/lib/ash.ex @@ -1,5 +1,12 @@ defmodule Ash do + @moduledoc """ + The primary interface for interrogating apis and resources, an + + This is not the code level interface for a resource. Instead, call functions + on an `Api` module that contains those resources. + """ alias Ash.Resource.Relationships.{BelongsTo, HasOne, HasMany, ManyToMany} + alias Ash.Resource.Actions.{Create, Read, Update, Destroy} @type record :: struct @type cardinality_one_relationship() :: HasOne.t() | BelongsTo.t() @@ -7,20 +14,27 @@ defmodule Ash do @type relationship :: cardinality_one_relationship() | cardinality_many_relationship() @type query :: struct @type resource :: module + @type data_layer :: module @type api :: module @type error :: struct @type filter :: map() @type sort :: Keyword.t() @type side_loads :: Keyword.t() + @type attribute :: Ash.Attributes.Attribute.t() + @type action :: Create.t() | Read.t() | Update.t() | Destroy.t() + @type side_load_config :: Keyword.t() + @spec resources(api) :: list(resource()) def resources(api) do api.resources() end + @spec primary_key(resource()) :: nil | attribute() | list(attribute) def primary_key(resource) do resource.primary_key() end + @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)) end @@ -29,14 +43,17 @@ defmodule Ash do Enum.find(resource.relationships(), &(&1.name == relationship_name)) end + @spec relationships(resource()) :: list(relationship()) def relationships(resource) do resource.relationships() end + @spec side_load_config(api()) :: side_load_config() def side_load_config(api) do api.side_load_config() end + @spec primary_action(resource(), atom()) :: action() | nil def primary_action(resource, type) do resource |> actions() @@ -47,14 +64,17 @@ defmodule Ash do end end + @spec action(resource(), atom(), atom()) :: action() | nil def action(resource, name, type) do Enum.find(resource.actions(), &(&1.name == name && &1.type == type)) end + @spec actions(resource()) :: list(action()) def actions(resource) do resource.actions() end + @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)) end @@ -63,26 +83,32 @@ defmodule Ash do Enum.find(resource.attributes, &(&1.name == name)) end + @spec attributes(resource()) :: list(attribute()) def attributes(resource) do resource.attributes() end + @spec name(resource()) :: String.t() def name(resource) do resource.name() end + @spec type(resource()) :: String.t() def type(resource) do resource.type() end + @spec max_page_size(api(), resource()) :: non_neg_integer() | nil def max_page_size(api, resource) do min(api.max_page_size(), resource.max_page_size()) end + @spec default_page_size(api(), resource()) :: non_neg_integer() | nil def default_page_size(api, resource) do min(api.default_page_size(), resource.default_page_size()) end + @spec data_layer(resource()) :: data_layer() def data_layer(resource) do resource.data_layer() end diff --git a/lib/ash/constraints.ex b/lib/ash/constraints.ex new file mode 100644 index 00000000..63ce1320 --- /dev/null +++ b/lib/ash/constraints.ex @@ -0,0 +1,4 @@ +defmodule Ash.Constraints do + def positive?(integer), do: integer >= 0 + def greater_than_zero?(integer), do: integer > 0 +end diff --git a/lib/ash/error/resource_dsl_error.ex b/lib/ash/error/resource_dsl_error.ex index 942c6def..2885c2f7 100644 --- a/lib/ash/error/resource_dsl_error.ex +++ b/lib/ash/error/resource_dsl_error.ex @@ -1,6 +1,10 @@ defmodule Ash.Error.ResourceDslError do defexception [:message, :path, :option, :resource] + def message(%{message: message, path: nil, option: option, resource: resource}) do + "#{inspect(resource)}: #{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}" diff --git a/lib/ash/resource.ex b/lib/ash/resource.ex index def695b6..6a7b0f84 100644 --- a/lib/ash/resource.ex +++ b/lib/ash/resource.ex @@ -1,44 +1,117 @@ defmodule Ash.Resource do + @primary_key_schema Ashton.schema( + opts: [field: :atom, type: :atom], + defaults: [field: :id, type: :uuid], + describe: [ + field: "The field name of the primary key of the resource.", + type: "The data type of the primary key of the resource." + ] + ) + + @resource_opts_schema Ashton.schema( + opts: [ + name: :string, + type: :string, + max_page_size: :integer, + default_page_size: :integer, + primary_key: [ + :boolean, + @primary_key_schema + ] + ], + describe: [ + name: + "The name of the resource. This will typically be the pluralized form of the type", + type: + "The type of the resource, e.g `post` or `author`. This is used throughout the system.", + max_page_size: + "The maximum page size for any read action. Any request for a higher page size will simply use this number.", + default_page_size: + "The default page size for any read action. If no page size is specified, this value is used.", + primary_key: + "If true, a default `id` uuid primary key is used. If false, none is created. See the primary_key opts for info on specifying primary key options." + ], + required: [:name, :type], + defaults: [ + primary_key: true + ], + constraints: [ + max_page_size: + {&Ash.Constraints.greater_than_zero?/1, "must be greater than zero"}, + default_page_size: + {&Ash.Constraints.greater_than_zero?/1, "must be greater than zero"} + ] + ) + + @moduledoc """ + The entry point for creating an `Ash.Resource`. + + This brings in the top level DSL macros, defines module attributes for aggregating state as + DSL functions are called, and defines a set of functions internal to the resource that can be + used to inspect them. + + Simply add `use Ash.Resource, ...` at the top of your resource module, and refer to the DSL documentation + at `Ash.Resource.DSL` for the rest. The options for `use Ash.Resource` are described below. + + #{Ashton.document(@resource_opts_schema)} + + 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)` + """ + defmacro __using__(opts) do quote do @before_compile Ash.Resource - @skip_data_layer unquote(Keyword.get(opts, :no_data_layer, false)) - Module.register_attribute(__MODULE__, :before_compile_hooks, accumulate: true) + opts = Ash.Resource.validate_use_opts(__MODULE__, unquote(opts)) + Ash.Resource.define_resource_module_attributes(__MODULE__, opts) + Ash.Resource.define_primary_key(__MODULE__, opts) - Module.register_attribute(__MODULE__, :actions, accumulate: true) - Module.register_attribute(__MODULE__, :attributes, accumulate: true) - Module.register_attribute(__MODULE__, :relationships, accumulate: true) - Module.register_attribute(__MODULE__, :mix_ins, accumulate: true) + use Ash.Resource.DSL + end + end - if unquote(Keyword.get(opts, :primary_key?, true)) do - @attributes Ash.Resource.Attributes.Attribute.new(__MODULE__, :id, :uuid, - primary_key?: true - ) - end + @doc false + def define_resource_module_attributes(mod, opts) 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, :mix_ins, accumulate: true) - # Module.put_attribute(__MODULE__, :custom_threshold_for_lib, 10) - import Ash.Resource - import Ash.Resource.Actions, only: [actions: 1] - import Ash.Resource.Attributes, only: [attributes: 1] - import Ash.Resource.Relationships, only: [relationships: 1] - import Ash.Authorization.Rule + Module.put_attribute(mod, :name, opts[:name]) + Module.put_attribute(mod, :resource_type, opts[:type]) + Module.put_attribute(mod, :max_page_size, opts[:max_page_size]) + Module.put_attribute(mod, :default_page_size, opts[:default_page_size]) + end - name = unquote(opts[:name]) - resource_type = unquote(opts[:type]) + @doc false + 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) + Module.put_attribute(mod, :attributes, attribute) - @name name - @resource_type resource_type - @max_page_size nil - @default_page_size nil + false -> + :ok - unless @name do - raise "Must set name" - end + opts -> + attribute = + Ash.Resource.Attributes.Attribute.new(mod, opts[:field], opts[:type], primary_key?: true) - unless @resource_type do - raise "Must set resource type" - end + 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, option: key, message: message + + {:ok, opts} -> + opts end end @@ -91,16 +164,8 @@ defmodule Ash.Resource do @default_page_size end - unless @skip_data_layer || @data_layer do - raise "Must `use` a data layer module or pass `no_data_layer: true`" - end - def data_layer() do - if @skip_data_layer do - false - else - @data_layer - end + @data_layer || false end Enum.map(@mix_ins || [], fn hook_module -> @@ -110,18 +175,6 @@ defmodule Ash.Resource do end end - defmacro max_page_size(page_size) do - quote do - @max_page_size unquote(page_size) - end - end - - defmacro default_page_size(page_size) do - quote do - @default_page_size unquote(page_size) - end - end - @doc false def primary_key(attributes) do attributes diff --git a/lib/ash/resource/attributes/attribute.ex b/lib/ash/resource/attributes/attribute.ex index 42c007c3..38cb4fca 100644 --- a/lib/ash/resource/attributes/attribute.ex +++ b/lib/ash/resource/attributes/attribute.ex @@ -1,12 +1,22 @@ defmodule Ash.Resource.Attributes.Attribute do + @moduledoc """ + The struct containing information about an attribute. + See the DSL documentation for more information on their usage in the DSL + """ + defstruct [:name, :type, :primary_key?] - @builtins Ash.Type.builtins() + @type t :: %__MODULE__{ + name: atom(), + type: Ash.type(), + primary_key?: boolean() + } - @option_schema Ashton.schema(opts: [primary_key?: :boolean]) + @builtins Ash.Type.builtins() + @schema Ashton.schema(opts: [primary_key?: :boolean], defaults: [primary_key?: false]) @doc false - def attribute_schema(), do: @option_schema + def attribute_schema(), do: @schema def new(resource, name, type, opts \\ []) @@ -25,7 +35,7 @@ defmodule Ash.Resource.Attributes.Attribute do end def new(resource, name, type, opts) when type in @builtins do - case Ashton.validate(opts, @option_schema) do + case Ashton.validate(opts, @schema) do {:error, [{key, message} | _]} -> raise Ash.Error.ResourceDslError, resource: resource, diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex new file mode 100644 index 00000000..3f0c210d --- /dev/null +++ b/lib/ash/resource/dsl.ex @@ -0,0 +1,19 @@ +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` + """ + + 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] + end + end +end diff --git a/lib/test.ex b/lib/test.ex deleted file mode 100644 index bb1d062d..00000000 --- a/lib/test.ex +++ /dev/null @@ -1,2 +0,0 @@ -defmodule Ash.Test do -end diff --git a/mix.exs b/mix.exs index 07222bce..ebeb97e0 100644 --- a/mix.exs +++ b/mix.exs @@ -43,7 +43,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.6"} + {:ashton, "~> 0.3.9"} ] end end diff --git a/mix.lock b/mix.lock index 1a13c904..5efbd141 100644 --- a/mix.lock +++ b/mix.lock @@ -1,5 +1,5 @@ %{ - "ashton": {:hex, :ashton, "0.3.6", "95f5d598c2e05662498349d81e7579e897b1e1cfe1aa79606d07a35305a47efe", [:mix], [], "hexpm"}, + "ashton": {:hex, :ashton, "0.3.7", "9349c196fb4302a08d5e19748f1af74bbe891b15c9a2ec11184f69ec1912f478", [: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/actions/read_test.exs b/test/actions/read_test.exs index a0c7e0de..f09cc9ec 100644 --- a/test/actions/read_test.exs +++ b/test/actions/read_test.exs @@ -1,6 +1,5 @@ defmodule Ash.Test.Actions.ReadTest do use ExUnit.Case, async: true - # import Ash.Test defmodule Post do use Ash.Resource, name: "posts", type: "post" diff --git a/test/dsl/resource/top_level_test.exs b/test/dsl/resource/top_level_test.exs new file mode 100644 index 00000000..e69de29b