Polymorphic resources

I have a number of resources that I'm beginning to add things like comments, attachments to, and would be interested what the idiomatic way to do this is. For this case, I can see a macro or something like https://discord.com/channels/711271361523351632/1079057460700123186/1092860018862346300 working as a template, specifying a different resource name and table for each instance.
49 Replies
ZachDaniel
ZachDaniel3y ago
I would suggest writing an extension for that. Fragments are not meant for sharing behavior among resources, but for splitting up single resources. I can show you what an extension might look like for something like that when I’m back at my computer.
\ ឵឵឵
\ ឵឵឵OP3y ago
That would be lovely, thanks mate!
ZachDaniel
ZachDaniel3y ago
What you might also actually do is use ash_postgres' polymorphic? true option
ZachDaniel
ZachDaniel3y ago
This lets you have a single resource for MyApp.Comment for example, but have it create/manage many tables. So then you'd do:
has_many :comments, MyApp.Comment do
context %{data_layer: %{table: "post_comments"}}
end
has_many :comments, MyApp.Comment do
context %{data_layer: %{table: "post_comments"}}
end
The migration generator will find all relationships to polymorphic resources and generate a table for every single one of them.
\ ឵឵឵
\ ឵឵឵OP3y ago
Ok, interesting Looks quite nice, seems like the one major tradeoff would be sacrificing the reverse relationship.
ZachDaniel
ZachDaniel3y ago
Yeah, that is correct.
\ ឵឵឵
\ ឵឵឵OP3y ago
Is there a way with Spark to write an extension that is similarly terse to a fragment? If not, any downsides to combining extension + fragment for that purpose?
ZachDaniel
ZachDaniel3y ago
Well, the fragment won't have any dynamism So you couldn't do things like modify the keys i.e assuming destination_attribute :post_id on one resource and destination_attribute :other_thing_id on one resource Honestly this is probably not worth it, but: The alternative, done with an extension, might look something like this:
defmodule MyApp.Extensions.Commentable do
@comments %Spark.Dsl.Section{
name: :comments,
schema: [
# may want more options here
reverse_relationship: [
type: :atom,
required: true
],
table: [
type: :string
]
]
}

@transformers [MyApp.Extensions.Commentable.Transformers.AddComments]

use Spark.Dsl.Extension, sections: [@comments], transformers: @transformers
end

defmodule MyApp.Extensions.Commentable.Info do
# Generate introspection functions
use Spark.InfoGenerator, extensions: MyApp.Extensions.Commentable, sections: [:comments]
end

defmodule MyApp.Extensions.Commentable.Transformers.AddComments do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

def transform(dsl) do
comments_resource = define_comments_resource(dsl)

Ash.Resource.Builder.add_relationship(dsl, :has_many, :comments, comments_resource)
end

defp define_comments_resource(dsl) do
resource_module = Transformer.get_persisted(dsl, :module)
comments_module = Module.concat(resource_module, Comments)

defmodule comments_module do
use Ash.Resource,
data_layer: AshPostgres.DataLayer

postgres do
table unquote(MyApp.Extensions.Commentable.Info.comments_table(dsl) || "#{AshPostgres.DataLayer.Info.table(dsl)}_comments")
repo MyApp.Repo
end

attributes do
attribute :text, :string, allow_nil?: false
end

relationships do
belongs_to unquote(MyApp.Extensions.Commentable.Info.comments_reverse_relationship(dsl)), unquote(comments_module) do
allow_nil? false
end
end
end

comments_module
end
end
defmodule MyApp.Extensions.Commentable do
@comments %Spark.Dsl.Section{
name: :comments,
schema: [
# may want more options here
reverse_relationship: [
type: :atom,
required: true
],
table: [
type: :string
]
]
}

@transformers [MyApp.Extensions.Commentable.Transformers.AddComments]

use Spark.Dsl.Extension, sections: [@comments], transformers: @transformers
end

defmodule MyApp.Extensions.Commentable.Info do
# Generate introspection functions
use Spark.InfoGenerator, extensions: MyApp.Extensions.Commentable, sections: [:comments]
end

defmodule MyApp.Extensions.Commentable.Transformers.AddComments do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

def transform(dsl) do
comments_resource = define_comments_resource(dsl)

Ash.Resource.Builder.add_relationship(dsl, :has_many, :comments, comments_resource)
end

defp define_comments_resource(dsl) do
resource_module = Transformer.get_persisted(dsl, :module)
comments_module = Module.concat(resource_module, Comments)

defmodule comments_module do
use Ash.Resource,
data_layer: AshPostgres.DataLayer

postgres do
table unquote(MyApp.Extensions.Commentable.Info.comments_table(dsl) || "#{AshPostgres.DataLayer.Info.table(dsl)}_comments")
repo MyApp.Repo
end

attributes do
attribute :text, :string, allow_nil?: false
end

relationships do
belongs_to unquote(MyApp.Extensions.Commentable.Info.comments_reverse_relationship(dsl)), unquote(comments_module) do
allow_nil? false
end
end
end

comments_module
end
end
So that actually defines the comments module for the resource automatically.
use Ash.Resource, extensions: [MyApp.Extensions.Commentable]

comments do
reverse_relationship :post
end
use Ash.Resource, extensions: [MyApp.Extensions.Commentable]

comments do
reverse_relationship :post
end
Would be how you configure it in a given resource
\ ឵឵឵
\ ឵឵឵OP3y ago
Very cool! That looks pretty reasonable, but I understand what you mean that it might be a bit overkill.
ZachDaniel
ZachDaniel3y ago
It really just depends on how bought into the idea of constructing your app from your core domain + extensions 😆 I'd do the above, because I know how it all works
\ ឵឵឵
\ ឵឵឵OP3y ago
My thought was to hit somewhere in the middle and have an extension for the comments resource itself that would essentially do the same polymorphism as the data layer approach above.
ZachDaniel
ZachDaniel3y ago
Yeah, you can definitely do that So youd do something like:
defmodule MyApp.PostComments do
use Ash.Resource, extensions: [MyApp.Extensions.Comment]

comment do
comments_on :post, MyApp.Post
end
end
defmodule MyApp.PostComments do
use Ash.Resource, extensions: [MyApp.Extensions.Comment]

comment do
comments_on :post, MyApp.Post
end
end
?
\ ឵឵឵
\ ឵឵឵OP3y ago
If I were to write it as a macro, something like:
defmodule App.Comment do
def __using__(opts \\ []) do
name = opts[:name]
quote do
use Ash.Resource, ...

attributes do
attribute :content, :string
...
end

relationships do
belongs_to :parent, unquote(opts[:parent])
end

postgres do
table unquote(opts[:name])
end
end
end
end

defmodule App.PostComment do
use App.Comment, name: "post_comments", parent: App.Post
end
defmodule App.Comment do
def __using__(opts \\ []) do
name = opts[:name]
quote do
use Ash.Resource, ...

attributes do
attribute :content, :string
...
end

relationships do
belongs_to :parent, unquote(opts[:parent])
end

postgres do
table unquote(opts[:name])
end
end
end
end

defmodule App.PostComment do
use App.Comment, name: "post_comments", parent: App.Post
end
The extension definitely does a bit more for you.
ZachDaniel
ZachDaniel3y ago
That will work, up to a point. If you try to use expr in there i.e to add actions that do certain things (not that you need to) then you may see some issues. Additionally, you won't be able to benefit from some compile time optimizations we make available to extensions. Its definitely okay to do what you've shown there. But FWIW I encourage people to "break the seal" so to speak on writing extensions as early as possible, because they are the most flexible way to extend your Ash app, and come with lots of tools to solve common metaprogramming problems. But its totally up to you, and the above will likely work just fine 🙂
\ ឵឵឵
\ ឵឵឵OP3y ago
Sure thing! I have a number of macros that I would consider converting to Spark, I think it would also benefit error reporting and validation. Actually, I was curious how one would write the above macro using Spark. In this particular case, it's not a problem for comments/attachments to be defined in the parent resource (except making sure that GQL doesn't get upset), so the comments section is quite cool. This would also make it easy to add actions on that resource in the parent.
ZachDaniel
ZachDaniel3y ago
You can do all sorts of things in spark transformers, like set DSL options, add entities (attributes/relationships) that kind of thing its only partially automated. I.e you have Ash.Resource.Builder.add_attribute but for setting the table you'd need to do something like Transformer.set_option(dsl, [:postgres], :table, "...")
\ ឵឵឵
\ ឵឵឵OP3y ago
No worries, that makes sense. Is there a generic helper like that for appending to a section that supports multiple, or is that the behaviour of set_option already? Starting to test out the Commentable modules, pretty interesting stuff. Were the unquotes intentional?
ZachDaniel
ZachDaniel3y ago
For your first question you'd use Transformer.add_entity (or ideally the functions in Ash.Resource.Builder) there are sections, entities, and options
\ ឵឵឵
\ ឵឵឵OP3y ago
Unfortunately looks like I may be running into problems mixing my existing macros with the extensions, as there's stuff defined there that this one wants.
ZachDaniel
ZachDaniel3y ago
resource do <- section
description "" <- option
end

attributes do <- section
attribute :foo, :bar <- entity
end
resource do <- section
description "" <- option
end

attributes do <- section
attribute :foo, :bar <- entity
end
The unquote was intentional, yes
\ ឵឵឵
\ ឵឵឵OP3y ago
It was erroring out for me on dsl not being defined.
ZachDaniel
ZachDaniel3y ago
Ah, sorry you need:
Module.create(module_name, quote do
#the module body
end, __ENV__)
Module.create(module_name, quote do
#the module body
end, __ENV__)
\ ឵឵឵
\ ឵឵឵OP3y ago
After removing them it seems to be doing better, but I derive my table name from the module in my base resource macro so it doesn't seem to be picked up.
ZachDaniel
ZachDaniel3y ago
I don't think thats the issue
\ ឵឵឵
\ ឵឵឵OP3y ago
Ah, right on. That makes more sense.
ZachDaniel
ZachDaniel3y ago
the table name should be set by the time you are in the transformer
\ ឵឵឵
\ ឵឵឵OP3y ago
Even when defined in a use macro?
ZachDaniel
ZachDaniel3y ago
yep
\ ឵឵឵
\ ឵឵឵OP3y ago
I thought so as well... Then there must be something else going on.
** (RuntimeError) No configuration for `table` present on `%{:persist => %{autho
** (RuntimeError) No configuration for `table` present on `%{:persist => %{autho
I see other stuff generated by the macro in that struct, though.
ZachDaniel
ZachDaniel3y ago
That error looks strange Can you paste the whole transformer in?
\ ឵឵឵
\ ឵឵឵OP3y ago
defmodule App.Extension.Commentable do
@comments %Spark.Dsl.Section{
name: :comments,
schema: [
# may want more options here
reverse_relationship: [
type: :atom,
required: true
],
table: [
type: :string
]
]
}

@transformers [App.Extension.Commentable.Transformers.AddComments]

use Spark.Dsl.Extension, sections: [@comments], transformers: @transformers
end

defmodule App.Extension.Commentable.Info do
# Generate introspection functions
use Spark.InfoGenerator, extension: App.Extension.Commentable, sections: [:comments]
end

defmodule App.Extension.Commentable.Transformers.AddComments do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

def transform(dsl) do
comments_resource = define_comments_resource(dsl)

Ash.Resource.Builder.add_relationship(dsl, :has_many, :comments, comments_resource)
end

defp define_comments_resource(dsl) do
resource_module = Transformer.get_persisted(dsl, :module)
comments_module = Module.concat(resource_module, Comments)
table_name = App.Extension.Commentable.Info.comments_table!(dsl) ||
"#{AshPostgres.DataLayer.Info.table(dsl)}_comments"
reverse_relationship = App.Extension.Commentable.Info.comments_reverse_relationship!(dsl)

Module.create(comments_module, quote do
use Ash.Resource, data_layer: AshPostgres.DataLayer

postgres do
table unquote(table_name)
repo App.Repo
end

attributes do
attribute :text, :string, allow_nil?: false
end

relationships do
belongs_to unquote(reverse_relationship), unquote(resource_module) do
allow_nil? false
end
end
end, __ENV__)

comments_module
end
end
defmodule App.Extension.Commentable do
@comments %Spark.Dsl.Section{
name: :comments,
schema: [
# may want more options here
reverse_relationship: [
type: :atom,
required: true
],
table: [
type: :string
]
]
}

@transformers [App.Extension.Commentable.Transformers.AddComments]

use Spark.Dsl.Extension, sections: [@comments], transformers: @transformers
end

defmodule App.Extension.Commentable.Info do
# Generate introspection functions
use Spark.InfoGenerator, extension: App.Extension.Commentable, sections: [:comments]
end

defmodule App.Extension.Commentable.Transformers.AddComments do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

def transform(dsl) do
comments_resource = define_comments_resource(dsl)

Ash.Resource.Builder.add_relationship(dsl, :has_many, :comments, comments_resource)
end

defp define_comments_resource(dsl) do
resource_module = Transformer.get_persisted(dsl, :module)
comments_module = Module.concat(resource_module, Comments)
table_name = App.Extension.Commentable.Info.comments_table!(dsl) ||
"#{AshPostgres.DataLayer.Info.table(dsl)}_comments"
reverse_relationship = App.Extension.Commentable.Info.comments_reverse_relationship!(dsl)

Module.create(comments_module, quote do
use Ash.Resource, data_layer: AshPostgres.DataLayer

postgres do
table unquote(table_name)
repo App.Repo
end

attributes do
attribute :text, :string, allow_nil?: false
end

relationships do
belongs_to unquote(reverse_relationship), unquote(resource_module) do
allow_nil? false
end
end
end, __ENV__)

comments_module
end
end
Interestingly, if I set table "posts_comments" explicitly, and add uuid_primary_key :id, now i'm getting:
warning: invalid association `issue` in schema App.Post.Comments: associated module App.Post is not an Ecto schema
lib/uncle/extensions/commentable.ex:61: App.Post.Comments (module)
warning: invalid association `issue` in schema App.Post.Comments: associated module App.Post is not an Ecto schema
lib/uncle/extensions/commentable.ex:61: App.Post.Comments (module)
ZachDaniel
ZachDaniel3y ago
App.Extension.Commentable.Info.comments_table!(dsl) that raises an error if the configuration is not set IIRC You might need
case App.Extesion.Commentable.Info.comments_table(dsl) do
{:ok, table} -> table
:error -> <default>
end
case App.Extesion.Commentable.Info.comments_table(dsl) do
{:ok, table} -> table
:error -> <default>
end
\ ឵឵឵
\ ឵឵឵OP3y ago
table_name =
case App.Extension.Commentable.Info.comments_table(dsl) do
{:ok, name} -> name
:error -> "#{AshPostgres.DataLayer.Info.table(dsl)}_comments"
end
table_name =
case App.Extension.Commentable.Info.comments_table(dsl) do
{:ok, name} -> name
:error -> "#{AshPostgres.DataLayer.Info.table(dsl)}_comments"
end
Probably need ! for Info.table as well. Ok, still getting the warning about not being an Ecto schema, but now a ton of GQL complaints about non-unique types. Fixed the GQL stuff 🙂 Can I discover the registry of the parent with Spark and have the comment resource add itself?
ZachDaniel
ZachDaniel3y ago
only kind of What you actually have to do is write an extension for the registry 😆
defmodule RegistryExtension do
use Spark.Dsl.Extension, transformers: [AddCommentsResource]
end

defmodule AddCommentsResource do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

# You want this to go before everything else
def before?(_), do: true

def transform(dsl) do
resources = Ash.Registry.entries(dsl)
# use `Transformer.add_entity` for each module using your extension
end
end
defmodule RegistryExtension do
use Spark.Dsl.Extension, transformers: [AddCommentsResource]
end

defmodule AddCommentsResource do
use Spark.Dsl.Transformer
alias Spark.Dsl.Transformer

# You want this to go before everything else
def before?(_), do: true

def transform(dsl) do
resources = Ash.Registry.entries(dsl)
# use `Transformer.add_entity` for each module using your extension
end
end
\ ឵឵឵
\ ឵឵឵OP3y ago
Nice, will add that in as well. Cool! Is there a more optimal way to detect the comments section on the resource than
case App.Extension.Commentable.Info.comments_reverse_relationship(r) do
case App.Extension.Commentable.Info.comments_reverse_relationship(r) do
ZachDaniel
ZachDaniel3y ago
You can do YourExtensionName in Spark.extensions(module)
\ ឵឵឵
\ ឵឵឵OP3y ago
Right on. I thought about just adding the extension to my base resource so I wanted to check if the section is used. Is there a way to detect the presence of the whole comments section rather than an entry in it? Or is it there implicitly and empty? Tbh this is probably not the normal pattern 🙂
ZachDaniel
ZachDaniel3y ago
Yeah, I wouldn't suggest doing that There is no good way to detect if a section has been opened
\ ឵឵឵
\ ឵឵឵OP3y ago
Cool
ZachDaniel
ZachDaniel3y ago
If at all possible, I'd suggest just adding your extension to each resource, but otherwise you'd need to add some kind of boolean flag like
comments do
enabled? true
end
comments do
enabled? true
end
Welll...I guess maybe that isn't true I think you can do Map.has_key?(dsl, [:comments])? still, would be pretty non-idiomatic
\ ឵឵឵
\ ឵឵឵OP3y ago
For sure, I'll stick with letting the existence of the extension be the indicator. Thanks a lot, mate! Will definitely be looking for opportunities to use this going forward 🙂 Wanted to check back in on this one. I'm still seeing this warning:
warning: invalid association `post` in schema App.Post.Comment: associated module App.Post is not an Ecto schema
warning: invalid association `post` in schema App.Post.Comment: associated module App.Post is not an Ecto schema
Any idea what might be the cause?
ZachDaniel
ZachDaniel3y ago
🤔 Interesting. So App.Post is the target Ash resource?
\ ឵឵឵
\ ឵឵឵OP3y ago
App.Post is the resource with the comments block.
ZachDaniel
ZachDaniel3y ago
🤔 but everything works otherwise? If so, mind making an issue in Ash? I'm not sure there is anything we can actually do about it but I can look into it Well, there is probably something we can do 🙂
\ ឵឵឵
\ ឵឵឵OP3y ago
Yep, will do when I get back to my desk. Any idea what the issue might be?
ZachDaniel
ZachDaniel3y ago
The basic issue is that, as a part of compiling App.Post you are compiling App.Post.Comment which then refers back to App.Post. Which is fine, except for ecto providing that warning.
\ ឵឵឵
\ ឵឵឵OP3y ago
GitHub
Resources defined within other resources issue Ecto warnings · Issu...
When creating a subresource using an extension, the compiler issues the following warning: warning: invalid association post in schema App.Post.Comment: associated module App.Post is not an Ecto ...
ZachDaniel
ZachDaniel3y ago
🙇

Did you find this page helpful?