Skip to content

Querying Data

Table-backed fields — links, artists, link_destinations, link_tracks, optins, fans, pixels, domains, and the rest — share a common set of arguments for filtering, sorting, pagination, and aggregation. Learn them once and they apply everywhere.

Custom resolvers (login, create_link, link_statistics, search_artists) do not take these arguments; they have fixed signatures documented on their own pages.

Arguments

Every table field accepts:

ArgumentTypePurpose
where<table>_bool_expFilter rows.
order_by[<table>_order_by!]Sort rows.
limitIntMaximum rows to return.
offsetIntRows to skip. Use only with order_by.
distinct_on[<table>_select_column!]Return one row per distinct value of a column.

Each table also exposes two companions:

  • <table>_by_pk(id: uuid!) — fetch a single row by primary key, returning the object or null.
  • <table>_aggregate(...) — counts and numeric aggregates over the same filter.

Filtering

where takes a boolean expression. Each column maps to a comparison object.

graphql
query {
  links(
    where: {
      artist_id: { _eq: "3aad8009-a307-4429-a586-8b3dbe39cdda" }
      type: { _eq: "release" }
      created_at: { _gte: "2026-01-01" }
    }
  ) {
    id
    path
    title
  }
}

Conditions at the same level are combined with AND.

Comparison operators

OperatorMeaning
_eq, _neqEqual / not equal
_gt, _gte, _lt, _lteOrdering comparisons
_in, _ninValue is / is not in a list
_is_nulltrue matches NULL, false matches non-NULL
_like, _nlikeCase-sensitive pattern match, % wildcard
_ilike, _nilikeCase-insensitive pattern match
_regex, _iregex, _nregex, _niregexRegular expression match
_similar, _nsimilarSQL SIMILAR TO

Text operators are available on string columns only. uuid, timestamp, and numeric columns support the equality, ordering, list, and null operators.

Boolean combinators

graphql
query {
  links(
    where: {
      artist_id: { _eq: $artist_id }
      _or: [
        { type: { _eq: "release" } }
        { type: { _eq: "playlist" } }
      ]
      _not: { path: { _ilike: "test-%" } }
    }
  ) {
    id
    path
  }
}

_and, _or, and _not each nest a full boolean expression.

Filtering across relationships

Relationship names can be used inside where, which is how you filter a table by a property of a related row.

graphql
# Links whose artist has a specific Spotify ID
query {
  links(where: { artist: { spotify_id: { _eq: "1uNFoZAHBGtllmzznpCI3s" } } }) {
    id
    path
  }
}
graphql
# Links that have at least one enabled Apple Music destination
query {
  links(
    where: {
      destinations: { type: { _eq: "applemusic" }, enabled: { _eq: true } }
    }
  ) {
    id
    path
  }
}

For an array relationship the condition means "at least one related row matches".

Sorting

graphql
query {
  links(
    where: { artist_id: { _eq: $artist_id } }
    order_by: [{ views_total: desc }, { created_at: desc }]
  ) {
    id
    title
    views_total
  }
}

Direction values: asc, desc, asc_nulls_first, asc_nulls_last, desc_nulls_first, desc_nulls_last.

Ordering by a related column works too:

graphql
order_by: { artist: { name: asc } }

Pagination

graphql
query Page($artist_id: uuid!, $limit: Int!, $offset: Int!) {
  links(
    where: { artist_id: { _eq: $artist_id } }
    order_by: { created_at: desc }
    limit: $limit
    offset: $offset
  ) {
    id
    path
  }
  links_aggregate(where: { artist_id: { _eq: $artist_id } }) {
    aggregate { count }
  }
}

Always pair offset with a deterministic order_by; without one, row order is not stable between pages and you will see duplicates and gaps.

For large exports, keyset pagination is cheaper and safe against inserts mid-walk:

graphql
query After($artist_id: uuid!, $after: timestamptz!) {
  links(
    where: {
      artist_id: { _eq: $artist_id }
      created_at: { _gt: $after }
    }
    order_by: { created_at: asc }
    limit: 500
  ) {
    id
    created_at
  }
}

Aggregates

graphql
query {
  links_aggregate(where: { artist_id: { _eq: $artist_id } }) {
    aggregate {
      count
      sum { views_total clickthroughs_total }
      avg { views_total }
      max { created_at }
      min { created_at }
    }
  }
}

count is available on every table; sum, avg, max, min, stddev, and variance apply to numeric columns.

Aggregates can be nested inside a parent row, which is the efficient way to get per-artist counts in one pass:

graphql
query {
  artists {
    id
    name
    links_aggregate {
      aggregate { count }
    }
  }
}

Nesting relationships

A single document can walk the whole graph. Nested array relationships accept the same where / order_by / limit arguments as top-level fields.

graphql
query FullLink($id: uuid!) {
  links_by_pk(id: $id) {
    id
    domain
    path
    title
    artist {
      id
      name
      socials { type url }
    }
    destinations(
      where: { enabled: { _eq: true } }
      order_by: { priority: asc }
    ) {
      id
      type
      url
      cta
    }
    tracks(order_by: { priority: asc }) {
      id
      name
      url
    }
  }
}

Prefer one nested document over several flat requests — it is a single round trip and a single authorization pass.

Mutations

Table mutations follow the same naming pattern.

MutationPurpose
insert_<table>(objects: [...], on_conflict: ...)Insert many rows.
insert_<table>_one(object: {...})Insert one row, returning the row itself.
update_<table>(where: ..., _set: {...})Update matching rows.
update_<table>_by_pk(pk_columns: { id: ... }, _set: {...})Update one row by primary key.
delete_<table>(where: ...)Delete matching rows.
delete_<table>_by_pk(id: ...)Delete one row by primary key.

Bulk forms return an affected_rows count plus an optional returning array:

graphql
mutation {
  update_link_destinations(
    where: { link_id: { _eq: $link_id }, type: { _eq: "spotify" } }
    _set: { enabled: false }
  ) {
    affected_rows
    returning { id type enabled }
  }
}

_by_pk forms return the row directly, or null if no row matched — which, under row-level permissions, also covers "you do not own it".

INFO

Writes are constrained twice: by a row rule (you may only touch rows belonging to your artists) and by a column allowlist (only certain columns are settable). Writing a disallowed column fails with permission-error. The allowlist for each table is documented on the relevant page — see Edit Link and Destinations.

Variables

Use variables rather than string interpolation. They are type-checked, they avoid quoting bugs, and they let the server cache the query plan.

json
{
  "query": "query GetLink($id: uuid!) { links_by_pk(id: $id) { id path } }",
  "variables": { "id": "3aad8009-a307-4429-a586-8b3dbe39cdda" }
}

Note the type is uuid! on table fields. Custom resolvers usually declare the same value as String! — check the signature on the page for that field.

ArtistHub developer documentation