PG Persistence lets a Viaduct application's GraphQL schema define its PostgreSQL data
model and provides a DbClient for resolving that data through pg_graphql.
For an explanation of the generated database model and runtime behavior, see ARCHITECTURE.md.
Apply PG Persistence to each Viaduct module that owns database nodes. The module must already apply the Viaduct module plugin and its Kotlin/KSP setup. For a single-project application:
// settings.gradle.kts
pluginManagement {
repositories {
maven("https://central.sonatype.com/repository/maven-snapshots/")
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
maven("https://central.sonatype.com/repository/maven-snapshots/")
mavenCentral()
}
}// build.gradle.kts
plugins {
id("com.airbnb.viaduct.application-gradle-plugin") version "<viaduct-version>"
id("com.airbnb.viaduct.module-gradle-plugin") version "<viaduct-version>"
id("dev.viaduct.pg-persistence") version "0.1.0-SNAPSHOT"
}
dependencies {
implementation("dev.viaduct.persistence:runtime:0.1.0-SNAPSHOT")
}The runtime is a Maven dependency of the application. The snapshot repository above provides the
current 0.1.0-SNAPSHOT; released versions are available from Maven Central.
For a multi-project application, apply PG Persistence to the database-owning modules, not just the
application project. Each module contributes its prepared schema to the application's normal
assembleViaductCentralSchema task.
An object that implements Viaduct's Node interface is persistent by default. PG Persistence
automatically enables selective resolvers for the module's database nodes, so you do not need to
write @resolver(isSelective: true). The generated node contexts expose ctx.selections() and
ctx.ownedSelections() for DbClient.
The plugin prepares a schema copy under build/generated/viaduct-persistence-schema before
Viaduct assembles the central schema. Source files stay unchanged, and code generation and runtime
use the same prepared schema. Types in denyList.types and modules without PG Persistence keep
their existing behavior. Existing @resolver declarations retain their other arguments, including
isBatching. Explicit isSelective: false is rejected for database nodes; remove that argument or
exclude the type from persistence.
Object fields, lists, and connections describe relationships:
type Group implements Node {
id: ID!
name: String
members: [GroupMember]
}
type GroupMember implements Node {
id: ID!
group: Group
person: Person
}
type Person implements Node {
id: ID!
displayName: String
}An ID field with @idOf stores a reference without requiring an object field:
type Person implements Node {
id: ID!
groupId: ID @idOf(type: "Group")
}The scalar ID field may also accompany a matching object field. These fields use the same foreign key column:
type Person implements Node {
id: ID!
group: Group
groupId: ID @idOf(type: "Group")
}The @idOf target must match the object field's type.
Supported stored fields are:
| GraphQL field | PostgreSQL representation |
|---|---|
ID |
UUID |
String |
Text |
Date |
Date |
DateTime |
Timestamp with time zone |
Time |
Time |
Boolean |
Boolean |
Byte, Short, Int, Long |
Matching integer type |
Float |
Double precision |
BigDecimal, BigInteger |
Numeric |
JSON |
JSONB |
| Enum | Text |
| Persistent object, list, or connection | Relationship |
List fields are supported only when their elements are persistent Node types. Nested lists and
lists of scalar, enum, or arbitrary non-persistent object values are not supported. Resolver-backed
fields that are not relationships between persistent types are not stored.
Optional persistence policy belongs in src/main/viaduct/persistence.yaml:
denyList:
types:
- ExternalProfile
semanticNotNull:
types:
- Group
fields:
- Person.displayName
relationships:
unidirectionalTargetForeignKeyFields:
- Group.members
inverseFieldOverrides:
ExternalGroup.discordServerRoles: serverdenyList.typesexcludes an occasionalNode. For a large externally backed schema, use a separate Viaduct tenant module without this plugin.semanticNotNullrequires stored values while leaving public GraphQL field nullability intact.relationships.unidirectionalTargetForeignKeyFieldsnames collection fields that store the relationship as a foreign key on the contained node's table. Each value usesType.field, must identify a persistent collection, and cannot be used when the connection has stored edge fields.relationships.inverseFieldOverridesresolves a collection whose contained node type has more than one object field referring back to the collection's declaring type. The key is the collection'sType.field; the value is the exact object-field name on the contained type. The named field must exist and refer to the declaring type.
For example, if DiscordServerRoleGroup has both externalGroup: ExternalGroup and
server: ExternalGroup, the schema alone cannot determine which field stores
ExternalGroup.discordServerRoles. This entry selects server:
relationships:
inverseFieldOverrides:
ExternalGroup.discordServerRoles: serverUnknown keys, types, field coordinates, and ineffective entries fail generation. Override only the file location from Gradle when needed:
viaductPgPersistence {
persistenceConfigFile.set(layout.projectDirectory.file("config/persistence.yaml"))
}./gradlew buildViaductEffectiveModelReview the files under:
build/generated/viaduct-effective-model/META-INF/
postgresql-migration.sql
postgresql-prerequisites.sql
postgresql-repeatable.sql
pg-graphql-metadata.sql
pg-graphql.sql
Adapt postgresql-migration.sql into the application's migration system. Apply
pg-graphql-metadata.sql after the relational schema exists. pg-graphql.sql is a convenience
bundle for a fresh schema, not a repeatable production migration. The plugin never applies
database changes automatically.
viaductPgPersistence {
schemaDiffUrl.set(providers.environmentVariable("SCHEMA_DIFF_DATABASE_URL"))
schemaDiffUser.set(providers.environmentVariable("SCHEMA_DIFF_DATABASE_USER"))
schemaDiffPassword.set(providers.environmentVariable("SCHEMA_DIFF_DATABASE_PASSWORD"))
}./gradlew hibernateSchemaDiffReview build/schema-diff/hibernate-review.postgresql.sql and
build/schema-diff/hibernate-destructive-review.postgresql.sql. Renames, removals, data
backfills, and database-owned constraints remain manual migration work.
val dbClient = DbClient(
httpClient = httpClient,
endpoint = postgresGraphqlEndpoint,
requestHeaders = DbRequestHeaders { context ->
mapOf("Authorization" to "Bearer ${accessTokenFor(context)}")
},
)The endpoint and headers depend on the service exposing pg_graphql. Supabase normally uses
https://<project>.supabase.co/graphql/v1 and expects both Authorization and apikey headers.
For the Group type above, add this mutation to src/main/viaduct/schema/Group.graphqls:
input AddGroupInput {
name: String!
}
type AddGroupPayload {
group: Group
}
extend type Mutation {
addGroup(input: AddGroupInput!): AddGroupPayload @resolver
}Here is the complete src/main/kotlin/com/example/groups/GroupResolvers.kt file for a module
whose configured package is com.example.groups, using the default viaduct.api.grts package.
The application's resolver factory supplies the configured DbClient to both constructors.
package com.example.groups
import com.example.groups.resolverbases.MutationResolvers
import com.example.groups.resolverbases.NodeResolvers
import dev.viaduct.persistence.runtime.db.DbClient
import dev.viaduct.persistence.runtime.db.toPgGraphqlInsert
import viaduct.api.grts.AddGroupPayload
import viaduct.api.grts.Group
import viaduct.api.resolver.Resolver
@Resolver
class GroupNodeResolver(
private val dbClient: DbClient,
) : NodeResolvers.Group() {
override suspend fun resolve(ctx: Context): Group =
dbClient.fetchByInternalId(
ctx = ctx,
collectionField = "groupCollection",
id = ctx.id.internalID,
ownedSelections = ctx.ownedSelections(),
requestedSelections = ctx.selections(),
)
}
@Resolver
class AddGroupResolver(
private val dbClient: DbClient,
) : MutationResolvers.AddGroup() {
override suspend fun resolve(ctx: Context): AddGroupPayload {
val insert = ctx.arguments.input.toPgGraphqlInsert()
return dbClient.entity<Group>().insert(ctx, insert)
}
}The mutation inserts the input and builds a payload containing a reference to the new group.
Selecting the group's fields invokes GroupNodeResolver, which fetches the requested data.
NodeResolvers, MutationResolvers, and the result types are generated from the schema.
ownedSelections() is the resolver's output selection set intersected with the current request's
selection set.
Other common operations are:
fetchfor an explicitDbRead.fetchResultfor aDbResultcontaining a GRT and any GraphQL errors returned by pg_graphql.fetchJsonResultfor the equivalent JSON result.fetchNodewhen returned node references must be attached.fetchUuidIdsfor a collection resolver that returns node references.fetchUuidConnectionforfirst/afterorlast/beforepagination.fetchNestedUuidConnectionsfor the same child connection across several parents.
Pass cursor strings returned by pg_graphql back unchanged. Applications must not decode or construct them.
Convert the Viaduct input into a pg_graphql value, then pass that value to the selected persistent node type. The resolver context supplies the payload type:
override suspend fun resolve(ctx: Context): AddGroupMemberPayload {
val insert = ctx.arguments.input.toPgGraphqlInsert()
return dbClient.entity<GroupMember>().insert(ctx, insert)
}The same API supports updates, deletes, and batches:
dbClient.entity<Group>().update(ctx, ctx.arguments.input.toPgGraphqlUpdate<Group>())
dbClient.entity<GroupMember>().delete(ctx, ctx.arguments.input.toPgGraphqlDelete<GroupMember>())
dbClient.entity<GroupMember>().insertBatch(ctx, ctx.arguments.inputs.map { it.toPgGraphqlInsert() })
dbClient.entity<GroupMember>().updateBatch(ctx, ctx.arguments.inputs.map { it.toPgGraphqlUpdate<GroupMember>() })
dbClient.entity<GroupMember>().deleteBatch(ctx, ctx.arguments.inputs.map { it.toPgGraphqlDelete<GroupMember>() })The conversion functions convert typed global IDs. The client creates returned node references, fills the matching payload
field, and initializes userErrors to an empty list. Multiple matching payload fields are rejected
as ambiguous.
The entity API provides pg_graphql insert, update, and delete operations for one persistent node
type at a time. Each call writes only the table represented by entity<T>(). It does not inspect an
input object for other persistent node types and does not turn nested objects into additional
database operations.
For example, an insert<Group> can write the Group's scalar fields and foreign-key ID fields. An
input shaped like this does not also insert the people:
input AddGroupInput {
name: String!
members: [AddPersonInput!]
}pg_graphql's GroupInsertInput has no nested insert operation for members, so passing that field
causes a pg_graphql input error. To refer to an existing node, pass the corresponding typed ID field:
input AddGroupMemberInput {
groupId: ID! @idOf(type: "Group")
personId: ID! @idOf(type: "Person")
}To create the Group, Person, and GroupMember together, the resolver must perform three inserts and pass the created IDs into GroupMember. The current entity API sends those as separate requests. Transactional multi-operation support requires one combined pg_graphql request with client-created UUIDs and is tracked separately.
Batch operations do not change this rule. insertBatch<Group> inserts several Groups in one table;
it does not insert a mixed object graph. Batch update and delete likewise target one selected node
type.
An update sends only fields present in the Viaduct input after removing its selected identifier. An omitted field is not changed; an explicitly supplied null is sent as null. Delete removes only the selected rows. Any cascading delete behavior comes from application-owned database constraints, not recursive behavior in PG Persistence. The entity API does not provide upsert.
Insert conversion accepts a generated Viaduct input whose supplied fields are valid for pg_graphql's
insert input for the selected node. Update and delete conversion require that input to contain an ID field whose
@idOf target is the type selected by entity<T>(). The ID must be inside the input;
the entity API does not inspect a separate mutation argument. The client converts that GlobalID to
the row's uuidId filter and excludes the selected field from the values sent by update.
The client automatically uses the matching @idOf field when exactly one exists. No match fails
because the operation cannot identify a row. If more than one field matches, the operation fails
rather than guessing; select the identifying field explicitly:
val update = ctx.arguments.input.toPgGraphqlUpdate<Group>(identifierField = "groupId")
dbClient.entity<Group>().update(ctx, update)The explicit field must exist in the input and have @idOf(type: "Group"). Batch update and delete
use the same identifier field for every input. They execute one pg_graphql operation per input;
batch insert sends all inputs in one operation.
Insert and update payloads must contain exactly one field matching the selected node type. Delete
payloads may omit that field. The entity API initializes userErrors to an empty list but does not
convert pg_graphql errors into application userErrors; operations throw on those errors. Use the
lower-level PgGraphqlMutationClient for explicit filters, returned database records, partial data,
or structured pg_graphql errors.
Use PgGraphqlMutationClient directly when a resolver needs the records returned by pg_graphql
instead of having DbClient.entity<T>() build the resolver's mutation payload.
The lower-level client executes pg_graphql collection mutations without building a Viaduct payload:
val mutations = PgGraphqlMutationClient(httpClient, postgresGraphqlEndpoint)
val group = PgGraphqlEntity("Group")
val inserted = mutations.insert(
entity = group,
objectValue = ctx.arguments.input.toPgGraphqlInsert(),
selection = "affectedCount records { uuidId name }",
headers = mapOf("Authorization" to "Bearer $accessToken"),
)This lower-level client accepts pg_graphql JSON. Convert a Viaduct input separately or construct
the pg_graphql values directly. Typed GlobalIDs are converted by toPgGraphqlInsert.
Update and delete accept explicit pg_graphql filters. atMost is required, must be greater than
zero, and limits how many matching rows pg_graphql may change:
val filter = buildJsonObject {
put("uuidId", buildJsonObject { put("eq", groupId.internalID) })
}
val updated = mutations.update(
entity = group,
set = buildJsonObject { put("name", "New name") },
filter = filter,
atMost = 1,
selection = "affectedCount records { uuidId name }",
headers = requestHeaders,
)
val deleted = mutations.delete(
entity = group,
filter = filter,
atMost = 1,
headers = requestHeaders,
)The selection is the selection inside the pg_graphql mutation payload; its default is
affectedCount. Methods without Result throw UpstreamGraphqlException when pg_graphql returns
errors. Use insertResult, updateResult, or deleteResult to receive a DbResult containing
partial payload data and structured GraphQL errors. Headers are supplied per call; unlike
DbClient, this client does not derive them from an execution context.
| Task | Use |
|---|---|
validateViaductPgPersistenceSchema |
Validate schema and policy compatibility |
generateViaductPgPersistenceModel |
Generate persistence metadata |
buildViaductEffectiveModel |
Generate PostgreSQL and pg_graphql SQL |
hibernateSchemaSnapshot |
Write a database-model snapshot |
hibernateSchemaDiff |
Compare the generated model with PostgreSQL |
Most applications should use the generated defaults. For custom naming strategies, Hibernate metadata customization, schema-directory changes, or complete HBM replacement, see Custom configuration.
- A Gradle build that provides
assembleViaductCentralSchema - PostgreSQL when applying generated SQL
pgcryptoandpg_graphqlfor the complete PostgreSQL/GraphQL integration
Applications do not need to be implemented in Kotlin or run on the JVM to use the generated PostgreSQL schema and pg_graphql API. The Gradle plugin and Viaduct runtime integration are JVM tools, but that is not a requirement of the database interface.
PG Persistence is licensed under the Apache License, Version 2.0.