Skip to content
Query.Farm
Talk with Us

2. Add a table function

The second tutorial step: add a table function that generates rows, so one worker serves both a scalar and a table function β€” about 10 minutes, picking up from step 1.

table shape
args β†’ N rows

A table-valued source: scalar arguments in, a whole set of rows out.

series(3) β†’ a three-row table 0, 1, 2. Unlike a scalar, a table function is pulled: DuckDB calls Process repeatedly until the generator reports it has nothing left.

Why this is powerful

series just counts, but Process can emit rows from anything β€” a file format DuckDB can’t read, a paginated HTTP API, a hardware device β€” and the rows stream straight into a SQL FROM clause.

Add a SeriesFn alongside the DoubleFn from step 1. Save the whole file as main.go:

main.go
// Copyright 2025, 2026 Query Farm LLC - https://query.farm

// Command calc is the worker built across the vgi-go tutorial: one scalar
// function and one table function in a single catalog.
//
// The scalar `double` transforms a column in place. The table function `series`
// *generates* rows from an argument, so it is called in a FROM clause rather
// than an expression. One worker can serve any mix of shapes.
//
//	go build -o calc .
//	# then, in a Haybarn shell:
//	ATTACH 'calc' (TYPE vgi, LOCATION './calc');
//	SELECT calc.double(21);
//	SELECT * FROM calc.series(3);
package main

import (
	"context"
	"flag"
	"log"

	"github.com/Query-farm/vgi-go/vgi"
	"github.com/Query-farm/vgi-rpc-go/vgirpc"
	"github.com/apache/arrow-go/v18/arrow"
	"github.com/apache/arrow-go/v18/arrow/array"
)

// ── scalar: double(n) ───────────────────────────────────────────────────────

type doubleArgs struct {
	N int64 `vgi:"pos=0,const=false,doc=Value to double"`
}

// DoubleFn doubles each value in its input column.
type DoubleFn struct{}

func (*DoubleFn) Name() string { return "double" }

func (*DoubleFn) Metadata() vgi.FunctionMetadata {
	return vgi.FunctionMetadata{
		Description: "Doubles a BIGINT",
		Stability:   vgi.StabilityConsistent,
		ReturnType:  arrow.PrimitiveTypes.Int64,
	}
}

func (*DoubleFn) OnBindTyped(_ *doubleArgs, _ *vgi.BindParams) (*vgi.BindResponse, error) {
	return vgi.BindResult(arrow.PrimitiveTypes.Int64)
}

func (*DoubleFn) ProcessTyped(_ context.Context, _ *doubleArgs, params *vgi.ProcessParams, batch arrow.RecordBatch) (arrow.RecordBatch, error) {
	return vgi.MapColumn(params, batch, 0, array.NewInt64Builder,
		func(col arrow.Array, i int) int64 { return vgi.GetInt64Value(col, i) * 2 })
}

// NewDouble returns the registration-ready scalar function.
func NewDouble() vgi.ScalarFunction {
	return vgi.AsScalarFunction[doubleArgs](&DoubleFn{})
}

// ── table: series(count) ────────────────────────────────────────────────────

// seriesOutputSchema is fixed, so OnBind can return it without inspecting the
// call. A table function that shapes its output from its arguments would build
// the schema here instead.
var seriesOutputSchema = arrow.NewSchema([]arrow.Field{
	{Name: "n", Type: arrow.PrimitiveTypes.Int64},
}, nil)

type seriesArgs struct {
	Count int64 `vgi:"pos=0,ge=0,doc=How many numbers to generate"`
}

// seriesState is the per-scan cursor. Embedding BatchState gives the generator
// helpers the remaining/batch-size bookkeeping; anything else the function needs
// between Process calls goes alongside it.
type seriesState struct {
	vgi.BatchState
}

// SeriesFn generates the integers 0..count-1.
type SeriesFn struct{}

// Compile-time proof that the shape is satisfied. Cheap, and it turns a missing
// method into a build error rather than a registration-time surprise.
var _ vgi.TypedTableFunc[seriesState] = (*SeriesFn)(nil)

func (*SeriesFn) Name() string { return "series" }

func (*SeriesFn) Metadata() vgi.FunctionMetadata {
	return vgi.FunctionMetadata{
		Description: "Generates the integers 0..count-1",
		Stability:   vgi.StabilityConsistent,
	}
}

func (*SeriesFn) ArgumentSpecs() []vgi.ArgSpec { return vgi.DeriveArgSpecs(seriesArgs{}) }

func (*SeriesFn) OnBind(_ *vgi.BindParams) (*vgi.BindResponse, error) {
	return vgi.BindSchema(seriesOutputSchema)
}

// NewState runs once per scan, after bind. This is where arguments are read:
// they are fixed for the whole scan, so decoding them per batch would be waste.
func (*SeriesFn) NewState(params *vgi.ProcessParams) (*seriesState, error) {
	var args seriesArgs
	if err := vgi.BindArgs(params.Args, &args); err != nil {
		return nil, err
	}
	return &seriesState{BatchState: vgi.NewBatchState(args.Count, 1024)}, nil
}

// Process is called repeatedly until the state reports no rows remain β€” that is
// what makes a table function a *generator*. GenerateBatch handles the chunking
// and signals completion for you; the callback only fills `size` rows.
func (*SeriesFn) Process(_ context.Context, _ *vgi.ProcessParams, state *seriesState, out *vgirpc.OutputCollector) error {
	return vgi.GenerateBatch(&state.BatchState, out, func(size int64) ([]arrow.Array, error) {
		start := state.Index
		return []arrow.Array{
			vgi.BuildInt64Array(size, func(i int64) int64 { return start + i }),
		}, nil
	})
}

// NewSeries returns the registration-ready table function.
func NewSeries() vgi.TableFunction {
	return vgi.AsTableFunction[seriesState](&SeriesFn{})
}

func main() {
	httpMode := flag.Bool("http", false, "serve over HTTP instead of stdio")
	logFlags := vgi.RegisterLoggingFlags(flag.CommandLine)
	flag.Parse()
	if err := logFlags.Apply(); err != nil {
		log.Fatalf("logging flags: %v", err)
	}

	w := vgi.NewWorker(
		vgi.WithCatalogName("calc"),
		vgi.WithCatalogComment("Tutorial worker: a scalar and a table function"),
	)
	w.RegisterScalar(NewDouble())
	w.RegisterTable(NewSeries())

	if *httpMode {
		if err := w.RunHttp("127.0.0.1:0"); err != nil {
			log.Fatal(err)
		}
		return
	}
	w.RunStdio()
}

Five things to notice about the new series code:

  • State is a separate type. TypedTableFunc[S] is parameterised by its state, not its arguments β€” a table function’s arguments are fixed for the whole scan, while its state changes between calls.
  • Arguments are read once, in NewState. They can’t change mid-scan, so decoding them per batch would be waste. vgi.BindArgs fills the struct from params.Args.
  • ge=0 is enforced. The constraint travels into the registered spec, so series(-1) fails at bind rather than looping or silently returning nothing.
  • GenerateBatch does the chunking. BatchState tracks how many rows remain and how big a batch should be; your callback only fills size rows. Signalling completion is handled for you.
  • The compile-time assertion earns its line. var _ vgi.TypedTableFunc[seriesState] = (*SeriesFn)(nil) turns a missing or mis-typed method into a build error instead of a registration-time surprise.
Why a generator?

A table function is pulled, not called: the engine asks the worker for output until it signals completion. Process is the pull handler. Because series knows up front how many rows it owes, BatchState can answer β€œam I done?” without the function tracking it β€” but a generator reading a paginated API would keep its cursor in the same state struct and decide for itself.

go build -o calc .

Start a fresh Haybarn shell (fresh, so the calc name is still free), then:

INSTALL vgi FROM community;
LOAD vgi;

ATTACH 'calc' (TYPE vgi, LOCATION './calc');

Continuing in the same shell from step 1? Its calc is still the scalar-only worker, so series won’t exist yet β€” run DETACH calc; first.

Table functions are called in the FROM clause:

SELECT * FROM calc.series(3);

Output

n
0
1
2

The scalar function is still there β€” one worker, both functions, composed in one query:

SELECT calc.double(n) AS doubled FROM calc.series(3);

Output

doubled
0
2
4

Ask for more rows than fit in one batch and the chunking is invisible from SQL:

SELECT count(*), sum(n) FROM calc.series(5000);

Output

count_star() sum(n)
5000 12497500

And the constraint does its job:

SELECT * FROM calc.series(-1);
Invalid Input Error: VGI Worker Exception: argument "count" (position 0): must be >= 0

What just happened: one calc worker now serves two functions. series ran in the FROM clause β€” DuckDB pulled batches from Process until BatchState reported none remaining β€” and double transformed them, all in the same Go process.

You’ve grown the worker into a two-function catalog. πŸŽ‰

Troubleshooting
  • Binder Error: Failed to attach database: database with name "calc" already exists β€” calc is still attached. Run DETACH calc; or open a fresh shell.
  • Function "series" is a table function but it was used as a scalar function β€” table functions go in FROM, not SELECT.
  • Empty result β€” series(0) is legitimately zero rows. Try series(5).
  • The scan never ends β€” a generator that never decrements its remaining count is an infinite stream. GenerateBatch handles that for you; a hand-rolled Process must signal completion itself.