quack-rs 0.8.0

Production-grade Rust SDK for building DuckDB loadable extensions
Documentation

The Rust SDK for building DuckDB loadable extensions — no C, no C++, no glue code.

quack-rs provides safe, production-grade wrappers for the DuckDB C Extension API, removing every known FFI pitfall so you can focus entirely on writing extension logic in pure Rust.


Table of Contents


Why quack-rs?

The DuckDB community extensions FAQ states:

Writing a Rust-based DuckDB extension requires writing glue code in C++ and will force you to build through DuckDB's CMake & C++ based extension template. We understand that this is not ideal and acknowledge the fact that Rust developers prefer to work on pure Rust codebases.

The DuckDB C Extension API (available since v1.1) changes this. quack-rs wraps that API and eliminates every rough edge, so you write zero lines of C or C++.

What extension authors face without quack-rs

Problem Without quack-rs With quack-rs
Entry point boilerplate ~40 lines of unsafe extern "C" code 1 macro call
State init/destroy Raw Box::into_raw / Box::from_raw FfiState<T> handles all of it
Boolean reads UB if read as bool directly VectorReader::read_bool uses u8 != 0
NULL output Silent corruption if ensure_validity_writable skipped VectorWriter::set_null calls it automatically
LogicalType memory Leak if not freed LogicalType implements Drop
Aggregate combine Config fields lost on segment-tree merges Testable with AggregateTestHarness
FFI panics Process abort or undefined behavior init_extension never panics
Table functions ~100 lines of raw bind/init/scan callbacks TableFunctionBuilder 5-method chain
Replacement scans Undocumented vtable + manual string allocation ReplacementScanBuilder 4-method chain
Complex types (STRUCT/LIST/MAP/ARRAY) Manual offset arithmetic over child vectors StructVector, ListVector, MapVector, ArrayVector helpers
Complex param/return types Raw duckdb_create_logical_type + manual lifecycle param_logical(LogicalType) / returns_logical(LogicalType) on all builders
Extension naming Rejected by DuckDB CI with no explanation validate_extension_name catches issues before submission
description.yml No tooling to validate before submission validate_description_yml_str validates the whole file
New project setup Hours of boilerplate + reading DuckDB internals generate_scaffold produces all 11 required files

What quack-rs Solves

Building a DuckDB extension in Rust — from project setup to community submission — requires navigating undocumented C API contracts, FFI memory rules, and data-encoding specifics found only in DuckDB's source code, which surface as silent corruption, process aborts, or unexplained CI rejections rather than compiler errors. quack-rs eliminates these barriers systematically across the complete extension lifecycle — scaffolding, function registration, type-safe data access, aggregate testing, metadata validation, and community submission readiness — with every abstraction backed by a documented, reproducible pitfall in LESSONS.md, making correct behavior automatic and incorrect behavior a compile-time error wherever the type system permits. The result is that any Rust developer can build, test, and ship a production-quality DuckDB extension without prior knowledge of DuckDB internals, covering every extension type exposed by DuckDB's public C Extension API: scalar, aggregate, table, cast, copy, replacement scan, and SQL macro functions.

quack-rs encapsulates 16 documented FFI pitfalls — hard-won knowledge from building real DuckDB extensions in Rust:

L1  COMBINE must propagate ALL config fields (not just data)
L2  State destroy double-free → FfiState<T> nulls pointers after free
L3  No panics across FFI → init_extension uses Result throughout
L4  ensure_validity_writable required before NULL output → VectorWriter handles it
L5  Boolean reading must use u8 != 0 → VectorReader enforces this
L6  Function set name must be set on EACH member → Set builders enforce on every member
L7  LogicalType memory leak → LogicalType implements Drop

P1  Library name must match [lib] name in Cargo.toml exactly
P2  C API version ("v1.2.0") ≠ DuckDB release version ("v1.4.4" / "v1.5.0")
P3  E2E SQLLogicTests required for community submission
P4  extension-ci-tools submodule must be initialized
P5  SQLLogicTest output must match DuckDB CLI output exactly
P6  Function registration can fail silently → builders check return values
P7  DuckDB strings use 16-byte format with inline and pointer variants
P8  INTERVAL is { months: i32, days: i32, micros: i64 } — not a single i64
P9  loadable-extension dispatch table uninitialised in cargo test → InMemoryDb initialises it

See LESSONS.md for full analysis of each pitfall.


Quick Start

1. Add the dependency

[dependencies]
quack-rs = "0.7"
libduckdb-sys = { version = ">=1.4.4, <2", features = ["loadable-extension"] }

DuckDB compatibility: quack-rs supports DuckDB 1.4.x and 1.5.x. Both releases expose the same C API version (v1.2.0), confirmed by E2E tests against DuckDB 1.4.4 and DuckDB 1.5.0. The upper bound <2 prevents silent adoption of a future major release that may change the C API. When the C API version changes, quack-rs will need to be updated and re-released.

2. Write your extension

// src/lib.rs
use quack_rs::prelude::*;

// Step 1: Define your aggregate state
#[derive(Default)]
struct WordCountState {
    count: i64,
}
impl AggregateState for WordCountState {}

// Step 2: Write callbacks using safe SDK helpers
unsafe extern "C" fn update(
    _info: libduckdb_sys::duckdb_function_info,
    chunk: libduckdb_sys::duckdb_data_chunk,
    states: *mut libduckdb_sys::duckdb_aggregate_state,
) {
    let reader = unsafe { VectorReader::new(chunk, 0) };
    for row in 0..reader.row_count() {
        if unsafe { reader.is_valid(row) } {
            let words = unsafe { reader.read_str(row) }
                .split_whitespace()
                .count() as i64;
            if let Some(state) =
                unsafe { FfiState::<WordCountState>::with_state_mut(*states.add(row)) }
            {
                state.count += words;
            }
        }
    }
}

unsafe extern "C" fn finalize(
    _info: libduckdb_sys::duckdb_function_info,
    states: *mut libduckdb_sys::duckdb_aggregate_state,
    result: libduckdb_sys::duckdb_vector,
    count: libduckdb_sys::idx_t,
    offset: libduckdb_sys::idx_t,
) {
    let mut writer = unsafe { VectorWriter::new(result) };
    for i in 0..count as usize {
        let idx = i + offset as usize;
        match unsafe { FfiState::<WordCountState>::with_state(*states.add(i)) } {
            Some(state) => unsafe { writer.write_i64(idx, state.count) },
            None => unsafe { writer.set_null(idx) },
        }
    }
}

unsafe extern "C" fn combine(
    _info: libduckdb_sys::duckdb_function_info,
    source: *mut libduckdb_sys::duckdb_aggregate_state,
    target: *mut libduckdb_sys::duckdb_aggregate_state,
    count: libduckdb_sys::idx_t,
) {
    for i in 0..count as usize {
        if let (Some(src), Some(tgt)) = (
            unsafe { FfiState::<WordCountState>::with_state(*source.add(i)) },
            unsafe { FfiState::<WordCountState>::with_state_mut(*target.add(i)) },
        ) {
            tgt.count += src.count;
        }
    }
}

// Step 3: Register using the builder
fn register(con: libduckdb_sys::duckdb_connection) -> ExtResult<()> {
    unsafe {
        AggregateFunctionBuilder::try_new("word_count")?
            .param(TypeId::Varchar)
            .returns(TypeId::BigInt)
            .state_size(FfiState::<WordCountState>::size_callback)
            .init(FfiState::<WordCountState>::init_callback)
            .update(update)
            .combine(combine)
            .finalize(finalize)
            .destructor(FfiState::<WordCountState>::destroy_callback)
            .register(con)?;
    }
    Ok(())
}

// Step 4: One macro call generates the entry point (pass the full symbol name DuckDB expects)
entry_point!(my_extension_init_c_api, |con| register(con));

3. Scaffold a new project

use quack_rs::scaffold::{generate_scaffold, ScaffoldConfig};

let config = ScaffoldConfig {
    name: "my_extension".to_string(),
    description: "Fast text analytics for DuckDB".to_string(),
    version: "0.1.0".to_string(),
    license: "MIT".to_string(),
    maintainer: "Your Name".to_string(),
    github_repo: "yourorg/duckdb-my-extension".to_string(),
    excluded_platforms: vec![], // or vec!["wasm_mvp".to_string(), ...]
};

let files = generate_scaffold(&config)?;
for file in &files {
    println!("{}", file.path);
    // write file.content to disk
}

This generates all 11 files required for a DuckDB community extension submission:

Cargo.toml                          ← cdylib, pinned deps, release profile
Makefile                            ← delegates to cargo + extension-ci-tools
extension_config.cmake              ← required by extension-ci-tools
src/lib.rs                          ← entry point template (no C++ needed)
src/wasm_lib.rs                     ← WebAssembly shim
description.yml                     ← community extension metadata
test/sql/my_extension.test          ← SQLLogicTest skeleton
.github/workflows/extension-ci.yml ← cross-platform CI (Linux/macOS/Windows)
.gitmodules                         ← extension-ci-tools submodule
.gitignore
.cargo/config.toml                  ← Windows CRT static linking

4. Append extension metadata

DuckDB loadable extensions require a metadata footer appended to the .so/.dylib/.dll after cargo build --release. quack-rs ships a native Rust binary for this step, replacing the Python append_extension_metadata.py script from the C++ template:

# Install the binary from the published crate
cargo install quack-rs --bin append_metadata

# Append metadata to your built extension (input .so → output .duckdb_extension)
append_metadata target/release/libmy_extension.so \
    my_extension.duckdb_extension \
    --duckdb-version v1.2.0 \
    --platform linux_amd64

Pitfall P2: The --duckdb-version flag must be v1.2.0 (the C API version), not the DuckDB release version (v1.4.4 or v1.5.0). DuckDB 1.4.x and 1.5.x both use C API version v1.2.0. Use the DUCKDB_API_VERSION constant from quack_rs to avoid hard-coding the wrong value.


Module Reference

Module Purpose Key types / functions
entry_point Extension initialization entry point init_extension, init_extension_v2, entry_point!, entry_point_v2!
connection Version-agnostic extension registration facade Connection, Registrar
aggregate Aggregate function registration AggregateFunctionBuilder, AggregateFunctionSetBuilder, AggregateFunctionInfo
aggregate::state Generic FFI state management AggregateState, FfiState<T>
aggregate::callbacks Callback type aliases UpdateFn, CombineFn, FinalizeFn, …
scalar Scalar function registration ScalarFunctionBuilder, ScalarFunctionSetBuilder, ScalarOverloadBuilder, ScalarFunctionInfo, ScalarBindInfo¹, ScalarInitInfo¹
cast Custom type cast functions CastFunctionBuilder, CastFunctionInfo, CastMode
table Table function registration (bind/init/scan) TableFunctionBuilder, BindInfo, FfiBindData, FfiInitData
replacement_scan SELECT * FROM 'file.xyz' replacement scans ReplacementScanBuilder
sql_macro SQL macro registration (no FFI callbacks) SqlMacro, MacroBody
vector Safe reading/writing of DuckDB vectors VectorReader, VectorWriter, vector_size()
vector::complex STRUCT / LIST / MAP / ARRAY child vector access StructVector, ListVector, MapVector, ArrayVector
vector::string 16-byte DuckDB string format DuckStringView, read_duck_string
types DuckDB type system wrappers TypeId, LogicalType, NullHandling
interval INTERVAL ↔ microseconds conversion DuckInterval, interval_to_micros
error FFI-safe error type ExtensionError, ExtResult<T>
config RAII wrapper for DuckDB database configuration DbConfig
validate Community extension compliance All validators below
validate::description_yml description.yml parsing and validation parse_description_yml, DescriptionYml
validate::extension_name Extension naming rules validate_extension_name
validate::function_name SQL identifier rules validate_function_name
validate::semver Semantic versioning validate_semver, ExtensionStability
validate::spdx SPDX license identifiers validate_spdx_license
validate::platform DuckDB build targets validate_platform, DUCKDB_PLATFORMS
validate::release_profile Cargo release profile validate_release_profile
scaffold Project generator generate_scaffold, ScaffoldConfig
testing Pure-Rust aggregate test harness AggregateTestHarness<S>
prelude Common re-exports use quack_rs::prelude::*
catalog¹ Catalog entry lookup CatalogEntry, Catalog, CatalogEntryType
client_context¹ Client context access (catalog, config, connection ID) ClientContext
config_option¹ Extension-defined configuration options ConfigOptionBuilder, ConfigOptionScope
copy_function¹ Custom COPY TO handlers CopyFunctionBuilder, CopyBindInfo, CopyGlobalInitInfo, CopySinkInfo, CopyFinalizeInfo
table_description¹ Table metadata (column count, names, types) TableDescription

¹ Requires the duckdb-1-5 feature flag (DuckDB 1.5.0+).


FFI Pitfalls Reference

The following table summarizes every known DuckDB Rust FFI pitfall and how quack-rs addresses it. The full analysis — including symptoms, root cause, and minimal reproduction — is in LESSONS.md.

Logic Pitfalls (L)

ID Name Symptom quack-rs Solution
L1 COMBINE config propagation Aggregate returns wrong results under parallelism Testable with AggregateTestHarness
L2 Double-free in destroy Heap corruption / SIGABRT FfiState<T>::destroy_callback nulls pointer after free
L3 Panic across FFI Process abort / UB init_extension propagates Result, never panics
L4 Missing ensure_validity_writable Segfault / silent NULL corruption VectorWriter::set_null calls it automatically
L5 Boolean undefined behavior Non-deterministic bool semantics VectorReader::read_bool reads u8 != 0
L6 Function set name on each member Silent registration failure AggregateFunctionSetBuilder and ScalarFunctionSetBuilder set name on every member
L7 LogicalType memory leak RSS grows with each extension load LogicalType implements Drop

Practical Pitfalls (P)

ID Name Symptom quack-rs Solution
P1 Library name mismatch Extension fails to load Documented; scaffold sets it correctly
P2 C API version ≠ release version Wrong -dv flag corrupts extension metadata DUCKDB_API_VERSION = "v1.2.0" constant; append_metadata binary ships with the crate
P3 Missing E2E tests Community submission rejected Scaffold generates SQLLogicTest skeleton
P4 Uninitialized submodule make fails with missing files Documented; scaffold generates .gitmodules
P5 SQLLogicTest format mismatch Tests fail with exact-match errors Documented with format reference
P6 Registration failure not checked Function silently not registered Builders check and propagate return values
P7 DuckDB 16-byte string format Garbled or truncated strings DuckStringView, read_duck_string
P8 INTERVAL layout misunderstood INTERVAL computed incorrectly DuckInterval with interval_to_micros
P9 loadable-extension dispatch table uninitialised in cargo test InMemoryDb::open() panics with "DuckDB API not initialized" InMemoryDb::open() calls CreateAPIv1() shim to populate dispatch table before opening connection

Community Extension Compliance

quack-rs enforces every requirement from the DuckDB community extensions development guide.

description.yml validation

Every community extension must include a description.yml metadata file. quack-rs can validate the entire file before submission:

use quack_rs::validate::description_yml::{
    parse_description_yml, validate_rust_extension, validate_description_yml_str,
};

// Quick pass/fail check
let result = validate_description_yml_str(include_str!("description.yml"));
assert!(result.is_ok(), "description.yml has errors: {}", result.unwrap_err());

// Structured access for programmatic inspection
let desc = parse_description_yml(include_str!("description.yml"))?;
println!("Extension: {} v{}", desc.name, desc.version);
println!("Maintainers: {:?}", desc.maintainers);

// Validate Rust-specific fields (language, build, toolchains)
validate_rust_extension(&desc)?;

Validated fields:

Field Rule
extension.name ^[a-z][a-z0-9_-]*$, max 64 chars
extension.version Semver or 7–40 char lowercase hex git hash
extension.license Recognized SPDX identifier
extension.excluded_platforms Semicolon-separated list of known DuckDB platforms
extension.maintainers At least one maintainer required
repo.github Must contain / (owner/repo format)
repo.ref Non-empty git ref

Example description.yml:

extension:
  name: my_extension
  description: Fast text analytics for DuckDB.
  version: 0.1.0
  language: Rust
  build: cargo
  license: MIT
  requires_toolchains: rust;python3
  excluded_platforms: "wasm_mvp;wasm_eh;wasm_threads"
  maintainers:
    - Your Name

repo:
  github: yourorg/duckdb-my-extension
  ref: main

Extension naming

Extensions submitted to the community repository must follow strict naming rules. Validate before you submit:

use quack_rs::validate::{validate_extension_name, validate_function_name};

// Extension names: lowercase alphanumeric, hyphens and underscores allowed
assert!(validate_extension_name("my_analytics").is_ok());
assert!(validate_extension_name("MyExt").is_err());       // uppercase rejected
assert!(validate_extension_name("my ext").is_err());      // spaces rejected
assert!(validate_extension_name("").is_err());             // empty rejected

// Function/SQL identifier names: lowercase alphanumeric and underscores only
assert!(validate_function_name("word_count").is_ok());
assert!(validate_function_name("word-count").is_err());    // hyphens not allowed in SQL
assert!(validate_function_name("WordCount").is_err());     // uppercase rejected

Platform targets

DuckDB community extensions must build for all 11 standard platforms or declare explicit exclusions:

linux_amd64         linux_amd64_gcc4    linux_arm64
osx_amd64           osx_arm64
windows_amd64       windows_amd64_mingw windows_arm64
wasm_mvp            wasm_eh             wasm_threads
use quack_rs::validate::{validate_platform, validate_excluded_platforms_str, DUCKDB_PLATFORMS};

// Validate a single platform
assert!(validate_platform("linux_amd64").is_ok());
assert!(validate_platform("freebsd_amd64").is_err()); // not a DuckDB platform

// Validate the excluded_platforms field from description.yml
// (semicolon-separated, as it appears in the YAML file)
assert!(validate_excluded_platforms_str("wasm_mvp;wasm_eh;wasm_threads").is_ok());
assert!(validate_excluded_platforms_str("invalid_platform").is_err());
assert!(validate_excluded_platforms_str("").is_ok()); // empty = no exclusions

println!("All DuckDB platforms: {:?}", DUCKDB_PLATFORMS);

Extension versioning

DuckDB extensions use a three-tier versioning scheme:

Tier Format Example Meaning
Unstable Short git hash 690bfc5 No stability guarantees
Pre-release Semver 0.y.z 0.1.0 Working toward stability
Stable Semver x.y.z (x ≥ 1) 1.0.0 Full semver semantics
use quack_rs::validate::{validate_extension_version, validate_semver};
use quack_rs::validate::semver::{classify_extension_version, ExtensionStability};

// validate_extension_version accepts both semver and git hashes
assert!(validate_extension_version("1.0.0").is_ok());
assert!(validate_extension_version("0.1.0").is_ok());
assert!(validate_extension_version("690bfc5").is_ok()); // git hash (unstable)
assert!(validate_extension_version("").is_err());

// Classify the stability tier
let (stability, _) = classify_extension_version("1.0.0").unwrap();
assert_eq!(stability, ExtensionStability::Stable);

let (stability, _) = classify_extension_version("0.1.0").unwrap();
assert_eq!(stability, ExtensionStability::PreRelease);

let (stability, _) = classify_extension_version("690bfc5").unwrap();
assert_eq!(stability, ExtensionStability::Unstable);

Release profile requirements

DuckDB loadable extensions are shared libraries. The Cargo release profile must be configured correctly:

[profile.release]
panic = "abort"     # REQUIRED: panics across FFI are undefined behavior
lto = true          # Recommended: reduces binary size
codegen-units = 1   # Recommended: better optimization quality
opt-level = 3       # Recommended: maximum performance
strip = true        # Recommended: smaller binary
use quack_rs::validate::validate_release_profile;

// Validate from parsed Cargo.toml values
let check = validate_release_profile("abort", "true", "3", "1").unwrap();
assert!(check.is_fully_optimized());
assert!(check.panic_abort);       // REQUIRED
assert!(check.lto_enabled);       // recommended
assert!(check.opt_level_3);       // recommended
assert!(check.codegen_units_1);   // recommended

// Missing panic=abort is rejected with a descriptive error
let err = validate_release_profile("unwind", "true", "3", "1").unwrap_err();
assert!(err.as_str().contains("panic"));

Architecture

Module dependency graph

flowchart TB
    Author(["**Extension Author**<br/>use quack_rs::prelude::*"]):::author

    subgraph REG ["Registration layer"]
        direction LR
        EP["**entry_point**<br/>entry_point! · init_extension"]
        AGG["**aggregate**<br/>AggregateFunctionBuilder<br/>AggregateFunctionSetBuilder · FfiState&lt;T&gt;"]
        SCL["**scalar**<br/>ScalarFunctionBuilder<br/>ScalarFunctionSetBuilder"]
        TBL["**table**<br/>TableFunctionBuilder<br/>BindInfo · FfiBindData · FfiInitData"]
        RSC["**replacement_scan**<br/>ReplacementScanBuilder"]
        SM["**sql_macro**<br/>SqlMacro · MacroBody"]
        CST["**cast**<br/>CastFunctionBuilder"]
        CPY["**copy_function**¹<br/>CopyFunctionBuilder"]
    end

    subgraph DATA ["Data layer"]
        direction LR
        VEC["**vector**<br/>VectorReader · VectorWriter<br/>ValidityBitmap · DuckStringView"]
        CMP["**vector::complex**<br/>StructVector · ListVector · MapVector · ArrayVector"]
        TYP["**types**<br/>TypeId · LogicalType"]
        INT["**interval**<br/>DuckInterval · interval_to_micros"]
        CFG["**config_option**¹<br/>ConfigOptionBuilder"]
        CAT["**catalog**¹<br/>CatalogEntry · Catalog"]
        CTX["**client_context**¹<br/>ClientContext"]
        TDS["**table_description**¹<br/>TableDescription"]
    end

    SYS["**libduckdb-sys** >=1.4.4, &lt;2<br/>DuckDB C Extension API<br/>headers only · no linked library"]:::ffi

    RT[("**DuckDB**<br/>Runtime")]:::duckdb

    subgraph DEV ["Dev-time utilities"]
        direction LR
        ERR["**error**<br/>ExtensionError · ExtResult&lt;T&gt;"]
        TST["**testing**<br/>AggregateTestHarness&lt;S&gt;<br/>pure Rust · no FFI"]
        VAL["**validate**<br/>extension_name · semver<br/>spdx · platform<br/>description_yml"]
        SCF["**scaffold**<br/>generate_scaffold<br/>ScaffoldConfig"]
    end

    Author --> REG
    REG    --> DATA
    DATA   --> SYS
    SYS    --> RT
    Author -.->|"dev-time"| DEV

    classDef author fill:#1e3a5f,stroke:#5a9fd4,color:#ddf0ff
    classDef ffi fill:#3d2406,stroke:#c87941,color:#f5dbb4
    classDef duckdb fill:#1c3b1c,stroke:#4a9e4a,color:#c8ecc8

Design principles

  1. Thin wrapper mandate: Every abstraction must pay for itself in reduced boilerplate or improved safety. When in doubt, prefer simplicity over cleverness.

  2. No panics across FFI: unwrap() and panic!() are forbidden in any code path that crosses the FFI boundary. All errors propagate as Result<T, ExtensionError>.

  3. Bounded version range: libduckdb-sys = ">=1.4.4, <2" is deliberate. The C API is stable across DuckDB 1.4.x and 1.5.x (verified by E2E tests on both). The upper bound prevents silent adoption of a future major release. When the C API version changes, quack-rs will be updated.

  4. Testable business logic: Aggregate state structs have zero FFI dependencies. They can be tested in isolation using AggregateTestHarness<S> without a DuckDB runtime.

  5. Enforce community standards: The validate module makes it impossible to accidentally submit a non-compliant extension. Validation errors are caught at build time, not submission time.

Safety model

All unsafe code within quack-rs is sound and documented. Extension authors must write unsafe extern "C" callback functions (required by DuckDB's C API), but the SDK's helpers (FfiState, VectorReader, VectorWriter) minimize the surface area of unsafe code within those callbacks. Every unsafe block in this crate has a // SAFETY: comment explaining the invariants being upheld.

// Extension author code: no unsafe required
fn register(con: duckdb_connection) -> ExtResult<()> {
    AggregateFunctionBuilder::try_new("word_count")?
        .param(TypeId::Varchar)
        .returns(TypeId::BigInt)
        // ... callbacks (which are unsafe extern "C" fns) ...
        .register(con)          // unsafe is inside the SDK
}

Architecture Decision Records

ADR-1: Thin Wrapper Mandate

quack-rs wraps, but does not redesign, the DuckDB C API. We do not invent new abstractions that would require understanding two APIs. Extension authors who want to go below the SDK can use libduckdb-sys directly — the two libraries compose without conflict.

ADR-2: Bounded Version Range

libduckdb-sys = ">=1.4.4, <2" is intentional. The DuckDB C Extension API is stable across 1.4.x and 1.5.x — verified by E2E tests against DuckDB 1.4.4 and DuckDB 1.5.0. Both releases use C API version v1.2.0. The upper bound <2 prevents silent adoption of a future major-band release that may change the C API version or callback signatures. When a future DuckDB release bumps the C API version, quack-rs will need to be updated to match.

ADR-3: No Panics Across FFI

The Rust reference is explicit: unwinding across an FFI boundary is undefined behavior. quack-rs enforces this architecturally: every FFI boundary in the SDK is wrapped by init_extension, which converts Result::Err into a DuckDB error report via set_error. No unwrap(), expect(), or panic!() appears in any code path reachable from a DuckDB callback.


Testing strategy

quack-rs uses four layers of tests:

1. Unit tests (in every source file)

Each module contains #[cfg(test)] unit tests that verify pure-Rust behavior without a DuckDB runtime. These test state machine correctness, builder field storage, validation logic, and the description.yml parser.

2. Property-based tests (proptest)

The interval module and AggregateTestHarness include proptest-based tests:

  • interval_to_micros overflow is detected for all possible field combinations
  • AggregateTestHarness::combine associativity holds for sum aggregates
  • combine identity element property: combining with empty state is idempotent

3. Integration tests (tests/integration_test.rs)

Pure-Rust cross-module tests that exercise the public API without a live DuckDB process. These cover DuckInterval, TypeId, FfiState<T> lifecycle, AggregateTestHarness, ExtensionError, VectorReader/VectorWriter layout, DuckStringView, and SqlMacro.

4. Example extension (examples/hello-ext)

A comprehensive extension that exercises every feature in quack-rs: scalar, aggregate, table, cast, replacement scan, and SQL macro functions — plus complex types (STRUCT, LIST, MAP), entry_point_v2!/Connection/Registrar, aggregate sets, scalar sets with per-overload NULL handling, DuckInterval, ValidityBitmap, named_param, local_init, implicit_cost, extra_info, and all VectorReader/VectorWriter type variants. All 39 live SQL tests pass against both DuckDB 1.4.4 and 1.5.0.

Testing aggregate logic without DuckDB

The AggregateTestHarness<S> type lets you test aggregate state logic in isolation:

use quack_rs::testing::AggregateTestHarness;
use quack_rs::aggregate::AggregateState;

#[derive(Default)]
struct SumState { total: i64 }
impl AggregateState for SumState {}

// Test update logic
let result = AggregateTestHarness::<SumState>::aggregate(
    [10_i64, 20, 30],
    |s, v| s.total += v,
);
assert_eq!(result.total, 60);

// Test combine: verify config fields are propagated (Pitfall L1)
let mut source = AggregateTestHarness::<SumState>::new();
source.update(|s| s.total = 100);

let mut target = AggregateTestHarness::<SumState>::new();
target.combine(&source, |src, tgt| tgt.total += src.total);

assert_eq!(target.finalize().total, 100);

Known Limitations

Window functions are not available

DuckDB's window functions (OVER (...) clauses) are implemented entirely in the C++ API layer and have no counterpart in DuckDB's public C extension API. This is not a gap in quack-rs or in libduckdb-sys — the symbol duckdb_create_window_function does not exist in the C API.

COPY format handlers were previously in this category, but DuckDB 1.5.0 added duckdb_create_copy_function and related symbols. quack-rs wraps them in the copy_function module (requires the duckdb-1-5 feature flag).

If DuckDB exposes the window function API in a future C API version, quack-rs will add wrappers in the relevant release.

VARIANT type (Iceberg v3)

DuckDB v1.5.1 introduced the VARIANT type for Iceberg v3 support. This type is not yet exposed in the DuckDB C Extension API (DUCKDB_TYPE_VARIANT does not exist in libduckdb-sys 1.10501.0). quack-rs will add TypeId::Variant when the C API exposes it.

For the full list of resolved and open limitations, see the Known Limitations reference page.


Changelog

See CHANGELOG.md for the full version history.

v0.7.1 (2026-03-27) — Upgraded to DuckDB 1.5.1 (libduckdb-sys 1.10501.0). Added TypeId::Any, TypeId::Varint, TypeId::SqlNull (behind duckdb-1-5). Fixed ARM64/aarch64 builds. Added 28 new tests (DbConfig, MockVector, TypeId variants). Comprehensive v1.5.1 compatibility evaluation. Users should upgrade DuckDB runtime for WAL and ART index fixes.

v0.7.0 (2026-03-22) — Upgraded to DuckDB 1.5.0 (libduckdb-sys 1.10500.0). Populated the duckdb-1-5 feature flag with five new modules: catalog, client_context, config_option, copy_function, table_description. Added TypeId::TimeNs and ScalarFunctionBuilder methods varargs(), volatile(), bind(), init(). COPY format handlers are now supported.

v0.5.0 (2026-03-10) — Added param_logical(LogicalType) and returns_logical(LogicalType) on all function builders (scalar, aggregate, and their set variants), enabling complex parameterized types like LIST(BOOLEAN) or MAP(VARCHAR, INTEGER) without raw FFI. Added per-overload null_handling(NullHandling) on set overload builders.

v0.4.0 (2026-03-09) — Added Connection and Registrar trait (version-agnostic registration facade), init_extension_v2, entry_point_v2! macro, and duckdb-1-5 feature flag. Broadened libduckdb-sys support from an exact =1.4.4 pin to >=1.4.4, <2, covering DuckDB 1.4.x and 1.5.x.

v0.3.0 (2026-03-08) — Added TableFunctionBuilder, ReplacementScanBuilder, CastFunctionBuilder, complex vector types (StructVector, ListVector, MapVector), write_interval, DbConfig, append_metadata binary, cross-platform CI (Linux/macOS/Windows).

v0.2.0 (2026-03-07) — Added validate::description_yml module, prelude module, scaffold improvements (extension_config.cmake, SQLLogicTest, GitHub Actions CI), ScalarFunctionBuilder, entry_point! macro, vector writer improvements, Windows CI.

v0.1.0 (2025-05-01) — Initial release with all core modules.


Contributing

See CONTRIBUTING.md for the development guide, quality gates, and how to run the full test suite.

Quality gates (all required before merge):

cargo test --all-targets                      # all tests pass
cargo clippy --all-targets -- -D warnings     # no clippy warnings
cargo fmt -- --check                          # code is formatted
cargo doc --no-deps                           # docs compile without warnings
cargo check                                   # MSRV check (Rust 1.84.1)

License

MIT — see LICENSE.


quack-rs is a community project. It is not affiliated with or endorsed by DuckDB Labs. Built with care for the open-source and Rust communities.