Skip to content

oh-tarnished/generateql

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

20 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

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
Loading

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]
Loading

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.

About

A zero-annotation Hasura generator producing native, execution-ready clients for Go, Python, and Rust.

Topics

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Contributors

Languages