Skip to main content
This guide is the deep reference for writing C++ VDF implementations. It is the companion to Creating Extensions in C++, which covers the end-to-end build steps, and C++ Testing, which covers the test-and-iterate loop.
VEF Protocol 3 is stable as of v0.0.4. Protocol 4 is under development and is available only via opt-in dev ABI headers (-DVSQL_USE_DEV_ABI=ON). Extensions built against the old Protocol 2 are rejected by the server and must be rebuilt.

Writing Extension Functions

Extension functions are written in C++ and registered with VEF. Include a single header to access the full SDK:

Exception Safety

Containing exceptions is the extension’s responsibility. The SDK does not wrap your implementation in a try/catch, and neither does the server, so an exception that escapes an entry point unwinds across the C ABI boundary with no handler anywhere above it and the server process terminates. Ordinary SQL input reaches this: std::stoi() throws on a non-numeric string, and nlohmann::json::dump() throws on text that is not valid UTF-8. Wrap the body of every entry point you register: VDFs, aggregate result functions, and the from_string/to_string operations of a custom type.
Catch ... in addition to const std::exception &: a dependency may throw a type that does not derive from std::exception, and that one still terminates the server. Choose between warning() and error() on the same basis as any other failure — warning() for bad input the statement should survive, error() where continuing is unsafe.

Argument and Result Types

VDF parameters and results are passed as type-safe argument and result types. The framework detects them from your function signature and adapts automatically — the make_func registration syntax is unchanged. Argument types: IntArg, RealArg, StringArg, CustomArg — each provides is_null() and value(). For parameterized custom types, CustomArgWith<P> adds a params() accessor that returns the cached parsed params struct (see Parameterized Types). Result types: IntResult, RealResult, StringResult, CustomResult — each provides set_null(), warning(msg), and error(msg). Scalar results also provide set(value). Buffer results provide buffer() and set_length(len). StringResult also provides set(std::string_view), which copies up to buffer().size() bytes from the view and sets the length in one call. For parameterized custom types, CustomResultWith<P> adds a params() accessor. Span type: value() and buffer() on the byte-oriented argument and result types return a vsql::Span<T> — a non-owning view over a contiguous run of T with data(), size(), empty(), begin()/end(), and operator[]. Under C++20 it is an alias for std::span<T>; under C++17 this SDK provides a minimal compatible implementation, so the same code compiles in either standard. It is available through <villagesql/vsql.h>. warning(msg) returns SQL NULL for the row and appends a SQL warning. That NULL is an ordinary value from the server’s point of view, so it only becomes a statement error the way any NULL does: writing it into a NOT NULL column fails under STRICT_TRANS_TABLES, the same as INSERT INTO t VALUES (NULL) would. Writing it into a nullable column succeeds, warning and all. Use warning(msg) for recoverable bad input, such as an unparseable string in an encode function. Use error(msg) for corrupt stored data or any condition where continuing is unsafe. The message for both is truncated to fit the server’s internal error buffer if necessary. Scalar example — add two integers:
Binary example — transform a custom type buffer in place:
For StringResult and CustomResult, write into buffer(), then call set_length() with the number of bytes written. buffer().size() is the maximum capacity. For VDFs that return a custom type (returns(CUSTOM(MYTYPE))), the server sizes the result buffer to the resolved return type’s persisted_length automatically — extension authors do not need to declare .buffer_size(...) on the function builder for this case. If prerun grows the buffer further, that larger size is preserved. This is what lets, for example, SVECTOR::from_string('[…1024 floats…]') encode a wide vector without the result buffer running out of room. You can use different styles across functions in the same extension — each function’s style is determined by its own signature.

Aggregate VDFs

Aggregate VDFs accumulate state across rows within each GROUP BY group and return a single result per group, like SQL SUM or COUNT. Use make_aggregate_func<State, &result_fn>("name") to register one. The State type is the per-group accumulation buffer; prerun and postrun are auto-generated to allocate and delete it. The result function must have the signature void(const State&, ResultType) where ResultType is one of IntResult, RealResult, StringResult, CustomResult, or CustomResultWith<P>. Call out.set(value) to return a value or out.set_null() to return SQL NULL. Both .clear<>() and .accumulate<>() are required. The builder enforces this at compile time (via build()), and the server validates it again at INSTALL EXTENSION time — clear resets state, accumulate folds rows, and the result function reads the final state.
How the builder methods work:
  • make_aggregate_func<State, &result_fn>() auto-generates prerun and postrun (value-initializes and deletes State).
  • .clear<&fn>() registers your void(State&) reset function.
  • .accumulate<&fn>() registers your void(State&, TypedArgs...) fold function. TypedArgs are deduced from the function signature (IntArg, StringArg, etc.). Call .accumulate<>() after all .param(TYPE) calls: the builder checks the typed-argument count against the declared SQL parameter count at compile time, so calling it earlier fails to build.
  • The result type (IntResult, RealResult, etc.) is deduced from the result function signature.
For a counter that never returns NULL, use a plain state type:
A StringResult aggregate VDF returns text: the result reports the utf8mb4_bin charset and collation, so clients display it as characters rather than hex — the same as the scalar VDF STRING path. It also honors .max_result_length(n) — which requires the opt-in dev-ABI headers (-DVSQL_USE_DEV_ABI=ON) and Protocol 4 — the same way, sizing a materialized aggregate result (a GROUP BY/DISTINCT temp table, CREATE TABLE ... SELECT, or UNION) so it isn’t truncated at the argument width. See Custom Buffer Sizes for the sizing rules and cap.

Per-Statement State (Prerun and Postrun)

Some VDFs need state that spans every row a single query touches — a call counter, a cached result, an open resource. Allocate it in a prerun hook, access it from the VDF body, and release it in a postrun hook. Both hooks run once per statement; the VDF body runs once per row. Register them with .prerun<&Hook>() and .postrun<&Hook>(). The required signatures are: Use PrerunResult::set_user_data(void*) to stash state; use PostrunArgs::delete_state<T>() to release it. If prerun calls set_user_data(new T{}), postrun must call delete_state<T>() — the SDK does not auto-free. PrerunArgs::type_at(i) exposes the declared SQL type of each argument before any rows are read; the predicates is_int(), is_real(), is_str(), is_custom() on the returned PrerunArgType mirror the column types. Use this in prerun to validate argument types or call PrerunResult::request_buffer_size(n) to size the result buffer.

Varargs VDFs

A varargs VDF accepts any number of arguments of any SQL type. Declare one with .varargs() on the func builder, which is mutually exclusive with .no_params() and .param(TYPE). The body receives a vsql::VarArgs argument instead of the usual fixed-arity argument types.
Varargs registration requires VEF Protocol 3. Older servers reject the extension at install time.
The framework cannot validate argument count or types for varargs VDFs. Pair every varargs registration with a prerun hook that calls PrerunResult::error() on invalid input or PrerunResult::request_buffer_size(n) to size the result buffer. Iterate over arguments with range-for. Each AnyArg element requires a type check before reading its value: Check is_null() before any accessor — all four are undefined on a null argument. PrerunArgType::custom_type() returns the argument’s custom type name as a std::string_view — empty for non-custom arguments. is_custom() alone accepts every custom type registered on the server; pairing it with a custom_type() comparison narrows a varargs call to one specific type:

Deciding a Function’s Type Parameters at Resolution Time

The server’s built-in rules usually work out a return type’s parameters from its arguments’ declared types. Sometimes they can’t. For example, tvector_concat(TVECTOR(M), TVECTOR(N)) returns a TVECTOR(M+N). The built-in rules reject two TVECTOR arguments whose dimensions differ, and they can copy an argument’s parameters to the return type but never add them. For cases like this, register a bind-and-check-types hook with .bind_and_check_types<&Hook>() on the func builder, with the signature:
.bind_and_check_types() and .varargs() are mutually exclusive on the same function — a varargs VDF has no fixed argument list for the hook to reason about. Declaring .varargs() before .bind_and_check_types<&Hook>() in the builder chain is a compile-time static_assert failure. Declaring them in the other order compiles, but the extension is rejected when the server loads it: registration walks every function’s final built state and rejects the first one that is both varargs and has a bind hook, with:
.bind_and_check_types<&Hook>() and <villagesql/vsql/bind_check_types.h> are declared only in the opt-in dev ABI headers (-DVSQL_USE_DEV_ABI=ON) and require VEF Protocol 4. A default stable-ABI build has neither, so registering a hook fails to compile.
The server calls the hook between two passes it makes over a function’s arguments: the first pass checks each argument’s base type, the hook runs next, and the second pass applies the hook’s answer for each argument where it gave one. With a hook installed, the first pass does not collect shared parameters between arguments of the same custom type, so none of them are required to agree with each other. An argument whose own type is not already resolved, such as a bare string literal, gets its parameters from the hook alone. A parameterized return type’s parameters also come from the hook, in place of the built-in rule that copies them from an argument. The vsql_tvector example extension in the server source tree registers such a hook on tvector_concat:
The hook lets two vectors of different dimensions combine, and it counts the elements of a constant literal. A source build of the server includes vsql_tvector; the release tarballs do not.

Extension Load and Unload Hooks

An extension sometimes has work to do once per load rather than once per call — picking a CPU-specific implementation, reading its own configuration once, or building a lookup table the VDFs then read. Register that work with .on_init<&Fn>() on the extension builder, and the matching teardown with .on_deinit<&Fn>(). Both take a function with the signature void(), and must be registered together — declaring one without the other fails to compile. An extension that reaches the server with a mismatched pair some other way is rejected at load instead:
.on_init<&Fn>() and .on_deinit<&Fn>() are declared only in the opt-in dev ABI headers (-DVSQL_USE_DEV_ABI=ON). A default stable-ABI build has neither method, so registering a hook fails to compile.
on_init runs once per load — at INSTALL EXTENSION, and again on every server startup — after the server has validated the extension and populated its required capabilities, so a registered system variable already holds its configured value. At startup specifically, on_init is deferred until persisted system-variable values have been applied, so it never sees a compiled-in default in place of a value set with SET PERSIST. It never runs for an extension the server rejected. on_deinit runs at unload — UNINSTALL EXTENSION or server shutdown — before the server depopulates those same capabilities, so it sees the same live state on_init did.
After INSTALL EXTENSION my_extension, SELECT fast_sqrt(16) returns 4 — a result that is only reachable if build_table already ran.

VEF_GENERATE_REGISTRATION

VEF_GENERATE_REGISTRATION creates an internal _vef_do_register() helper that performs extension registration but does not define the extern "C" entry points. Use it when you need to customize vef_register behavior — for example, to patch descriptors after registration in a test build. For normal extensions, use VEF_GENERATE_ENTRY_POINTS instead.

Custom Type Operations

For the full type operation builder reference — encode, decode, compare, hash, intrinsic defaults, and parameterized types — see Type Operations.

Preview Capabilities

The following VEF capabilities are available as opt-in preview headers. ABI and API are still under active development; see Preview Capabilities for the full reference.

Inspecting Extension Registration Metadata

INFORMATION_SCHEMA.EXTENSION_REGISTRATION exposes the in-memory VEF registration struct for each loaded extension as a JSON document. Use it to verify that the server parsed your extension’s functions, types, and system variables correctly after INSTALL EXTENSION.

See Also