Skip to main content

Documentation Index

Fetch the complete documentation index at: https://villagesql.com/docs/llms.txt

Use this file to discover all available pages before exploring further.

The Rust SDK is at version 0.0.1.
Custom types let you define new column types — like RATIONAL, VECTOR, or INET — that work with ORDER BY, indexes, and aggregate functions. The Rust SDK supports this through the custom_type! macro. This page assumes you’ve already worked through Building Extensions in Rust. The setup (Cargo.toml, manifest.json, cargo-vsql) is the same.

When to use a custom type

Use a custom type when:
  • You need a binary on-disk layout that a standard SQL type can’t express (packed floats, fixed-width integers, binary identifiers)
  • Your type has its own ordering semantics that differ from lexicographic string ordering
  • You want the server to index and hash values correctly for ORDER BY, COUNT(DISTINCT), and set operations
If you only need SQL-callable functions and your data fits comfortably in STRING, INT, or REAL columns, you don’t need a custom type.

The custom_type! macro

Every custom type needs 4 callbacks (encode, decode, compare, hash) and a default value. Here’s the full macro signature:
villagesql::custom_type!(
    type_name: "type_name_in_sql",
    persisted_length: N,
    max_decode_buffer_length: M,
    encode: your_encode_fn,
    decode: your_decode_fn,
    compare: your_compare_fn,
    hash: your_hash_fn,
    default: "a_valid_string_literal",
)
FieldTypeDescription
type_namestring literalThe SQL type name. Case-insensitive in SQL.
persisted_lengthusizeFixed byte length for on-disk storage.
max_decode_buffer_lengthusizeMaximum byte length of the decoded string representation.
encodefnConverts a &str to binary bytes at INSERT time.
decodefnConverts binary bytes back to a String for display.
comparefnReturns Ordering for ORDER BY, MIN, MAX.
hashfnReturns a usize hash for COUNT(DISTINCT) and set operations. Optional but recommended for indexed columns.
defaultstring literalA valid string the server can encode at type initialization. Must encode to exactly persisted_length bytes. Optional but recommended.
type_name, persisted_length, max_decode_buffer_length, encode, decode, and compare are required. hash and default are optional but recommended — hash is needed for correct COUNT(DISTINCT) and set operations, and default is needed for type initialization verification.

Receiving and returning binary values

Functions that take or return a custom type work with raw bytes. InputInValue::Custom(b) carries the stored binary as &[u8]:
fn rational_numer_impl(args: &[InValue]) -> VdfReturn {
    match args.first() {
        Some(InValue::Custom(b)) => {
            let numer = read_i64(b, 0);
            VdfReturn::int(numer)
        }
        Some(InValue::Null) | None => VdfReturn::null(),
        _ => VdfReturn::error("rational_numer: expected a RATIONAL argument"),
    }
}
OutputVdfReturn::Binary(bytes) sends binary bytes back to the server:
fn rational_add_impl(args: &[InValue]) -> VdfReturn {
    match (args.get(0), args.get(1)) {
        (Some(InValue::Custom(a)), Some(InValue::Custom(b))) => {
            let result = add_rationals(a, b);
            VdfReturn::Binary(result)
        }
        _ => VdfReturn::null(),
    }
}
To reference a custom type in a func! declaration, use villagesql::custom!("type_name"):
villagesql::func!(
    rational_add_impl,
    "rational_add",
    [villagesql::custom!("rational"), villagesql::custom!("rational")] -> villagesql::custom!("rational"),
    deterministic: true
)

Example: rational number type

examples/vsql_rational in the SDK repo is a working extension implementing a RATIONAL type. It stores a rational number as a 16-byte pair of i64 values (numerator, denominator) in little-endian byte order and provides arithmetic functions. Here are the core encode, decode, compare, and hash implementations:
use villagesql::{InValue, VdfReturn};

// Binary layout: [numerator: i64 LE][denominator: i64 LE] — 16 bytes total.
// Always stored in reduced form (GCD = 1) with a positive denominator.
const BYTES: usize = 16;

fn to_bytes(num: i64, den: i64) -> Vec<u8> {
    let mut v = Vec::with_capacity(BYTES);
    v.extend_from_slice(&num.to_le_bytes());
    v.extend_from_slice(&den.to_le_bytes());
    v
}

fn from_bytes(b: &[u8]) -> (i64, i64) {
    let num = i64::from_le_bytes(b[..8].try_into().unwrap());
    let den = i64::from_le_bytes(b[8..16].try_into().unwrap());
    (num, den)
}

// encode: "3/4" -> 16 bytes
pub fn rational_encode(s: &str) -> Result<Vec<u8>, String> {
    let (num_s, den_s) = s
        .split_once('/')
        .ok_or_else(|| format!("rational: expected 'n/d', got {:?}", s))?;
    let num: i64 = num_s.trim().parse()
        .map_err(|e| format!("rational numerator: {}", e))?;
    let den: i64 = den_s.trim().parse()
        .map_err(|e| format!("rational denominator: {}", e))?;
    let (n, d) = normalize(num as i128, den as i128)
        .ok_or_else(|| "rational: zero or overflowing denominator".to_string())?;
    Ok(to_bytes(n, d))
}

// decode: 16 bytes -> "3/4"
pub fn rational_decode(b: &[u8]) -> Result<String, String> {
    if b.len() < BYTES {
        return Err(format!("rational: expected {} bytes, got {}", BYTES, b.len()));
    }
    let (n, d) = from_bytes(b);
    Ok(format!("{}/{}", n, d))
}

// compare: for ORDER BY, MIN, MAX
pub fn rational_compare(a: &[u8], b: &[u8]) -> std::cmp::Ordering {
    let (n1, d1) = from_bytes(a);
    let (n2, d2) = from_bytes(b);
    // cross-multiply (denominators are always positive)
    let lhs = (n1 as i128) * (d2 as i128);
    let rhs = (n2 as i128) * (d1 as i128);
    lhs.cmp(&rhs)
}

// hash: for COUNT(DISTINCT) and set operations
pub fn rational_hash(b: &[u8]) -> usize {
    // FNV-1a over the 16 bytes
    let mut h: usize = 0xcbf29ce484222325u64 as usize;
    for &byte in b {
        h ^= byte as usize;
        h = h.wrapping_mul(0x100000001b3u64 as usize);
    }
    h
}
The custom_type! registration and the arithmetic VDFs (rational_add, rational_sub, etc.) are in the full source at examples/vsql_rational/src/lib.rs. With the extension installed:
INSTALL EXTENSION 'vsql_rational';

CREATE TABLE fractions (
    id   INT PRIMARY KEY,
    val  RATIONAL
);

INSERT INTO fractions VALUES (1, '1/2'), (2, '3/4'), (3, '1/4');

-- ORDER BY uses rational_compare
SELECT val FROM fractions ORDER BY val;
-- → 1/4, 1/2, 3/4

-- Arithmetic with rational_add
SELECT rational_add('1/3', '1/6');
-- → 1/2

-- Extract numerator and denominator
SELECT rational_numer(val), rational_denom(val) FROM fractions;

-- Convert to floating-point approximation
SELECT rational_to_real('1/3');
-- → 0.3333333333333333
rational_to_real(r RATIONAL) -> REAL converts a RATIONAL value to a 64-bit floating-point approximation by dividing the numerator by the denominator. Useful when you need an approximate decimal for display or comparison but don’t want to store the lossy representation in the column.

The extension! block with types

When registering both functions and types, the extension! block has two sections:
villagesql::extension! {
    funcs: [
        // VDFs declared with func!
    ],
    types: [
        // Custom types declared with custom_type!
    ]
}
Either section can be omitted if empty. A type-only extension omits funcs:; a function-only extension omits types:.

Next steps

Rust API Reference

Complete reference for InValue, VdfReturn, and all macros.

Building Extensions in Rust

Getting started — Cargo setup, first function, packaging, and testing.

C++ Custom Types

Custom types in C++ — make_type<>, encode/decode/compare/hash, ALTER TABLE rules.

Extension Architecture

How custom types are resolved, cached, and stored.