From 7ef2f4884cbca7f24e3a0dd861da3faad6c6be97 Mon Sep 17 00:00:00 2001 From: Zach Daniel Date: Wed, 21 Sep 2022 18:49:59 -0400 Subject: [PATCH] improvement: remove __timestamps__ in favor of simpler macro docs: add extending resources guide --- documentation/tutorials/base-resources.md | 0 .../tutorials/extending-resources.md | 225 ++++++++++++++++++ lib/ash/flow/transformers/set_types.ex | 1 - lib/ash/resource/attribute.ex | 11 + lib/ash/resource/dsl.ex | 18 +- .../transformers/replace_timestamps.ex | 48 ---- 6 files changed, 237 insertions(+), 66 deletions(-) delete mode 100644 documentation/tutorials/base-resources.md create mode 100644 documentation/tutorials/extending-resources.md delete mode 100644 lib/ash/resource/transformers/replace_timestamps.ex diff --git a/documentation/tutorials/base-resources.md b/documentation/tutorials/base-resources.md deleted file mode 100644 index e69de29b..00000000 diff --git a/documentation/tutorials/extending-resources.md b/documentation/tutorials/extending-resources.md new file mode 100644 index 00000000..85b17b12 --- /dev/null +++ b/documentation/tutorials/extending-resources.md @@ -0,0 +1,225 @@ +# Extending Resource + +Resource extensions allow you to make powerful modifications to resources, and extend the DSL to configure how those modifications are made. If you are using `AshPostgres`, `AshGraphql` or `AshJsonApi`, they are all integrated into a resource using extensions. In this guide we will build a simple extension that adds timestamps to your resource. We'll also show some simple patterns that can help ensure that all of your resources are using your extension. + +## Creating an extension + +Extensions are modules that expose a set of DSL Transformers and DSL Sections. We'll start with the transformers. + +Here we create an extension called `MyApp.Extensions.Base`, and configure a single transformer, called `MyApp.Extensions.Base.AddTimestamps` + +```elixir +defmodule MyApp.Extensions.Base do + use Spark.Dsl.Extension, transformers: [MyApp.Extensions.Base.AddTimestamps] +end +``` + +## Creating a transformer + +Transformers are all run serially against a map of data called `dsl_state`, which is the data structure that we build as we use the DSL. For example: + +```elixir +attributes do + attribute :name, :string +end +``` + +Would, under the hood might look like this: + +```elixir +%{ + [:attributes] => %{entities: [ + %Ash.Resource.Attribute{name: :name, type: :string} + ] + }, + ... +} +``` + +`Spark.Dsl.Transformer` provides utilities to work with this data structure, and most introspection utilities also work with with that data structure (i.e `Ash.Resource.Info.attributes(dsl_state)`). A transformer exposes `transform/1`, which takes the `dsl_state` and returns either `{:ok, dsl_state}` or `{:error, error}` + +```elixir +defmodule MyApp.Extensions.Base.AddTimestamps do + use Spark.Dsl.Transformer + alias Spark.Dsl.Transformer + + def transform(dsl_state) do + {:ok, inserted_at} = + Transformer.build_entity(Ash.Resource.Dsl, [:attributes], :create_timestamp, + name: :inserted_at + ) + + {:ok, updated_at} = + Transformer.build_entity(Ash.Resource.Dsl, [:attributes], :update_timestamp, + name: :updated_at + ) + + {:ok, + dsl_state + |> Transformer.add_entity([:attributes], inserted_at) + |> Transformer.add_entity([:attributes], updated_at)} + end +end + +``` + +This transformer builds and adds a `create_timestamp` called `:inserted_at` and an `update_timestamp` called `:updated_at`. + +### Introspecting the resource + +If the resource we are extending already has an attribute called `inserted_at` or `updated_at`, we'd most likely want to avoid adding one ourselves (this would cause a compile error about duplicate attribute names). We can check for an existing attribute and make that change like so: + +```elixir + def transform(dsl_state) do + {:ok, + dsl_state + |> add_attribute_if_not_exists(:create_timestamp, :inserted_at) + |> add_attribute_if_not_exists(:update_timestamp, :updated_at)} + end + + defp add_attribute_if_not_exists(dsl_state, type, name) do + if Ash.Resource.Info.attribute(dsl_state, name) do + dsl_state + else + {:ok, attribute} = + Transformer.build_entity(Ash.Resource.Dsl, [:attributes], type, + name: name + ) + + dsl_state + |> Transformer.add_entity([:attributes], attribute) + end + end +``` + +This is just one example of what you can do with transformers. Check out the functions in `Spark.Dsl.Transformer` to see what utilities are available. + +### Make the extension configurable + +So far we've covered transformers, and using them to modify resources, but now lets say we want to make this behavior opt-out. Perhaps certain resources really shouldn't have timestamps, but we want it to be the default. Lets add a "DSL Section" to our extension. + +```elixir +defmodule MyApp.Extensions.Base do + @base %Ash.Dsl.Section{ + name: :base, + describe: """ + Configure the behavior of our base extension. + """, + examples: [ + """ + base do + timestamps? false + end + """ + ], + schema: [ + timestamps?: [ + type: :boolean, + doc: "Set to false to skip adding timestamps", + default: true + ] + ] + } + + defmodule Info do + def timestamps?(resource) do + Spark.Dsl.Extension.get_option(resource, [:base], :timestamps?, true) + end + end + + use Spark.Dsl.Extension, + transformers: [MyApp.Extensions.Base.AddTimestamps], + sections: [@base] +end +``` + +Now we can use this configuration in our transformer, like so: + +```elixir + def transform(dsl_state) do + if MyApp.Extensions.Base.Info.timestamps?(dsl_state) do + {:ok, + dsl_state + |> add_attribute_if_not_exists(:create_timestamp, :inserted_at) + |> add_attribute_if_not_exists(:update_timestamp, :updated_at)} + else + {:ok, dsl_state} + end + end + + defp add_attribute_if_not_exists(dsl_state, type, name) do + if Ash.Resource.Info.attribute(dsl_state, name) do + dsl_state + else + {:ok, attribute} = + Transformer.build_entity(Ash.Resource.Dsl, [:attributes], type, + name: name + ) + + dsl_state + |> Transformer.add_entity([:attributes], attribute) + end + end +``` + +And now we have a configurable base extension + +### A note on the ordering of transformers + +In this case, this transformer can run in any order. However, as we start adding transformers and/or modify the behavior of this one, we may need to ensure that our transformer runs before or after specific transformers. As of the writing of this guide, the best way to look at the list of transformers is to look at the source of the extension, and see what transformers it has and what they do. The [Resource DSL](https://github.com/ash-project/ash/blob/main/lib/ash/resource/dsl.ex) for example. + +If you need to affect the ordering, you can define `before?/1` and `after?/1` in your transformer, i.e + +```elixir +# I go after any other transformer +def after?(_), do: true + +# except I go before `SomeOtherTransformer` +def before?(SomeOtherTransformer), do: true +def before?(_), do: false +``` + +## Using your extension + +Now it can be used like any other extension: + +```elixir +defmodule MyApp.Tweet do + use Ash.Resource, + extensions: [MyApp.Extensions.Base] + + base do + # And you can configure it like so + timestamps? false + end +end +``` + +Your extension will be automatically supported by the `elixir_sense` extension, showing inline documentation and auto complete as you type. For more on that, see {{link:ash:guide:Development Utilities}}. + +## Making a Base Resource + +The "Base Resource" pattern has been adopted by some as a way to make it easy to ensure that your base extension is used everywhere. Instead of using `Ash.Resource` you use `MyApp.Resource`. Take a look at the {{link:ash:guide:Development Utilities}} guide if you do this, as you will need to update your formatter configuration, if you are using it. + +```elixir +defmodule MyApp.Resource do + defmacro __using__(opts) do + quote do + use Ash.Resource, + unquote(Keyword.update(opts, :extensions, [MyApp.Extensions.Base], &[MyApp.Extensions.Base | &1])) + end + end +end +``` + +And now you can use it with your resources like this: + +```elixir +defmodule MyApp.Tweet do + use MyApp.Resource +end +``` + +## Ensuring that all resources use your base extension + +To do this, you could create an extension very similar to `Ash.Registry.ResourceValidations`, that ensures that any resource present uses your extension. `Spark.extensions/1` can be used to see what extensions a given module or `dsl_config` has adopted. diff --git a/lib/ash/flow/transformers/set_types.ex b/lib/ash/flow/transformers/set_types.ex index ce17fa5b..371cbd53 100644 --- a/lib/ash/flow/transformers/set_types.ex +++ b/lib/ash/flow/transformers/set_types.ex @@ -95,6 +95,5 @@ defmodule Ash.Flow.Transformers.SetTypes do end def after?(Ash.Resource.Transformers.BelongsToAttribute), do: true - def after?(Ash.Resource.Transformers.ReplaceTimestamps), do: true def after?(_), do: false end diff --git a/lib/ash/resource/attribute.ex b/lib/ash/resource/attribute.ex index fc1939c9..683bdeeb 100644 --- a/lib/ash/resource/attribute.ex +++ b/lib/ash/resource/attribute.ex @@ -20,6 +20,17 @@ defmodule Ash.Resource.Attribute do constraints: [] ] + defmodule Helpers do + @moduledoc "Helpers for building attributes" + + defmacro timestamps(opts \\ []) do + quote do + create_timestamp :inserted_at, unquote(opts) + update_timestamp :updated_at, unquote(opts) + end + end + end + @type t :: %__MODULE__{ name: atom(), constraints: Keyword.t(), diff --git a/lib/ash/resource/dsl.ex b/lib/ash/resource/dsl.ex index e06ce8a1..425cb5cc 100644 --- a/lib/ash/resource/dsl.ex +++ b/lib/ash/resource/dsl.ex @@ -46,21 +46,6 @@ defmodule Ash.Resource.Dsl do args: [:name] } - @timestamps %Spark.Dsl.Entity{ - name: :timestamps, - describe: """ - Declares non-writable `inserted_at` and `updated_at` attributes with create and update defaults of `&DateTime.utc_now/0`. - """, - examples: [ - "timestamps()" - ], - links: [], - target: Ash.Resource.Attribute, - auto_set_fields: [ - name: :__timestamps__ - ] - } - @integer_primary_key %Spark.Dsl.Entity{ name: :integer_primary_key, describe: """ @@ -98,6 +83,7 @@ defmodule Ash.Resource.Dsl do describe: """ A section for declaring attributes on the resource. """, + imports: [Ash.Resource.Attribute.Helpers], links: [], examples: [ """ @@ -135,7 +121,6 @@ defmodule Ash.Resource.Dsl do @attribute, @create_timestamp, @update_timestamp, - @timestamps, @integer_primary_key, @uuid_primary_key ] @@ -1217,7 +1202,6 @@ defmodule Ash.Resource.Dsl do Ash.Resource.Transformers.HasDestinationField, Ash.Resource.Transformers.CreateJoinRelationship, Ash.Resource.Transformers.CachePrimaryKey, - Ash.Resource.Transformers.ReplaceTimestamps, Ash.Resource.Transformers.ValidatePrimaryActions, Ash.Resource.Transformers.ValidateActionTypesSupported, Ash.Resource.Transformers.CountableActions, diff --git a/lib/ash/resource/transformers/replace_timestamps.ex b/lib/ash/resource/transformers/replace_timestamps.ex deleted file mode 100644 index 860baa03..00000000 --- a/lib/ash/resource/transformers/replace_timestamps.ex +++ /dev/null @@ -1,48 +0,0 @@ -defmodule Ash.Resource.Transformers.ReplaceTimestamps do - @moduledoc "Replaces a single `timestamps()` attribute with `inserted_at` and `updated_at` timestamps." - use Spark.Dsl.Transformer - - alias Spark.Dsl.Transformer - - def transform(dsl_state) do - attributes = - dsl_state - |> Transformer.get_entities([:attributes]) - |> Enum.flat_map(fn - %{name: :__timestamps__} -> - timestamp_attributes() - - _ -> - [] - end) - - {:ok, - Enum.reduce(attributes, dsl_state, fn attr, dsl_state -> - dsl_state - |> Transformer.add_entity([:attributes], attr) - |> Transformer.remove_entity([:attributes], fn attr -> - attr.name == :__timestamps__ - end) - end)} - end - - defp timestamp_attributes do - %{ - inserted_at: Ash.Resource.Attribute.create_timestamp_schema(), - updated_at: Ash.Resource.Attribute.update_timestamp_schema() - } - |> Enum.map(&build_attribute/1) - |> Enum.map(fn {:ok, attr} -> attr end) - end - - defp build_attribute({name, schema}) do - params = %{ - target: Ash.Resource.Attribute, - schema: schema, - auto_set_fields: [name: name], - transform: nil - } - - Spark.Dsl.Entity.build(params, [], []) - end -end