Ash Embedded Union Type Attributes
Here's how to define, write and query union types in the Ash framework.
The docs explain this well. I will give an end to end example, defining, writing and reading unioned data, focused on embedded types in jsonb.
Define embedded union type
Jsonb is nicely flexible. But once you define an embedded type, you define which fields may or may not exist on your data. It because more structured. You can gain some of the flexibility back by indicating that there's a union of different types possible on the field. This gives it a polymorphism that can make it more useful for different scenarios.
These can be defined explicitly or implicitly, that is, tagged or untagged. I'll prefer explicitly, tagged unions. This tag provides a discriminator in the data to discover its specific type in the union.
Let's say that I have a checked out book resource. It has a due date action that's specified by user preference. The preference might be to auto renew or to send an SMS for due date. Let's define these models:
defmodule MyLibrary.Shelves.CheckedOutBook do
use Ash.Resource,
domain: MyLibrary.Shelves,
data_layer: AshPostgres.DataLayer,
extensions: [AshGraphql.Resource]
attributes do
attribute :due_action, :union,
constraints: [
types: [
auto_renew: [
type: MyLibrary.Shelves.AutoRenewer,
tag: :action,
tag_value: :auto_renew
],
send_sms: [
type: MyLibrary.Shelves.SmsSender,
tag: :action,
tag_value: :send_sms
]
]
]
end
end
defmodule MyLibrary.Shelves.AutoRenewer do
use Ash.Resource,
domain: MyLibrary.Shelves,
data_layer: :embedded,
extensions: [AshGraphql.Resource]
attributes do
attribute :length, :integer
end
# ...
end
defmodule MyLibrary.Shelves.SmsSender do
use Ash.Resource,
domain: MyLibrary.Shelves,
data_layer: :embedded,
extensions: [AshGraphql.Resource]
attributes do
attribute :greeting, :string
attribute :address, :string
end
# ...
end
Ok, there we are. We've defined 1 resource that has a field for an embedded type that can be either one thing or the other. Depending on the tag, the due_action can be marshalled and unmarshalled into the two types of due_action resources.
Inserted tagged union data
When a checked out book resource is created and the due_action is specified, it has a very specific shape that's required, in part, because it's a tagged union. Here's the auto_renew creation:
MyLibrary.Shelves.CheckedOutBook.create!(%{
due_action: %{
action: :auto_renew, length: 999
}
})
Note that it's created with the action field (the tag) as :auto_renew, thus identifying it as a certain kind of struct with a length attribute.
Remember, we chose action as the tag in our definition above. Another cool thing to note is that the tag field doesn't need to be added just for this purpose. You can key off another existing, meaningful field on your resource.
Reading tagged union data
When this data is saved in the database, the due_action jsonb column will have a shape like this:
{
"type": "auto_renew",
"value": {
"length": 999
}
}
Note that action is saved as type in the database. And note that the embedded resource itself is stored under a value key.
Querying tagged union data
This is state more fully in the post Query Embedded Jsonb in Ash, but let's show it for this specific kind of tagged union data. The query to get all SMSes that will be sent to 5551234, query:
MyLibrary.Shelves.CheckedOutBook
|> Ash.Query.filter(get_path(due_action, [:value, :address]) == "5551234")
|> Ash.read!()
Note that traversing the value key is needed.
Unions are pretty nice, eh? You need to be more... flexible.
Bonus: Direct SQL query
Just as a tack-on bonus, here's how to access the deep jsonb via psql:
select due_action->'value'->>'length'
from checked_out_book
Note that -> access the first-level object in the json. -> returns the next json blob, the inner object. Then inside value, we can return text with ->>.