Defining nested macros for a DSL? How can you share state?

I’m trying to build a macro for edge definitions in a graph. How can I access parameters from the outer macro inside the inner macro?

  1. The direction macro must define a module with the block passed into schema.
  2. The schema macro must implicitly be able to access the “accesses” string passed into the top-level edge macro.

How can I define complex multi-level macros like this for building DSLs but have the state be shared like I described?

This is the edge module:

defmodule App.Edge.Accesses do
  import App.Edge.EdgeType

  alias App.Users.User
  alias App.Invitations.Invitation
  alias App.Tenants.Tenant

  edge "accesses" do
    direction from: User, to: Invitation
    direction from: Invitation, to: Tenant

    schema do
    end
  end
end

This is the EdgeType module with all of the macros:

defmodule Quota.Edge.EdgeType do
  defmacro edge(collection_name, do: do_block) do
    quote do
    end
  end

  defmacro direction(from: from, to: to) do
    quote do
    end
  end

  defmacro schema(do: do_block) do
    quote do
    end
  end
end

A somewhat common option often used for “contexts” like this is to use module attributes, where each macro would push their context into the attribute prior to injecting the body block and then pop it out after. Each inner macro can access the current context through that attribute.

I’m on my phone so can’t easily provide a more complete example, but basically the idea is to inject a call to Module.put_attribute at the beginning of the quote block, and then

Here’s an example (untested):

@edge_context :"#{__MODULE__}.edge"

defmacro edge(collection, do: quoted) do
  quote do
    prev = Module.get_attribute(__MODULE__, unquote(@edge_context), [])

    Module.put_attribute(__MODULE__, unquote(@edge_context), [unquote(collection) | prev])

    result = unquote(quoted)

    Module.put_attribute(__MODULE__, unquote(@edge_context), prev)

    result
  end
end

defmacro schema(do: quoted) do
  context = Module.get_attribute(__CALLER__.module, @edge_context)
  
  ...
end
1 Like

https://elixirforum.com/t/spark-dsl-builder-with-goodies/50015

1 Like

In spark, this DSL would look something like this:

defmodule App.Edge.EdgeType.Dsl do
  @schema ... (left out for brevity)

  @direction %Spark.Dsl.Entity{
    name: :direction,
    schema: [
      from: [type: :module, required: true],
      to: [type: :module, required: true]
    ]
  }
  
  defmodule Edge do
    defstruct [:name, :directions]
  end

  @edge %Spark.Dsl.Entity{
    name: :edge,
    target: Edge,
    schema: [
      name: [
        type: :atom,
        required: true
      ],
      entities: [
        directions: [@direction],
        schema: [@schema]
      ],
     singleton_entity_keys: [:schema]
    ]
  }

  @edges %Spark.Dsl.Section{
    name: :edges,
    top_level?: true,
    entities: [
      @edge
    ]
  }
  
  use Spark.Dsl.Extension, sections: [@edge]
end

defmodule App.Edge.EdgeType do
  use Spark.Dsl,
    default_extensions: [extensions: [App.Edge.EdgeType,Dsl]]
end

defmodule EdgeType.Info do
  use Spark.InfoGenerator, extension: App.Edge.EdgeType.Dsl, sections: [:edge]
end

With the above (I left some stuff out, and probably got a few things wrong since I typed this all in a forum window and didn’t test it), the syntax you’ve laid out should work as it is.

defmodule App.Edge.Accesses do
  use App.Edge.EdgeType # but you'd need to `use` instead of `import`

  alias App.Users.User
  alias App.Invitations.Invitation
  alias App.Tenants.Tenant

  edge "accesses" do
    direction from: User, to: Invitation
    direction from: Invitation, to: Tenant

    schema do
    end
  end
end
3 Likes

Thank you, is there a way to defer evaluation of everything inside the schema block? When I add the following code, it give me issues as Ecto.Schema is not imported into the App.Edge.Accesses module.

defmodule App.Edge.Accesses do
  use App.Edge.EdgeType

  alias App.Users.User
  alias App.Invitations.Invitation
  alias App.Tenants.Tenant

  edge "accesses" do
    direction(from: User, to: Invitation)
    direction(from: Invitation, to: Tenant)

    schema do
      field :role, Ecto.Enum, values: [:owner, :editor, :viewer]

      edge_fields()
      timestamps(type: :utc_datetime)
    end
  end
end

oh, interesting, I didn’t realize that was meant to be just a regular ecto schema. In Ash we define the ecto schema for Ash.Resource out-of-band. What is edge_fields() meant to do? I’m not sure what the best way is to accomplish what you want TBH.

So, to answer the specific question, you can add imports to the edge entity, i.e imports [Ecto.Schema]. But I feel like you might have a hard time making edge_fields() work.

I’ll have to think about it a bit more :slight_smile: