Yes — with one caveat. Strongly typed in SQL, stringly typed at the edge.
TL;DR. Hasura will track a custom Postgres function whose argument is an
array of a table's row type (e.g. mountains[]), and the Postgres side is fully
strongly typed. But at the GraphQL boundary, Hasura exposes that argument as a custom
scalar named after the Postgres array type (here, _mountains — Postgres's
internal name for mountains[]). The client passes the value as a Postgres array
literal of composite literals — a single opaque string — not as a structured
GraphQL input object.
Inside the function, mountains_input is a real mountains[].
(m).name, (m).elevation_m, etc. are real columns with real
types. Wrong column count or wrong types → Postgres rejects it.
At the GraphQL layer it's a single scalar with no per-field validation. Clients must hand-build a Postgres array literal of composite literals, with all the nested-quoting that implies.
Standing feature request to generate proper GraphQL input objects from composite types: hasura/graphql-engine#7078 (related: #3757).
Three pieces collaborate. A table whose row type we want to pass in. A function that takes an array of that row type. Hasura, tracking both and exposing the function at the GraphQL boundary.
mountains(id, name, elevation_m, first_summited) — tracked by Hasura.summarize_mountains(mountains_input mountains[]) RETURNS SETOF mountains$m: _mountains! — custom scalar, opaque to the client.CREATE TABLE mountains (
id serial PRIMARY KEY,
name text NOT NULL,
elevation_m int NOT NULL,
first_summited int NOT NULL
);
INSERT INTO mountains (name, elevation_m, first_summited) VALUES
('K2', 8611, 1954),
('Denali', 6190, 1913),
('Mont Blanc', 4809, 1786);
-- Argument is a strongly-typed array of the `mountains` row type.
-- Inside the body, (m).name / (m).elevation_m / etc are real columns
-- with real Postgres types.
CREATE FUNCTION summarize_mountains(mountains_input mountains[])
RETURNS SETOF mountains
LANGUAGE sql STABLE AS $$
SELECT (m).*
FROM unnest(mountains_input) AS m
WHERE (m).elevation_m > 6000;
$$;
Nothing special goes in Hasura's metadata for the composite-array argument — Hasura figures it out from the Postgres signature alone.
query TallMountains($m: _mountains!) {
summarize_mountains(args: { mountains_input: $m }) {
id
name
elevation_m
first_summited
}
}
And the variables payload — this is where the catch lives:
{
"m": "{\"(1,K2,8611,1954)\",\"(2,Denali,6190,1913)\",\"(3,\\\"Mont Blanc\\\",4809,1786)\"}"
}
The function receives a real mountains[] and the WHERE (m).elevation_m > 6000
clause does its job — Mont Blanc (4,809 m) is correctly filtered out:
{
"data": {
"summarize_mountains": [
{ "id": 1, "name": "K2", "elevation_m": 8611, "first_summited": 1954 },
{ "id": 2, "name": "Denali", "elevation_m": 6190, "first_summited": 1913 }
]
}
}
That JSON string value is several layers of quoting compressed into one line. Peeling it back:
(col1,col2,col3,col4). Composite literals inside an array literal are double-quoted.jsonbIf you don't strictly need Postgres-side type enforcement, take a jsonb
argument and jsonb_to_recordset(…) inside the function. Clients then
pass a normal JSON array of objects — no Postgres-literal escaping, no
positional-column trap. You lose Postgres-side type checking, but for most
GraphQL use cases that's the pragmatic call.
CREATE FUNCTION summarize_mountains_json(mountains_input jsonb)
RETURNS SETOF mountains
LANGUAGE sql STABLE AS $$
SELECT m.*
FROM jsonb_to_recordset(mountains_input)
AS m(id int, name text, elevation_m int, first_summited int)
WHERE m.elevation_m > 6000;
$$;
And the variables become a structured JSON array — no escape gymnastics:
{
"m": [
{ "id": 1, "name": "K2", "elevation_m": 8611, "first_summited": 1954 },
{ "id": 2, "name": "Denali", "elevation_m": 6190, "first_summited": 1913 },
{ "id": 3, "name": "Mont Blanc", "elevation_m": 4809, "first_summited": 1786 }
]
}
psql) and you want Postgres to enforce the shape there too.The full setup — Postgres + Hasura via Docker Compose, migrations, metadata,
and the example request — lives in the companion repo. Two services come up on
remapped ports (58080 for Hasura, 55432 for Postgres) so nothing collides
with whatever you already have running.
git clone https://github.com/mrxinu/hasura-composite-array-args.git
cd hasura-composite-array-args
docker compose up -d
# wait ~15s for migrations to apply, then:
curl -s -X POST http://localhost:58080/v1/graphql \
-H 'Content-Type: application/json' \
-d @example-request.json | python3 -m json.tool
The console is at
http://localhost:58080/console; the GraphiQL
tab is the easiest place to poke at the schema and watch Hasura's type for
mountains_input show up as _mountains!.