Skip to main content

CatalogMux API

CatalogMux is the recommended way to build hugr app catalogs. It provides a handler-based API for registering functions and tables with automatic GraphQL SDL generation.

For lower-level control, you can register catalog.Table, catalog.ScalarFunction, catalog.TableFunction interfaces directly, or provide raw SDL.

All catalog interfaces are defined in the github.com/hugr-lab/airport-go/catalog package.

Creating a CatalogMux

import "github.com/hugr-lab/query-engine/client/app"

func (a *MyApp) Catalog(ctx context.Context) (catalog.Catalog, error) {
mux := app.New()
// register functions, tables, table functions...
return mux, nil
}
Thread safety

Registration methods (HandleFunc, HandleTableFunc, Table, TableRef, ScalarFunc, TableFunc, TableFuncInOut, WithSDL, ...) are not safe to call concurrently on the same CatalogMux. Build the catalog from a single goroutine — typically inside Application.Catalog as shown above — and return it. Once the runtime has the catalog, all subsequent reads (SDL generation, function execution, table scans) are safe for any number of goroutines.


Scalar Functions

Handler-based (simple)

Register with HandleFunc:

err := mux.HandleFunc("default", "add",
func(w *app.Result, r *app.Request) error {
return w.Set(r.Int64("a") + r.Int64("b"))
},
app.Arg("a", app.Int64),
app.Arg("b", app.Int64),
app.Return(app.Int64),
app.Desc("Add two numbers"),
)

Parameters:

  • Schema name ("default" = top-level module, others = nested modules)
  • Function name
  • Handler function func(w *Result, r *Request) error
  • Options: Arg(), ArgDesc(), Return(), Desc(), Mutation()

Mutation functions

By default, scalar functions are exposed as GraphQL queries (extend the Function type) and called via query { function { ... } }. To register a function with side effects as a GraphQL mutation, add the Mutation() option — the function will extend MutationFunction and must be called via mutation { function { ... } }.

err := mux.HandleFunc("default", "send_message",
func(w *app.Result, r *app.Request) error {
to := r.String("to")
body := r.String("body")
// perform side effect (send notification, update external system, etc.)
return w.Set(fmt.Sprintf("sent to %s: %s", to, body))
},
app.Desc("Send a message to a recipient"),
app.Arg("to", app.String),
app.Arg("body", app.String),
app.Return(app.String),
app.Mutation(),
)

GraphQL call:

mutation {
function {
my_app {
send_message(to: "alice", body: "hello")
}
}
}

The same function is not callable via a query operation — it only appears under the MutationFunction type. The Mutation() option works in any schema, including nested ones (e.g., schema "admin" produces mutation { function { my_app { admin { reset_counter } } } }).

Mutation() only applies to scalar functions registered via HandleFunc. Table functions, tables, and table refs use their own write-enablement mechanisms (see the table sections below).

Server-injected arguments (ArgFromContext)

Use app.ArgFromContext(name, type, placeholder) to declare a function argument whose value is injected from the request's auth context. The argument is hidden from the GraphQL schema — clients cannot see or set it. At handler execution time, read the value via r.String(name), r.Int64(name), etc., the same as a regular argument.

err := mux.HandleFunc("default", "my_orders",
func(w *app.Result, r *app.Request) error {
userID := r.String("user_id") // injected from auth context
limit := r.Int64("limit")
// ... fetch orders for userID ...
return w.Set("ok")
},
app.Arg("limit", app.Int64),
app.ArgFromContext("user_id", app.String, "[$auth.user_id]"),
app.Return(app.String),
)

GraphQL call:

{ function { my_app { my_orders(limit: 10) } } }

The user_id argument is not in the public schema. The hugr planner injects the current user's ID into the SQL function call before invoking your handler.

Allowed placeholders:

PlaceholderSource
[$auth.user_id]Authenticated user ID (string)
[$auth.user_id_int]Authenticated user ID parsed as integer
[$auth.user_name]Authenticated user display name
[$auth.role]Authenticated role
[$auth.auth_type]Auth method (apiKey, jwt, oidc, etc.)
[$auth.provider]Auth provider name
[$auth.impersonated_by_role]Original role when impersonating
[$auth.impersonated_by_user_id]Original user ID when impersonating
[$auth.impersonated_by_user_name]Original user name when impersonating
[$catalog]Current catalog name

If the auth context value is unavailable (e.g., anonymous request), the placeholder resolves to NULL. Your handler receives an empty/zero value via r.String() etc.

ArgFromContext() works for both scalar and table functions, in default and named schemas. Clients attempting to set a value for a server-injected argument receive a clear error: argument "user_id" is server-injected and cannot be set by client.

Struct return types

Use app.Struct(name) to declare a typed return value. The SDK emits a GraphQL type definition and @function(json_cast: true); the planner casts the JSON output to the typed structure when clients select fields.

weatherType := app.Struct("weather_result").
Desc("Current weather snapshot").
Field("temp", app.Float64).
Field("humidity", app.Int64).
FieldFromSource("city", app.String, "city_name") // GraphQL "city" → JSON "city_name"

mux.HandleFunc("default", "get_weather",
func(w *app.Result, r *app.Request) error {
return w.SetJSON(map[string]any{
"temp": 22.5,
"humidity": 60,
"city_name": "Berlin",
})
},
app.Arg("lat", app.Float64),
app.Arg("lon", app.Float64),
app.Return(weatherType.AsType()),
)

Generated SDL:

"""Current weather snapshot"""
type weather_result {
temp: Float!
humidity: BigInt!
city: String! @field_source(field: "city_name")
}

extend type Function {
get_weather(lat: Float!, lon: Float!): weather_result
@function(name: "...", json_cast: true)
}

GraphQL clients query:

{ function { my_app { get_weather(lat: 52.5, lon: 13.4) { temp humidity city } } } }

Field options:

  • Field(name, type) — non-null scalar or struct field.
  • FieldDesc(name, type, desc) — with description.
  • FieldNullable(name, type) — nullable.
  • FieldList(name, type) — non-null list [Type!]! (element may be a scalar or another struct).
  • FieldFromSource(name, type, originalName) — rename via @field_source (output structs only).

Nesting: a struct field type may be another struct (via its AsType()). Both definitions are emitted in SDL.

Type deduplication: the same StructType (by name) used in multiple functions is emitted once. Conflict on different fields → registration error.

Struct input types

Use app.InputStruct(name) to declare a typed input argument. The handler reads the value as JSON via r.JSON(name, &out).

queryInput := app.InputStruct("search_input").
Field("query", app.String).
Field("limit", app.Int64)

mux.HandleFunc("default", "search",
func(w *app.Result, r *app.Request) error {
var in struct {
Query string `json:"query"`
Limit int64 `json:"limit"`
}
if err := r.JSON("input", &in); err != nil {
return err
}
// ... use in.Query, in.Limit ...
return w.SetJSON(results)
},
app.Arg("input", queryInput.AsType()),
app.Return(app.JSON),
)

Generated SDL:

input search_input {
query: String!
limit: BigInt!
}

extend type Function {
search(input: search_input!): JSON @function(name: "...")
}

The hugr planner inlines the structured argument value as a JSON literal automatically. No sql: template required in the function declaration.

Raw JSON return / input

Use app.JSON for opaque JSON values:

mux.HandleFunc("default", "raw_data",
func(w *app.Result, r *app.Request) error {
return w.SetJSON(map[string]any{"anything": "goes"})
},
app.Return(app.JSON),
)

List of scalars return

Use app.ReturnList(scalar) for [String!]-style returns. Wire format is native Arrow LIST (not JSON), so the handler returns a Go slice via Set:

mux.HandleFunc("default", "list_tags",
func(w *app.Result, r *app.Request) error {
return w.Set([]string{"go", "graphql", "duckdb"})
},
app.ReturnList(app.String),
)

Generated SDL:

extend type Function {
list_tags: [String!] @function(name: "...")
}

The outer list is nullable, matching the scalar-return convention — elements are non-null. Supported slice types match the Arrow scalar types: []string, []bool, []int / []int8 / []int16 / []int32 / []int64, []uint8 / []uint16 / []uint32 / []uint64, []float32 / []float64. For other element types wrap the values in []any.

ReturnList does NOT support struct elements — for lists of structs, register a table function via HandleTableFunc instead.

Result and Request helpers

MethodUsage
Result.Set(v)Generic setter — accepts the Go type matching the return's Arrow type
Result.SetJSON(v)Marshal v to JSON; valid only for app.JSON or Struct returns
Result.SetJSONValue(v)Accepts string, []byte, or any value (marshaled); flexible JSON setter
Request.JSON(name, &out)Unmarshal a JSON-string argument into out

Direct interface registration

For full control, implement catalog.ScalarFunction and register directly:

mux.ScalarFunc("default", &myScalarFunc{})
// catalog.ScalarFunction interface (from airport-go/catalog)
type ScalarFunction interface {
Name() string
Comment() string
Signature() FunctionSignature
Execute(ctx context.Context, input arrow.RecordBatch) (arrow.Array, error)
}

type FunctionSignature struct {
Parameters []arrow.DataType
ReturnType arrow.DataType
}

With manual SDL

Wrap any scalar function with custom SDL:

mux.ScalarFunc("default", app.WithScalarFuncSDL(&myFunc{}, `
my_func(input: String!): String @function(name: "my_func")
`))

Tables

Direct interface registration

Implement catalog.Table and register:

mux.Table("default", &ItemsTable{})
// catalog.Table interface (from airport-go/catalog)
type Table interface {
Name() string
Comment() string
ArrowSchema(columns []string) *arrow.Schema
Scan(ctx context.Context, opts *ScanOptions) (array.RecordReader, error)
}

SDL is auto-generated from the Arrow schema. Mutable tables get @table, read-only get @view.

Insertable / Updatable / Deletable Tables

To enable GraphQL mutations, implement additional interfaces:

// Enable INSERT mutations
type InsertableTable interface {
Table
Insert(ctx context.Context, rows array.RecordReader, opts *DMLOptions) (*DMLResult, error)
}

// Enable UPDATE mutations
type UpdatableTable interface {
Table
Update(ctx context.Context, rowIDs []int64, rows array.RecordReader, opts *DMLOptions) (*DMLResult, error)
}

// Enable DELETE mutations
type DeletableTable interface {
Table
Delete(ctx context.Context, rowIDs []int64, opts *DMLOptions) (*DMLResult, error)
}

Example:

type OrdersTable struct{}

func (t *OrdersTable) Name() string { return "orders" }
// ... ArrowSchema, Scan ...

func (t *OrdersTable) Insert(ctx context.Context, rows array.RecordReader, opts *catalog.DMLOptions) (*catalog.DMLResult, error) {
// insert logic
return &catalog.DMLResult{AffectedRows: 1}, nil
}

// Register — auto-detected as insertable
mux.Table("default", &OrdersTable{})

Schema Options for Tables

mux.Table("default", &myTable{},
app.WithDescription("My table description"),
app.WithPK("id"),
app.WithFieldDescription("name", "User display name"),
app.WithFilterRequired("tenant_id"),
app.WithReferences("orders", []string{"id"}, []string{"user_id"}, "users", "orders"),
)
OptionDescription
WithDescription(desc)Table description
WithPK(fields...)Primary key fields
WithFieldDescription(field, desc)Field-level description
WithFilterRequired(fields...)Fields required in filters
WithReferences(name, srcFields, refFields, query, refQuery)Add @references relation
WithM2MReferences(...)Many-to-many reference
WithFieldReferences(fieldName, refName, query, refQuery)Field-level reference
WithRawSDL(sdl)Override auto-generated SDL entirely

With manual SDL

mux.Table("default", app.WithSDL(&myTable{}, `
type orders @table(name: "orders") {
id: Int! @pk
customer: String!
total: Float!
created_at: Timestamp
}
`))

Table Functions (Parameterized Views)

Table functions are tables that accept arguments — useful for search, filtering, paginated views.

Handler-based (simple)

err := mux.HandleTableFunc("default", "search",
func(w *app.Result, r *app.Request) error {
query := r.String("query")
for _, item := range items {
if strings.Contains(item.Name, query) {
w.Append(item.ID, item.Name)
}
}
return nil
},
app.Arg("query", app.String),
app.ColPK("id", app.Int64),
app.Col("name", app.String),
app.ColDesc("score", app.Float64, "Relevance score"),
app.ColNullable("details", app.String),
)

Column options:

  • Col(name, type) — regular column
  • ColPK(name, type) — primary key column
  • ColNullable(name, type) — nullable column
  • ColDesc(name, type, description) — column with description

Direct interface registration

mux.TableFunc("default", &myTableFunc{})
// catalog.TableFunction interface (from airport-go/catalog)
type TableFunction interface {
Name() string
Comment() string
Signature() FunctionSignature
SchemaForParameters(ctx context.Context, params []any) (*arrow.Schema, error)
Execute(ctx context.Context, params []any, opts *ScanOptions) (array.RecordReader, error)
}

TableFunctionInOut

For functions that transform input row sets:

mux.TableFuncInOut("default", &myTransformFunc{})
// catalog.TableFunctionInOut interface (from airport-go/catalog)
type TableFunctionInOut interface {
Name() string
Comment() string
Signature() FunctionSignature
SchemaForParameters(ctx context.Context, params []any, inputSchema *arrow.Schema) (*arrow.Schema, error)
Execute(ctx context.Context, params []any, input array.RecordReader, opts *ScanOptions) (array.RecordReader, error)
}

Table References

Table references delegate execution to DuckDB function calls — useful for wrapping existing DuckDB table functions:

mux.TableRef("default", &myTableRef{})
// catalog.TableRef interface (from airport-go/catalog)
type TableRef interface {
Name() string
Comment() string
ArrowSchema() *arrow.Schema
FunctionCalls(ctx context.Context, req *FunctionCallRequest) ([]FunctionCall, error)
}

Manual SDL

Global SDL override

Replace all auto-generated SDL with custom SDL:

mux := app.New()
mux.WithSDL(`
type users @table(name: "users") {
id: Int! @pk
name: String!
email: String!
}

type orders @view(name: "orders") {
id: Int! @pk
user_id: Int!
total: Float!
}
`)

Per-item SDL wrapping

Attach custom SDL to individual items:

// Table with custom SDL
mux.Table("default", app.WithSDL(&myTable{}, `type ... @table(...) { ... }`))

// Table ref with custom SDL
mux.TableRef("default", app.WithTableRefSDL(&myRef{}, `type ... @view(...) { ... }`))

// Scalar function with custom SDL
mux.ScalarFunc("default", app.WithScalarFuncSDL(&myFunc{}, `my_func(...): String @function(...)`))

// Table function with custom SDL
mux.TableFunc("default", app.WithTableFuncSDL(&myTF{}, `type ... @view(...) { ... }`))

Type Constants

Type constantGraphQL typeArrow typeGo type
app.BooleanBooleanBooleanbool
app.Int8IntInt8int8
app.Int16IntInt16int16
app.Int32IntInt32int32
app.Int64BigIntInt64int64
app.Uint8UIntUint8uint8
app.Uint16UIntUint16uint16
app.Uint32UIntUint32uint32
app.Uint64BigUIntUint64uint64
app.Float32FloatFloat32float32
app.Float64FloatFloat64float64
app.StringStringStringstring
app.TimestampDateTimeTimestamp_ustime.Time
app.DateDateDate32time.Time
app.GeometryGeometryGeometryExtensionorb.Geometry

Request Methods

MethodReturnsDescription
r.Bool(name)boolBoolean argument
r.Int8(name)int88-bit integer
r.Int16(name)int1616-bit integer
r.Int32(name)int3232-bit integer
r.Int64(name)int6464-bit integer
r.Uint8(name)uint8Unsigned 8-bit
r.Uint16(name)uint16Unsigned 16-bit
r.Uint32(name)uint32Unsigned 32-bit
r.Uint64(name)uint64Unsigned 64-bit
r.Float32(name)float3232-bit float
r.Float64(name)float6464-bit float
r.String(name)stringString argument
r.Bytes(name)[]byteBinary data
r.Geometry(name)orb.GeometryGeometry value
r.Get(name)anyRaw value (any type)
r.Context()context.ContextRequest context

Schemas (Modules)

The first argument to all registration methods is the schema name:

  • "default" — top-level app module (no extra nesting)
  • Any other name — nested module: { app_name { schema_name { ... } } }
// Top-level: { function { my_app { add(...) } } }
mux.HandleFunc("default", "add", ...)

// Nested: { function { my_app { admin { user_count } } } }
mux.HandleFunc("admin", "user_count", ...)

See Schema Design for details.

GraphQL Query Paths

RegistrationGraphQL Path
HandleFunc("default", "add", ...){ function { app_name { add(...) } } }
HandleFunc("default", "send", ..., app.Mutation())mutation { function { app_name { send(...) } } }
HandleFunc("admin", "count", ...){ function { app_name { admin { count } } } }
HandleFunc("admin", "reset", ..., app.Mutation())mutation { function { app_name { admin { reset } } } }
Table("default", &items{}){ app_name { items { ... } } }
HandleTableFunc("default", "search", ...){ app_name { search(args: {...}) { ... } } }
HandleTableFunc("reports", "daily", ...){ app_name { reports { reports_daily(args: {...}) { ... } } } }