Skip to content
Query.Farm
Talk with Us

1. Your first scalar function

The first tutorial step: build a worker with one scalar function and call it from SQL — about 10 minutes, for first-time VGI users with JDK 21+.

What's a “worker”?

A worker is a small program DuckDB talks to over Apache Arrow. It exposes one or more typed functions, and DuckDB calls them like built-ins. It is an ordinary JVM program — nothing is compiled into DuckDB, and nothing links against it.

scalar shape
1 row → 1 value

Runs on each row independently and returns a single value — a pure per-row transform.

Why this is powerful

upper_case is intentionally trivial — DuckDB can already uppercase a string. The point is that compute is ordinary Java: reach for a Maven dependency, a model, an HTTP client, a parser DuckDB has never heard of, and DuckDB calls it like a native SQL function.

A worker needs one dependency, and three build settings that are not optional.

build.gradle.kts
plugins { application }

repositories { mavenCentral() }

dependencies {
  implementation("farm.query:vgi:0.26.1")
  runtimeOnly("org.slf4j:slf4j-simple:2.0.16")   // any SLF4J binding
}

tasks.withType<JavaCompile> {
  // Required. See the warning below.
  options.compilerArgs.add("-parameters")
}

application {
  mainClass.set("farm.query.vgi.examples.ScalarExample")
  applicationDefaultJvmArgs = listOf(
      "--add-opens=java.base/java.nio=ALL-UNNAMED",   // Arrow needs nio access
      "--enable-native-access=ALL-UNNAMED",           // shared-memory transport
  )
}
`-parameters` fails silently, not loudly

The annotation-driven API reads your parameter names to build the SQL signature. Java erases those by default, and without -parameters the worker still starts, still registers, and still answers positional calls — so nothing looks wrong:

SELECT arg_name FROM vgi_function_arguments() WHERE function_name = 'upper_case';
Built with -parametersBuilt without
valuearg0

upper_case('hello') keeps working either way. What breaks is everything that depends on the name: upper_case(value := 'hello') stops resolving, and any agent or tool reading the catalog sees arg0. Nothing warns you, so put the flag in the build file before you write the function.

ScalarExample.java
// VGI-Java example: a scalar function.
//
// A scalar function maps each input row to one output row. You extend
// `ScalarFn` and write a single `compute()` method; the framework reads its
// parameter annotations to derive the SQL signature, the output type, and the
// per-batch dispatch. There is no schema boilerplate to write by hand.
//
// Run it on its own:
//   ./gradlew runScalar --args="--unix /tmp/scalar.sock --idle-timeout 60"
// then from Haybarn:
//   ATTACH 'demo' AS demo (TYPE vgi, LOCATION 'launch:/abs/path/bin/runScalar');
//   SELECT demo.upper_case('hello');   -- HELLO
package farm.query.vgi.examples;

import farm.query.vgi.Worker;
import farm.query.vgi.scalar.ScalarFn;
import farm.query.vgi.scalar.Vector;
import org.apache.arrow.vector.VarCharVector;

import java.nio.charset.StandardCharsets;
import java.util.Locale;

/** {@code upper_case(value VARCHAR) -> VARCHAR}: ASCII/Unicode uppercase. */
public final class ScalarExample extends ScalarFn {

    @Override public String name() { return "upper_case"; }
    @Override public String description() { return "Uppercase a string"; }

    // One `@Vector` input column + one trailing (unannotated) output vector.
    // The framework allocates `result`, sized to the batch row count, and
    // writes whatever you put into it back across the wire.
    //
    // Parameter rules in one breath:
    //   @Vector  -> a per-row input column (the Arrow vector type is the SQL type)
    //   @Const   -> a bind-time constant arg (long/double/String/boolean/byte[])
    //   @Setting -> a session setting (SET demo.foo = ...)
    //   last unannotated vector = the output (framework-allocated)
    public void compute(@Vector VarCharVector value, VarCharVector result) {
        int rows = value.getValueCount();
        result.allocateNew();
        for (int i = 0; i < rows; i++) {
            if (value.isNull(i)) { result.setNull(i); continue; }
            String up = new String(value.get(i), StandardCharsets.UTF_8).toUpperCase(Locale.ROOT);
            byte[] bytes = up.getBytes(StandardCharsets.UTF_8);
            result.setSafe(i, bytes, 0, bytes.length);
        }
    }

    public static void main(String[] args) {
        Worker.builder()
                .catalogName("demo")
                .registerScalar(new ScalarExample())
                .runFromArgs(args);   // handles --unix / --http / --idle-timeout / stdio
    }
}

Four things to notice:

  • The method signature is the SQL signature. @Vector marks a per-row input column, and the Java vector type is the SQL type. There is no separate spec to keep in sync.
  • The last unannotated vector is the output. The framework allocates it, sized to the batch, and sends back whatever you write into it.
  • A whole column at a time. compute runs once per batch, not once per row.
  • runFromArgs picks the transport. One entry point handles stdio, --unix and --http, which is why the same binary works for every deployment below.
New to Apache Arrow?

Apache Arrow is a language-independent columnar memory format. Rather than rows of objects, data lives in vectors: a contiguous, typed sequence of values for a single column. VGI hands your function a whole column, and operating on it at once is what keeps it fast across the process boundary. In Java these are the org.apache.arrow.vector.* types — VarCharVector is one UTF-8 column.

./gradlew installDist

That produces a self-contained launch script under build/install/<project>/bin/<project> — the JVM flags from the build file are baked into it, which is why ATTACH can point straight at it.

Start Haybarn from anywhere and attach the launch script:

INSTALL vgi FROM community;
LOAD vgi;

ATTACH 'demo' (TYPE vgi, LOCATION 'launch:/abs/path/build/install/demo/bin/demo');
Use `launch:`, not a bare path — and know what it caches

A cold JVM takes seconds to start. A bare LOCATION spawns a fresh one for every query, which makes an interactive session unusable. The launch: prefix starts the worker once and reuses it across queries over a flock-coordinated Unix socket.

The consequence to remember: after you rebuild, the pooled worker is still the old one. It will keep answering with the previous build until it idles out or you kill it, which produces the memorable experience of a fix that appears not to work. DETACH, then kill the JVM, then re-attach.

Now call it:

SELECT demo.upper_case('hello');

Output

upper_case('hello')
HELLO

…and by name, which is what -parameters bought you:

SELECT demo.upper_case(value := 'hello');

Output

upper_case(value := 'hello')
HELLO

What just happened: ATTACH launched the worker and registered demo.upper_case in your SQL session. DuckDB handed your Java a whole VarCharVector, compute ran over it, and the result came back across the wire — no row-by-row round trips. Swap the body for any Java you like and the SQL above doesn’t change.

You’ve built and run your first VGI function in Java. 🎉

Troubleshooting
  • The argument is called arg0 — the -parameters flag is missing from the build.
  • Your change had no effect — the pooled launch: worker is still the old build. Kill the JVM.
  • IllegalAccessError mentioning java.nio — the --add-opens flag is missing.
  • ATTACH takes seconds every time — you used a bare LOCATION instead of launch:.
  • No worker handles catalog 'x' — the name in ATTACH must equal catalogName(...).