generateql

module
v0.3.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 4, 2026 License: Apache-2.0

README

GenerateQL

Turn a GraphQL endpoint into a typed, idiomatic Go SDK — like protoc, but for GraphQL.

Release Go Reference Go Version CI License

GenerateQL introspects any GraphQL server with standard introspection (Hasura, Grafbase / Hasura DDN, Prisma-backed engines, …) and emits a self-contained Go library: typed models, a fluent predicate DSL for filters, single-object create/update inputs, and one method per query, mutation, and subscription — in clean per-domain packages on a small transport runtime.

No hand-written query strings, no struct tags, no pointers in your call sites.

Contents

svc, _ := freebusyql.Connect(u)                       // *url.URL in

ins, _ := svc.Mutation.Organisation.Resource.Create(ctx, resourceql.CreateInput{
    Id: id, DisplayName: "BoB the Builder", Name: "organisations/" + id, MemberCount: 2,
})

rows, _ := svc.Query.Organisation.Resource.List(ctx, resourceql.List().
    Where(resourceql.And(resourceql.Id.Eq(id), resourceql.MemberCount.Gt(1))).
    OrderBy(resourceql.DisplayName.Desc()).
    Limit(10))

Overview

Hand-written GraphQL query strings and client code drift from the schema. GenerateQL is a small compiler that makes the schema the single source of truth: it runs the standard introspection query against any GraphQL server (Hasura, Hasura DDN / Grafbase, Prisma-backed engines, …), normalizes the result into a language-agnostic IR, and renders a self-contained Go library — the way protoc turns a .proto into generated code. Every row becomes a struct and every root field a method, so mistakes fail at compile time instead of in production.

The output is a protobuf-style library folder dropped into your module — GenerateQL never writes a go.mod; you own that:

yourmodule/                     ← your go.mod + code
└── freebusyql/                 ← GENERATED (named after the service)
    ├── service.go  field.go    package freebusyql → Connect, Service, Subscription, Int64
    │                           (+ schema.json when generated with --dump-schema)
    ├── organisationql/
    │   ├── organisationql.go   domain aggregator + model aliases
    │   ├── resourceql/         handlers · predicates · request builders · Create/UpdateInput · models
    │   └── schemaql/           row-model structs
    └── bookingql/ scheduleql/ identityql/ promocodeql/ prismaql/

Every generated package name carries a ql suffix (foldername == package == import segment), distinguishing generated code from yours — the convention protoc-gen-go uses with pb.

Features

  • Typed end to end — every row is a struct, every root field a method; mistakes fail at compile time.
  • Natural call sitessvc.Query.Booking.Contacts.List(ctx, …) with native values, never raw inputs.* or pointers.
  • Predicate DSL — fluent, typed filters (Eq/In/Like/…, And/Or/Not, relation filters, ordering); no BoolExp leaks out.
  • Full CRUD surfaceList / Get / Find (first match) / Aggregate, Create / Update / Delete, plus On* live subscriptions — one method per root field.
  • Three-state updatesUpdateInput fields are Nullable[T]: leave unset (unchanged), Null[T]() (clear to SQL NULL), or Value(v) (set, even to a zero value).
  • Generic handler interfaces — every handler satisfies graphql.QueryHandler[M] / graphql.MutationHandler[…] (compile-time asserted), so one generic adapter can drive CRUD for every entity.
  • Atomic multi-table writessvc.Mutation.Tx() batches several mutations into one document the engine commits in a single transaction; no orphan-row risk.
  • Optimistic concurrencyUpdateIfMatch(ctx, id, patch, predicate) returns a typed graphql.ErrConflict when no row matched the precondition.
  • Stable pagination — keyset/cursor paging (List().KeysetAfter(col.Asc(), last)) alongside Limit/Offset.
  • Context-awarectx threads through to the transport for deadlines, cancellation, and tracing.
  • Escape hatchsvc.Query.QueryRaw(ctx, query, vars) and svc.Mutation.ExecuteRaw(ctx, mutation, vars) run arbitrary operations the typed API doesn't cover.
  • Convention-agnostic — CRUD/aggregate families and filterable columns are derived from introspection, not hardcoded to one engine.
  • Reproducible — deterministic, gofmt-clean output; pass --dump-schema to ship the exact schema.json it was built from.

How it works

GenerateQL is a small compiler: it parses the introspection response into a normalized IR (the AST), runs analysis passes over it, and renders Go.

flowchart LR
    EP["endpoint / cached schema.json"] -->|"__schema query"| INTRO[introspect]
    INTRO -->|raw JSON| IR["ir.Build → IR / AST"]
    IR --> PLAN[plan: domains · ql pkgs · CRUD names]
    IR --> TM[typemap: GraphQL → Go]
    IR --> SEL[selection: depth-bounded models]
    IR --> PRED[predicates: filterable columns]
    PLAN --> GEN[Go renderer]
    TM --> GEN
    SEL --> GEN
    PRED --> GEN
    GEN -->|gofmt| OUT["typed library (+ schema.json with --dump-schema)"]
    IR --> OUT

ir.Build flattens the introspection wrappers into a language-agnostic shape (each type reference is a flat FieldType; operations are grouped per resource — the row object they act on) so the generator never re-parses GraphQL:

flowchart TD
    S[ir.Schema] --> O["Objects (models · responses · aggregates)"]
    S --> I["Inputs (BoolExp · OrderByExp · Insert/Update)"]
    S --> E[Enums] --> SC[Scalars]
    S --> R["Resources: ops grouped by row type"]
    R --> Q[Queries: list · byId · aggregate]
    R --> M[Mutations: insert · update · delete]
    R --> SUB[Subscriptions]

Analysis passes (all engine-agnostic, derived from the AST):

  • plan — groups resources by domain, assigns ql package names, maps root fields to CRUD verbs (list→List plus a first-match Find, byId→Get, insertX→Create, updateXById→Update, deleteXById→Delete, subscriptions→On*).
  • typemap — GraphQL scalars → Go; nullable inputs become native values tagged json:",omitzero" (presence without pointers, Go 1.24+).
  • selection — models as nested structs with relations inlined to --max-depth, cycle-safe via a per-branch visited set.
  • predicates — resolves each filterable column's _eq operand to a Go family and emits a typed field handle; relations become composable predicate functions. No BoolExp leaks out.

The renderer emits per-resource packages, per-domain aggregators, the root Service (with QueryRaw/ExecuteRaw escape hatches), and — with --dump-schema — a schema.json dump, then gofmts everything. Generated code runs on the runtime (runtime/go) — a transport-agnostic GraphQL / HTTP / WebSocket client behind a small facade.

Install

Installing the CLI requires Go 1.26+ (the module's go.mod go directive). The generated code only needs Go 1.24+, where encoding/json gained omitzero.

go install github.com/oh-tarnished/generateql/cmd/generateql@latest   # Go toolchain
# ...or build from a clone:
git clone https://github.com/oh-tarnished/generateql
cd generateql && go build -o generateql ./cmd/generateql

Quick start

The fastest path is a config file checked into your project — generateql.yaml:

endpoint: http://localhost:3280/graphql      # live introspection…
# schema: schema.json                        # …or a cached introspection JSON
lang: go
go-module: github.com/me/app/freebusyql      # import path of the generated package
out: .                                        # library written to ./freebusyql/
max-depth: 1
# admin-secret: "$HASURA_ADMIN_SECRET"
# headers: ["Authorization: Bearer <token>"]
# scalars: ["Timestamptz=time.Time"]
generateql generate          # auto-detects generateql.yaml, introspects, writes ./freebusyql/

generate auto-introspects: with endpoint it runs the introspection query live; with schema it reads the cached JSON. Every flag is also a config key (flags override config):

generateql generate --endpoint http://localhost:3280/graphql --go-module github.com/me/app/freebusyql

A complete runnable example lives in examples/freebusy: config, generated library, and a main.go exercising the full CRUD + a live subscription.

Using the client

Two imports — the client (Connect, Service, Subscription, Int64) and the resource you operate on (builders, predicate DSL, Create/UpdateInput, model aliases):

import (
    "github.com/me/app/freebusyql"
    "github.com/me/app/freebusyql/organisationql/resourceql"
)

u, _ := url.Parse("http://localhost:3280/graphql")
svc, _ := freebusyql.Connect(u)                                   // headers optional: Connect(u, map[string]string{...})
q := svc.Query.Organisation.Resource
m := svc.Mutation.Organisation.Resource

// CRUD — native fields, single object, no pointers
ins, _ := m.Create(ctx, resourceql.CreateInput{Id: id, DisplayName: "BoB", MemberCount: 2})
row, _ := q.Get(ctx, id)                                          // *OrganisationResource
upd, _ := m.Update(ctx, id, resourceql.UpdateInput{DisplayName: "BoB (updated)"})
del, _ := m.Delete(ctx, id)

// Filter — predicate DSL (And/Or/Not, relations, ordering)
rows, _ := q.List(ctx, resourceql.List().
    Where(resourceql.And(resourceql.Id.Eq(id), resourceql.DisplayName.Like("Bob%"))).
    OrderBy(resourceql.DisplayName.Desc()).Limit(10))
rows, _ = q.List(ctx, resourceql.List().Where(
    resourceql.OrganisationMembers(membersql.Email.Eq("a@b.com"))))   // filter across a relation
one, _ := q.Find(ctx, resourceql.List().Where(resourceql.Id.Eq(id)))  // first match: *OrganisationResource or nil
agg, _ := q.Aggregate(ctx, resourceql.Aggregate().Where(resourceql.Id.Eq(id)))

// Escape hatch — run an arbitrary operation the typed API doesn't cover
raw, _ := svc.Query.QueryRaw(ctx, `query { organisationResource { id } }`, nil)

// Subscribe — graphql-transport-ws; pushes the result set on connect and on change
sub, _ := svc.Subscription.Organisation.Resource.OnList(ctx, resourceql.OnList().Where(resourceql.Id.Eq(id)))
defer sub.Stop()
for res := range sub.Updates() {
    rows, _ := res.Response.(*[]resourceql.OrganisationResource)
    _ = rows
}
Beyond basic CRUD
// Three-state updates — UpdateInput fields are Nullable[T]
m.Update(ctx, id, resourceql.UpdateInput{
    DisplayName: freebusyql.Value("BoB (updated)"),  // set (even to a zero value)
    BillingEmail: freebusyql.Null[string](),         // clear the column to SQL NULL
    // a field left unset is omitted — the column is unchanged
})

// Optimistic concurrency — etag-guarded update; typed conflict when no row matched
_, err := m.UpdateIfMatch(ctx, id, patch, resourceql.Etag.Eq(prevEtag))
if errors.Is(err, graphql.ErrConflict) { /* re-read and retry */ }

// Atomic multi-table write — one transaction, all-or-nothing
var promo  pschemaql.InsertPromocodeResourceResponse
var amount bschemaql.InsertBookingMoneyResponse
tx := svc.Mutation.Tx()
tx.Add(svc.Mutation.Promocode.Resource.CreateOp(promoInput, &promo))
tx.Add(svc.Mutation.Booking.Money.CreateOp(amountInput, &amount))
if err := tx.Commit(ctx); err != nil { /* nothing was written */ }

// Keyset (cursor) pagination — stable under concurrent inserts
page, _ := q.List(ctx, resourceql.List().KeysetAfter(resourceql.Id.Asc(), lastID).Limit(50))

// Generic adapter — one engine drives every entity (compile-time asserted)
var _ graphql.QueryHandler[schemaql.OrganisationResource] = svc.Query.Organisation.Resource

Operators: Eq / Neq / In / IsNull on all; Gt / Gte / Lt / Lte on strings & numbers; Like / ILike / Regex on strings; Asc() / Desc() to order. CreateInput optional fields use omitzero (an unset/zero field is omitted); UpdateInput fields are Nullable[T], so a masked update distinguishes unset from a deliberate clear-to-null.

Configuration

generateql.yaml is auto-detected in the working directory (or --config <path>); flags override config, relative paths resolve against the working directory.

Key / Flag Default Description
endpoint / --endpoint GraphQL URL to introspect live (when schema is unset)
schema / --schema Cached introspection JSON (skips the network)
lang / --lang go Target language (only go today)
go-module / --go-module Required. Import path of the generated root package
out / --out . Parent dir; library written to <out>/<package>/
package / --package derived Root package name (last segment of go-module, +ql if missing)
max-depth / --max-depth 1 Relation levels inlined into models (0 = scalars only)
dump-schema / --dump-schema false Also write the introspection schema to <package>/schema.json
runtime-module / --runtime-module repo runtime Import path of the runtime facade
scalars / --scalar Scalar override GraphQLName=GoType (repeatable)
admin-secret / --admin-secret Shortcut for the x-hasura-admin-secret header
headers / --header Extra request header Key: Value (repeatable)

--go-module is the absolute import path the generated cross-importing packages live under (e.g. --out ./gen in module github.com/me/appgithub.com/me/app/gen/freebusyql).

Scalar mapping (override via --scalar Timestamptz=time.Time):

GraphQL Go
ID / String / String1 / Timestamp / Timestamptz string
Boolean / Boolean1 bool
Int / Int32 int / int32
Int64 / Bigdecimal graphql.Int64 / graphql.Bigdecimal (string-or-number tolerant)
Float / Float64 float64
Json json.RawMessage
OrderBy graphql.OrderBy (runtime-provided; not generated)

CLI

  • generateql introspect --endpoint <url> [-o schema.json] — fetch and cache a schema.
  • generateql generate — generate the library (see the configuration table for all flags).

Both accept --admin-secret and repeatable --header "Key: Value" for auth.

The runtime

Generator and runtime are one module (github.com/oh-tarnished/generateql). Generated clients import three packages from it:

  • runtime/go/runtime — the facade the generated code targets (connection plumbing, Subscription, URLFromStd).
  • runtime/go/graphql — predicate DSL primitives (Predicate, field handles, And/Or/Not, Relation, OrderTerm, OrderBy, SetColumns, IsOmitted) and tolerant scalars: Int64 / Bigdecimal decode from a JSON string or number (engines serialize big ints/decimals as strings for precision but return aggregates as numbers).
  • runtime/go/network — the transport factory (GraphQL / HTTP / WebSocket), built on hasura/go-graphql-client.

Design notes

  • Native over pointers — optional fields are native values + omitzero; no &/Opt/Ptr at call sites. Cost: a deliberate zero/empty/null on a nullable field can't be sent (treated as unset); filters keep fidelity via the DSL.
  • No inputs packageBoolExp→predicate DSL, Insert*InputCreateInput, UpdateColumns→native UpdateInput (flattened to {set: …} via graphql.SetColumns).
  • Optional args are omitted, not nulled — many engines reject explicit null for filter/check args.
  • Deferred today: distinct_on, jsonb / _inc update operators, aggregate options beyond where.
  • Roadmap: .proto output and Python / TypeScript / Rust targets — the IR is language-agnostic; today the generator emits Go (--lang go).

Repository layout

cmd/generateql/   CLI binary entrypoint        internal/ir/         normalized schema (AST)
cmd/*.go          Cobra commands + config      internal/selection/  depth-bounded models
internal/introspect/  fetch + decode schema    internal/typemap/    GraphQL → Go mapping
internal/gen/golang/  the Go code generator    runtime/go/          runtime + DSL + scalars
examples/freebusy/    config + generated library + demo

License

Copyright © 2026 oh-tarnished. Licensed under the Apache License, Version 2.0; see LICENSE. Obtain a copy at http://www.apache.org/licenses/LICENSE-2.0. Distributed on an "AS IS" BASIS, without warranties or conditions of any kind.

Directories

Path Synopsis
cmd
Package cmd implements the generateql command-line interface.
Package cmd implements the generateql command-line interface.
generateql command
Command generateql is the GenerateQL CLI: it introspects a GraphQL endpoint (or a cached schema) and generates a typed Go client library.
Command generateql is the GenerateQL CLI: it introspects a GraphQL endpoint (or a cached schema) and generates a typed Go client library.
examples
freebusy module
internal
gen/golang
Package golang renders a Go client from the IR using an interface/handler architecture grouped by domain.
Package golang renders a Go client from the IR using an interface/handler architecture grouped by domain.
introspect
Package introspect fetches and models a GraphQL server's introspection response.
Package introspect fetches and models a GraphQL server's introspection response.
ir
Package ir holds the normalized intermediate representation of a GraphQL schema.
Package ir holds the normalized intermediate representation of a GraphQL schema.
naming
Package naming converts GraphQL identifiers to idiomatic Go identifiers.
Package naming converts GraphQL identifiers to idiomatic Go identifiers.
selection
Package selection renders Go model struct bodies from IR objects.
Package selection renders Go model struct bodies from IR objects.
typemap
Package typemap maps GraphQL types to Go types for the generated client.
Package typemap maps GraphQL types to Go types for the generated client.
runtime
go/graphql
Package graphql provides small helpers and scalar types for generated GraphQL clients: pointer constructors for optional (nullable) arguments, and scalar types that tolerate engine-specific JSON encodings.
Package graphql provides small helpers and scalar types for generated GraphQL clients: pointer constructors for optional (nullable) arguments, and scalar types that tolerate engine-specific JSON encodings.
go/network
GraphQL client: connection lifecycle and scalar type aliases.
GraphQL client: connection lifecycle and scalar type aliases.
go/runtime
Package runtime is the stable, single-import facade that generated GraphQL clients depend on.
Package runtime is the stable, single-import facade that generated GraphQL clients depend on.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL