Query Embedded Jsonb in Ash
Here's how to query fields in embedded jsonb in the Ash framework.
Jsonb is cool
Jsonb columns in Postgres are so cool. Dump your semi-structured data there as json. Query it from sql:
select json->>'jsonField' from my_table;
Flexible!
Ash Resources with jsonb
You can specify a jsonb attribute on an Ash resource:
defmodule MyLibrary.Shelves.Book do
use Ash.Resource,
domain: MyLibrary.Shelves,
data_layer: AshPostgres.DataLayer,
extensions: [AshGraphql.Resource]
postgres do
table "book"
repo MyLibrary.Repo
end
attributes do
# ...
attribute :json, :map do
public? true
end
end
end
Now I can access the stringified json. If I am using ash_graphql, I could query:
query fetchBooks {
listBooks {
results {
json
}
}
}
And get back stringified json, like:
{
"data": {
"listBooks": {
"results": [
{
"json": "{\"length\":33333,\"genres\":[\"Comedy\",\"Romance\"]
}
]
}
}
}
It's a string. We can do better, but we need a struct
Ash Resources embedded types
Ash supports embedded resources via jsonb. Let's define the a BookMeta
type for this json:
defmodule MyLibrary.Shelves.Book.BookMeta do
use Ash.Resource,
domain: MyLibrary.Shelves,
data_layer: :embedded,
extensions: [AshGraphql.Resource]
attributes do
attribute :length, :integer, allow_nil?: true, public?: true
attribute :genres, {:array, :string},
allow_nil?: true,
public?: true
end
actions do
create :create do
accept [
:length,
:genres
]
primary? true
end
end
graphql do
type :book_meta
end
end
Notable here are that now all the fields must be specified. This makes it less flexible. If you try to query a book with json that has fields different than the declared attributes, the query will raise an error.
Note the data_layer: :embedded
. This indicates an embedded type. There is no backing table in postgres.
The create
action must be made to allow book
table insertion with those accepted fields in the json
field.
Then finally, add the graphql book_meta
type so that it'll be defined in our graph schema.
Once we have this BookMeta
resource, we can pop back to the Book
definition and change the json attribute to use this type:
attribute :json, MyLibrary.Shelves.Book.BookMeta do
public? true
end
This allows us to adjust our graphql query. json
now has fields!:
query fetchBooks {
listBooks {
results {
json {
length
genres
}
}
}
}
And the resultant json is parsed and queryable down to the field level:
{
"data": {
"listBooks": {
"results": [
{
"json": {
"length": 33333,
"genres": ["Comedy", "Romance"]
}
}
]
}
}
}
This is so cool.
Flatten with calculations
We can make it a little cooler, more convenient and flatter with some Ash.Resource calculations. We'll add these to Book
. This'll allow us to query the field directly as if it existed on Book
, without the json
intermediate container.
Let's make a calculation module:
defmodule MyLibrary.Shelves.Book.JsonFieldCalculation do
use Ash.Resource.Calculation
@impl true
def init(opts) do
if opts[:key] && is_atom(opts[:key]) do
{:ok, opts}
else
{:error, "Expected a `key` option"}
end
end
@impl true
def load(_query, _opts, _context) do
[:json]
end
@impl true
def calculate(records, opts, _context) do
Enum.map(records, fn book ->
Map.get(book.json, opts[:key])
end)
end
end
Now we can use this calculation on Book
:
defmodule MyLibrary.Shelves.Book do
alias MyLibrary.Shelves.Book.JsonFieldCalculation
calculations do
calculate :length,
:integer,
{JsonFieldCalculation, key: :length} do
public? true
end
calculate :genre,
{:array, :string},
{JsonFieldCalculation, key: :genres} do
public? true
end
end
end
Now our query got even flatter:
query fetchBooks {
listBooks {
results {
length
genres
}
}
}
And the data did too:
{
"data": {
"listBooks": {
"results": [
{
"length": 33333,
"genres": ["Comedy", "Romance"]
}
]
}
}
}
Isn't that neat! Semi-structured data in the db. No new columns. Definition about the embedded resource in the app. Direct field-level querying on the api.