use crate::Result;
use crate::error::{TypeChangeOperation, TypeChangeWriteMode};
use crate::metadata_writer::{
ColumnDef, ColumnStat, CommitIds, DataFileInfo, MetadataWriter, WriteMode, WriteSetupResult,
columns_differ, validate_name,
};
use duckdb::{Connection, OptionalExt, Transaction, params};
use std::sync::{Arc, Mutex, MutexGuard};
const SQL_CREATE_SCHEMA: &str = r#"
CREATE SEQUENCE IF NOT EXISTS ducklake_schema_id_seq START 1;
CREATE SEQUENCE IF NOT EXISTS ducklake_table_id_seq START 1;
CREATE SEQUENCE IF NOT EXISTS ducklake_data_file_id_seq START 1;
CREATE SEQUENCE IF NOT EXISTS ducklake_delete_file_id_seq START 1;
CREATE TABLE IF NOT EXISTS ducklake_metadata (
key VARCHAR NOT NULL,
value VARCHAR NOT NULL,
scope VARCHAR
);
CREATE TABLE IF NOT EXISTS ducklake_snapshot (
snapshot_id BIGINT PRIMARY KEY,
snapshot_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
schema_version BIGINT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS ducklake_schema_versions (
begin_snapshot BIGINT NOT NULL,
schema_version BIGINT NOT NULL,
table_id BIGINT NOT NULL,
UNIQUE (table_id, begin_snapshot)
);
CREATE TABLE IF NOT EXISTS ducklake_schema (
schema_id BIGINT PRIMARY KEY DEFAULT nextval('ducklake_schema_id_seq'),
schema_name VARCHAR NOT NULL,
path VARCHAR NOT NULL DEFAULT '',
path_is_relative BOOLEAN NOT NULL DEFAULT true,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
);
CREATE TABLE IF NOT EXISTS ducklake_table (
table_id BIGINT PRIMARY KEY DEFAULT nextval('ducklake_table_id_seq'),
schema_id BIGINT NOT NULL,
table_name VARCHAR NOT NULL,
path VARCHAR NOT NULL DEFAULT '',
path_is_relative BOOLEAN NOT NULL DEFAULT true,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
);
-- Bare table (no PK, no NOT NULL), upstream's exact column order, so a column
-- can be versioned by [begin_snapshot, end_snapshot) and a type promotion can
-- write a second row sharing the same column_id. Mirrors the SQLite writer's
-- `ducklake_column`. The four `*default*` columns + `parent_column` are left
-- NULL (no nested-type / column-default writes here).
CREATE TABLE IF NOT EXISTS ducklake_column (
column_id BIGINT,
begin_snapshot BIGINT,
end_snapshot BIGINT,
table_id BIGINT,
column_order BIGINT,
column_name VARCHAR,
column_type VARCHAR,
initial_default VARCHAR,
default_value VARCHAR,
nulls_allowed BOOLEAN,
parent_column BIGINT,
default_value_type VARCHAR,
default_value_dialect VARCHAR
);
CREATE TABLE IF NOT EXISTS ducklake_data_file (
data_file_id BIGINT PRIMARY KEY DEFAULT nextval('ducklake_data_file_id_seq'),
table_id BIGINT NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT true,
file_size_bytes BIGINT NOT NULL,
footer_size BIGINT,
encryption_key VARCHAR,
record_count BIGINT,
row_id_start BIGINT,
mapping_id BIGINT,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
);
CREATE TABLE IF NOT EXISTS ducklake_table_stats (
table_id BIGINT PRIMARY KEY,
record_count BIGINT NOT NULL DEFAULT 0,
next_row_id BIGINT NOT NULL DEFAULT 0,
file_size_bytes BIGINT NOT NULL DEFAULT 0
);
-- Per-file, per-column zone maps (DuckLake spec) — powers file pruning.
-- Column set mirrors the official extension and the other backends.
CREATE TABLE IF NOT EXISTS ducklake_file_column_stats (
data_file_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
column_id BIGINT NOT NULL,
column_size_bytes BIGINT,
value_count BIGINT,
null_count BIGINT,
min_value VARCHAR,
max_value VARCHAR,
contains_nan BOOLEAN,
extra_stats VARCHAR
);
-- Table-wide per-column roll-up (DuckLake spec) — feeds the optimizer.
CREATE TABLE IF NOT EXISTS ducklake_table_column_stats (
table_id BIGINT NOT NULL,
column_id BIGINT NOT NULL,
contains_null BOOLEAN,
contains_nan BOOLEAN,
min_value VARCHAR,
max_value VARCHAR,
extra_stats VARCHAR
);
CREATE TABLE IF NOT EXISTS ducklake_delete_file (
delete_file_id BIGINT PRIMARY KEY DEFAULT nextval('ducklake_delete_file_id_seq'),
data_file_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT true,
file_size_bytes BIGINT NOT NULL,
footer_size BIGINT,
encryption_key VARCHAR,
delete_count BIGINT,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
);
CREATE TABLE IF NOT EXISTS ducklake_files_scheduled_for_deletion (
data_file_id BIGINT NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT true,
schedule_start TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
"#;
#[derive(Debug, Clone)]
pub struct DuckdbMetadataWriter {
conn: Arc<Mutex<Connection>>,
#[allow(dead_code)]
catalog_path: String,
}
impl DuckdbMetadataWriter {
pub fn new(path: impl Into<String>) -> Result<Self> {
let catalog_path = path.into();
let conn = Connection::open(&catalog_path)?;
Ok(Self {
conn: Arc::new(Mutex::new(conn)),
catalog_path,
})
}
pub fn new_with_init(path: impl Into<String>) -> Result<Self> {
let writer = Self::new(path)?;
writer.initialize_schema()?;
Ok(writer)
}
fn connection(&self) -> MutexGuard<'_, Connection> {
self.conn.lock().expect("DuckDB connection mutex poisoned")
}
}
fn reserve_ids(tx: &Transaction<'_>, key: &str, n: i64) -> Result<i64> {
let last: i64 = tx.query_row(
"UPDATE ducklake_metadata
SET value = CAST(CAST(value AS BIGINT) + ? AS VARCHAR)
WHERE key = ? AND scope IS NULL
RETURNING CAST(value AS BIGINT)",
params![n, key],
|row| row.get(0),
)?;
Ok(last)
}
fn insert_snapshot(tx: &Transaction<'_>) -> Result<(i64, i64)> {
let (snapshot_id, schema_version): (i64, i64) = tx.query_row(
"SELECT COALESCE(MAX(snapshot_id), 0) + 1, COALESCE(MAX(schema_version), 0)
FROM ducklake_snapshot",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
tx.execute(
"INSERT INTO ducklake_snapshot (snapshot_id, snapshot_time, schema_version)
VALUES (?, CURRENT_TIMESTAMP, ?)",
params![snapshot_id, schema_version],
)?;
Ok((snapshot_id, schema_version))
}
fn bump_schema_version(tx: &Transaction<'_>, snapshot_id: i64) -> Result<i64> {
let prev_max: i64 = tx.query_row(
"SELECT COALESCE(MAX(schema_version), 0) FROM ducklake_snapshot WHERE snapshot_id <> ?",
params![snapshot_id],
|row| row.get(0),
)?;
let new_version = prev_max + 1;
tx.execute(
"UPDATE ducklake_snapshot SET schema_version = ? WHERE snapshot_id = ?",
params![new_version, snapshot_id],
)?;
Ok(new_version)
}
fn record_schema_version(
tx: &Transaction<'_>,
snapshot_id: i64,
schema_version: i64,
table_id: i64,
) -> Result<()> {
tx.execute(
"INSERT INTO ducklake_schema_versions (begin_snapshot, schema_version, table_id)
VALUES (?, ?, ?)",
params![snapshot_id, schema_version, table_id],
)?;
Ok(())
}
fn seed_stats_if_missing(tx: &Transaction<'_>, table_id: i64) -> Result<()> {
let exists: Option<i64> = tx
.query_row(
"SELECT 1 FROM ducklake_table_stats WHERE table_id = ?",
params![table_id],
|row| row.get(0),
)
.optional()?;
if exists.is_none() {
tx.execute(
"INSERT INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
params![table_id],
)?;
}
Ok(())
}
fn detect_replace_conflict(tx: &Transaction<'_>, table_id: i64, base_snapshot: i64) -> Result<()> {
let conflicts: i64 = tx.query_row(
"SELECT COUNT(*) FROM ducklake_data_file
WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?)",
params![table_id, base_snapshot, base_snapshot],
|row| row.get(0),
)?;
if conflicts > 0 {
return Err(crate::DuckLakeError::Conflict(format!(
"Replace on table {table_id} conflicts with a concurrent write committed since \
snapshot {base_snapshot}; aborting (retry the write against the new generation)"
)));
}
Ok(())
}
fn retire_prior_generation(tx: &Transaction<'_>, table_id: i64, snapshot_id: i64) -> Result<()> {
tx.execute(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL AND begin_snapshot < ?",
params![snapshot_id, table_id, snapshot_id],
)?;
tx.execute(
"UPDATE ducklake_table_stats SET record_count = 0, file_size_bytes = 0 WHERE table_id = ?",
params![table_id],
)?;
Ok(())
}
fn insert_file_column_stats(
tx: &Transaction<'_>,
table_id: i64,
data_file_id: i64,
column_stats: &[ColumnStat],
) -> Result<()> {
for stat in column_stats {
tx.execute(
"INSERT INTO ducklake_file_column_stats
(data_file_id, table_id, column_id, column_size_bytes,
value_count, null_count, min_value, max_value, contains_nan, extra_stats)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)",
params![
data_file_id,
table_id,
stat.column_id,
stat.column_size_bytes,
stat.value_count,
stat.null_count,
stat.min_value.as_deref(),
stat.max_value.as_deref(),
stat.contains_nan,
],
)?;
}
Ok(())
}
fn recompute_table_column_stats(
tx: &Transaction<'_>,
table_id: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<()> {
use crate::stats_encode::{FileColumnStat, aggregate_global_column_stats};
let live_file_count: i64 = tx.query_row(
"SELECT COUNT(*) FROM ducklake_data_file WHERE table_id = ? AND end_snapshot IS NULL",
params![table_id],
|row| row.get(0),
)?;
let per_file: Vec<FileColumnStat> = {
let mut stmt = tx.prepare(
"SELECT s.column_id, s.min_value, s.max_value, s.null_count, s.contains_nan
FROM ducklake_file_column_stats s
JOIN ducklake_data_file d ON d.data_file_id = s.data_file_id
WHERE d.table_id = ? AND d.end_snapshot IS NULL",
)?;
let mapped = stmt.query_map(params![table_id], |row| {
Ok(FileColumnStat {
column_id: row.get::<_, i64>(0)?,
min_value: row.get::<_, Option<String>>(1)?,
max_value: row.get::<_, Option<String>>(2)?,
null_count: row.get::<_, Option<i64>>(3)?,
contains_nan: row.get::<_, Option<bool>>(4)?,
})
})?;
mapped.collect::<duckdb::Result<Vec<_>>>()?
};
let numeric_of = |column_id: i64| -> bool {
column_ids
.iter()
.position(|id| *id == column_id)
.and_then(|i| columns.get(i))
.map(|c| crate::stats_encode::is_numeric_ducklake_type(c.ducklake_type()))
.unwrap_or(false)
};
let globals = aggregate_global_column_stats(&per_file, live_file_count, numeric_of);
tx.execute(
"DELETE FROM ducklake_table_column_stats WHERE table_id = ?",
params![table_id],
)?;
for g in globals {
tx.execute(
"INSERT INTO ducklake_table_column_stats
(table_id, column_id, contains_null, contains_nan, min_value, max_value, extra_stats)
VALUES (?, ?, ?, ?, ?, ?, NULL)",
params![
table_id,
g.column_id,
g.contains_null,
g.contains_nan,
g.min_value,
g.max_value,
],
)?;
}
Ok(())
}
fn finalize_snapshot(
tx: &Transaction<'_>,
table_id: i64,
columns: &[ColumnDef],
column_ids: &[i64],
mode: WriteMode,
base_snapshot: i64,
) -> Result<i64> {
use std::collections::{HashMap, HashSet};
let (snapshot_id, mut schema_version) = insert_snapshot(tx)?;
let current: Vec<(String, String, i64, bool)> = {
let mut stmt = tx.prepare(
"SELECT column_name, column_type, column_order, nulls_allowed
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)?;
let mapped = stmt.query_map(params![table_id], |row| {
let name: String = row.get(0)?;
let ty: String = row.get(1)?;
let order: i64 = row.get(2)?;
let nullable: Option<bool> = row.get(3)?;
Ok((name, ty, order, nullable.unwrap_or(true)))
})?;
mapped.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?
};
let existing: Vec<(String, String, bool)> = current
.iter()
.map(|(name, ty, _order, nullable)| (name.clone(), ty.clone(), *nullable))
.collect();
let is_ddl = existing.is_empty() || columns_differ(&existing, columns);
if is_ddl {
schema_version = bump_schema_version(tx, snapshot_id)?;
}
let new_names: HashSet<&str> = columns.iter().map(|c| c.name()).collect();
let mut current_by_name: HashMap<String, (i64, bool)> = HashMap::new();
for (name, _ty, order, nullable) in ¤t {
if !new_names.contains(name.as_str()) {
tx.execute(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL",
params![snapshot_id, table_id, name.as_str()],
)?;
}
current_by_name.insert(name.clone(), (*order, *nullable));
}
for (order, (col, column_id)) in columns.iter().zip(column_ids.iter()).enumerate() {
match current_by_name.get(col.name()) {
Some(&(cur_order, cur_nullable)) => {
if cur_order != order as i64 || cur_nullable != col.is_nullable() {
tx.execute(
"UPDATE ducklake_column SET column_order = ?, nulls_allowed = ?
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL",
params![order as i64, col.is_nullable(), table_id, col.name()],
)?;
}
},
None => {
tx.execute(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)",
params![
column_id,
table_id,
col.name(),
col.ducklake_type(),
order as i64,
col.is_nullable(),
snapshot_id
],
)?;
},
}
}
if mode == WriteMode::Replace {
detect_replace_conflict(tx, table_id, base_snapshot)?;
seed_stats_if_missing(tx, table_id)?;
retire_prior_generation(tx, table_id, snapshot_id)?;
}
if is_ddl {
record_schema_version(tx, snapshot_id, schema_version, table_id)?;
}
Ok(snapshot_id)
}
impl MetadataWriter for DuckdbMetadataWriter {
fn create_snapshot(&self) -> Result<i64> {
let mut conn = self.connection();
let tx = conn.transaction()?;
let (snapshot_id, _schema_version) = insert_snapshot(&tx)?;
tx.commit()?;
Ok(snapshot_id)
}
fn get_or_create_schema(
&self,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)> {
validate_name(name, "Schema")?;
let mut conn = self.connection();
let tx = conn.transaction()?;
let existing: Option<i64> = tx
.query_row(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
params![name],
|row| row.get(0),
)
.optional()?;
if let Some(schema_id) = existing {
tx.commit()?;
return Ok((schema_id, false));
}
let schema_path = path.unwrap_or(name);
let schema_id: i64 = tx.query_row(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, true, ?) RETURNING schema_id",
params![name, schema_path, snapshot_id],
|row| row.get(0),
)?;
tx.commit()?;
Ok((schema_id, true))
}
fn get_or_create_table(
&self,
schema_id: i64,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)> {
validate_name(name, "Table")?;
let mut conn = self.connection();
let tx = conn.transaction()?;
let existing: Option<i64> = tx
.query_row(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
params![schema_id, name],
|row| row.get(0),
)
.optional()?;
if let Some(table_id) = existing {
tx.commit()?;
return Ok((table_id, false));
}
let table_path = path.unwrap_or(name);
let table_id: i64 = tx.query_row(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, true, ?) RETURNING table_id",
params![schema_id, name, table_path, snapshot_id],
|row| row.get(0),
)?;
tx.commit()?;
Ok((table_id, true))
}
fn set_columns(
&self,
table_id: i64,
columns: &[ColumnDef],
snapshot_id: i64,
) -> Result<Vec<i64>> {
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"Table must have at least one column".to_string(),
));
}
let mut conn = self.connection();
let tx = conn.transaction()?;
tx.execute(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
params![snapshot_id, table_id],
)?;
let n = columns.len() as i64;
let last_column_id = reserve_ids(&tx, "next_column_id", n)?;
let first_column_id = last_column_id - n + 1;
let mut column_ids = Vec::with_capacity(columns.len());
for (order, col) in columns.iter().enumerate() {
let column_id = first_column_id + order as i64;
tx.execute(
"INSERT INTO ducklake_column (column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)",
params![
column_id,
table_id,
col.name(),
col.ducklake_type(),
order as i64,
col.is_nullable(),
snapshot_id
],
)?;
column_ids.push(column_id);
}
tx.commit()?;
Ok(column_ids)
}
fn register_data_file(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
file: &DataFileInfo,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
let mut conn = self.connection();
let tx = conn.transaction()?;
let snapshot_id =
finalize_snapshot(&tx, table_id, columns, column_ids, mode, base_snapshot)?;
seed_stats_if_missing(&tx, table_id)?;
let row_id_start: i64 = tx.query_row(
"SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?",
params![table_id],
|row| row.get(0),
)?;
let data_file_id: i64 = tx.query_row(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING data_file_id",
params![
table_id,
file.path.as_str(),
file.path_is_relative,
file.file_size_bytes,
file.footer_size,
file.record_count,
row_id_start,
snapshot_id
],
|row| row.get(0),
)?;
insert_file_column_stats(&tx, table_id, data_file_id, &file.column_stats)?;
recompute_table_column_stats(&tx, table_id, columns, column_ids)?;
tx.execute(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
params![file.record_count, file.record_count, file.file_size_bytes, table_id],
)?;
let schema_id: i64 = tx.query_row(
"SELECT schema_id FROM ducklake_table WHERE table_id = ?",
params![table_id],
|row| row.get(0),
)?;
tx.commit()?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
}
fn publish_snapshot(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
let mut conn = self.connection();
let tx = conn.transaction()?;
let snapshot_id =
finalize_snapshot(&tx, table_id, columns, column_ids, mode, base_snapshot)?;
let schema_id: i64 = tx.query_row(
"SELECT schema_id FROM ducklake_table WHERE table_id = ?",
params![table_id],
|row| row.get(0),
)?;
tx.commit()?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
}
fn end_table_files(&self, table_id: i64, snapshot_id: i64) -> Result<u64> {
let mut conn = self.connection();
let tx = conn.transaction()?;
let rows_affected = tx.execute(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
params![snapshot_id, table_id],
)? as u64;
tx.execute(
"UPDATE ducklake_table_stats
SET record_count = 0, file_size_bytes = 0
WHERE table_id = ?",
params![table_id],
)?;
tx.commit()?;
Ok(rows_affected)
}
fn get_data_path(&self) -> Result<String> {
let conn = self.connection();
let row: Option<String> = conn
.query_row(
"SELECT value FROM ducklake_metadata WHERE key = ? AND scope IS NULL",
params!["data_path"],
|row| row.get(0),
)
.optional()?;
match row {
Some(path) => Ok(path),
None => Err(crate::error::DuckLakeError::InvalidConfig(
"Missing required catalog metadata: 'data_path' not configured.".to_string(),
)),
}
}
fn set_data_path(&self, path: &str) -> Result<()> {
let mut conn = self.connection();
let tx = conn.transaction()?;
tx.execute(
"DELETE FROM ducklake_metadata WHERE key = 'data_path' AND scope IS NULL",
[],
)?;
tx.execute(
"INSERT INTO ducklake_metadata (key, value, scope) VALUES ('data_path', ?, NULL)",
params![path],
)?;
tx.commit()?;
Ok(())
}
fn initialize_schema(&self) -> Result<()> {
let conn = self.connection();
conn.execute_batch(SQL_CREATE_SCHEMA)?;
conn.execute_batch(
"INSERT INTO ducklake_metadata (key, value, scope)
SELECT 'next_column_id',
CAST(COALESCE((SELECT MAX(column_id) FROM ducklake_column), 0) AS VARCHAR),
NULL
WHERE NOT EXISTS (
SELECT 1 FROM ducklake_metadata WHERE key = 'next_column_id' AND scope IS NULL
)",
)?;
Ok(())
}
fn begin_write_transaction(
&self,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
mode: WriteMode,
) -> Result<WriteSetupResult> {
validate_name(schema_name, "Schema")?;
validate_name(table_name, "Table")?;
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"Table must have at least one column".to_string(),
));
}
let mut conn = self.connection();
let tx = conn.transaction()?;
let n = columns.len() as i64;
let last_column_id = reserve_ids(&tx, "next_column_id", n)?;
let fresh_ids: Vec<i64> = ((last_column_id - n + 1)..=last_column_id).collect();
let base_snapshot_id: i64 = tx.query_row(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
[],
|row| row.get(0),
)?;
let snapshot_id: i64 = base_snapshot_id + 1;
let schema_id: i64 = {
let existing: Option<i64> = tx
.query_row(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
params![schema_name],
|row| row.get(0),
)
.optional()?;
match existing {
Some(id) => id,
None => tx.query_row(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, true, ?) RETURNING schema_id",
params![schema_name, schema_name, snapshot_id],
|row| row.get(0),
)?,
}
};
let table_id: i64 = {
let existing: Option<i64> = tx
.query_row(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
params![schema_id, table_name],
|row| row.get(0),
)
.optional()?;
match existing {
Some(id) => id,
None => tx.query_row(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, true, ?) RETURNING table_id",
params![schema_id, table_name, table_name, snapshot_id],
|row| row.get(0),
)?,
}
};
let existing_rows: Vec<(String, String, bool, i64)> = {
let mut stmt = tx.prepare(
"SELECT column_name, column_type, nulls_allowed, column_id
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)?;
let mapped = stmt.query_map(params![table_id], |row| {
let name: String = row.get(0)?;
let col_type: String = row.get(1)?;
let nullable: Option<bool> = row.get(2)?;
let cid: i64 = row.get(3)?;
Ok((name, col_type, nullable.unwrap_or(true), cid))
})?;
mapped.collect::<std::result::Result<Vec<_>, duckdb::Error>>()?
};
let mut existing_columns: Vec<(String, String, bool)> =
Vec::with_capacity(existing_rows.len());
let mut existing_ids: std::collections::HashMap<String, i64> =
std::collections::HashMap::new();
for (name, col_type, nullable, cid) in existing_rows {
existing_ids.insert(name.clone(), cid);
existing_columns.push((name, col_type, nullable));
}
if !existing_columns.is_empty() {
use std::collections::HashMap;
let existing_map: HashMap<&str, (&str, bool)> = existing_columns
.iter()
.map(|(name, col_type, nullable)| (name.as_str(), (col_type.as_str(), *nullable)))
.collect();
for new_col in columns.iter() {
if let Some((existing_type, _existing_nullable)) = existing_map.get(new_col.name())
{
if !crate::types::types_equal_canonical(existing_type, new_col.ducklake_type())
{
return Err(crate::error::DuckLakeError::UnsupportedTypeChange {
operation: TypeChangeOperation::DataWrite {
mode: match mode {
WriteMode::Replace => TypeChangeWriteMode::Replace,
WriteMode::Append => TypeChangeWriteMode::Append,
},
},
column: new_col.name().to_string(),
from: (*existing_type).to_string(),
to: new_col.ducklake_type().to_string(),
});
}
} else if mode == WriteMode::Append && !new_col.is_nullable() {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"Schema evolution error: new column '{}' must be nullable. Adding non-nullable columns is not allowed.",
new_col.name()
)));
}
}
}
let column_ids: Vec<i64> = columns
.iter()
.zip(fresh_ids.iter())
.map(|(col, &fresh)| existing_ids.get(col.name()).copied().unwrap_or(fresh))
.collect();
tx.commit()?;
Ok(WriteSetupResult {
snapshot_id,
base_snapshot_id,
schema_id,
table_id,
column_ids,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::DuckdbMetadataProvider;
use crate::metadata_provider::MetadataProvider;
use tempfile::TempDir;
#[test]
fn duckdb_write_then_read_back_via_provider() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("catalog.ducklake");
let db_path_str = db_path.to_str().unwrap().to_string();
let data_path = temp.path().join("data");
let data_path_str = data_path.to_str().unwrap().to_string();
{
let writer = DuckdbMetadataWriter::new_with_init(&db_path_str).unwrap();
writer.set_data_path(&data_path_str).unwrap();
let columns = vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
];
let setup = writer
.begin_write_transaction("main", "users", &columns, WriteMode::Append)
.unwrap();
assert_eq!(setup.column_ids.len(), 2);
assert_eq!(setup.base_snapshot_id, 0, "fresh catalog: no prior head");
let file = DataFileInfo::new("users/data-0.parquet", 1024, 3).with_footer_size(256);
let commit = writer
.register_data_file(
setup.table_id,
"main",
"users",
setup.snapshot_id,
&file,
WriteMode::Append,
setup.base_snapshot_id,
&columns,
&setup.column_ids,
)
.unwrap();
assert_eq!(commit.snapshot_id, 1, "first write commits snapshot 1");
}
let provider = DuckdbMetadataProvider::new(&db_path_str).unwrap();
let snapshot = provider.get_current_snapshot().unwrap();
assert_eq!(snapshot, 1, "committed head is snapshot 1");
assert_eq!(provider.get_data_path().unwrap(), data_path_str);
let schema = provider
.get_schema_by_name("main", snapshot)
.unwrap()
.expect("schema 'main' must exist");
let table = provider
.get_table_by_name(schema.schema_id, "users", snapshot)
.unwrap()
.expect("table 'users' must exist");
assert_eq!(table.table_name, "users");
let cols = provider
.get_table_structure(table.table_id, snapshot)
.unwrap();
assert_eq!(cols.len(), 2, "both columns must read back");
assert_eq!(cols[0].column_name, "id");
assert_eq!(cols[0].column_type, "int64");
assert!(!cols[0].is_nullable);
assert_eq!(cols[1].column_name, "name");
assert_eq!(cols[1].column_type, "varchar");
assert!(cols[1].is_nullable);
let files = provider
.get_table_files_for_select(table.table_id, snapshot)
.unwrap();
assert_eq!(files.len(), 1, "exactly one data file");
assert_eq!(files[0].file.path, "users/data-0.parquet");
assert!(files[0].file.path_is_relative);
assert_eq!(files[0].file.file_size_bytes, 1024);
assert_eq!(files[0].file.footer_size, Some(256));
assert_eq!(files[0].max_row_count, Some(3));
assert_eq!(
files[0].row_id_start,
Some(0),
"first file starts at rowid 0"
);
}
#[test]
fn duckdb_row_id_start_advances_across_appends() {
let temp = TempDir::new().unwrap();
let db_path = temp.path().join("catalog.ducklake");
let db_path_str = db_path.to_str().unwrap().to_string();
let writer = DuckdbMetadataWriter::new_with_init(&db_path_str).unwrap();
writer.set_data_path("/tmp/does-not-matter").unwrap();
let columns = vec![ColumnDef::new("id", "int64", false).unwrap()];
let setup1 = writer
.begin_write_transaction("main", "t", &columns, WriteMode::Append)
.unwrap();
writer
.register_data_file(
setup1.table_id,
"main",
"t",
setup1.snapshot_id,
&DataFileInfo::new("a.parquet", 100, 3),
WriteMode::Append,
setup1.base_snapshot_id,
&columns,
&setup1.column_ids,
)
.unwrap();
let setup2 = writer
.begin_write_transaction("main", "t", &columns, WriteMode::Append)
.unwrap();
let commit2 = writer
.register_data_file(
setup2.table_id,
"main",
"t",
setup2.snapshot_id,
&DataFileInfo::new("b.parquet", 250, 7),
WriteMode::Append,
setup2.base_snapshot_id,
&columns,
&setup2.column_ids,
)
.unwrap();
assert_eq!(commit2.snapshot_id, 2, "second write commits snapshot 2");
let mut conn = writer.connection();
let tx = conn.transaction().unwrap();
let a_start: i64 = tx
.query_row(
"SELECT row_id_start FROM ducklake_data_file WHERE path = 'a.parquet'",
[],
|r| r.get(0),
)
.unwrap();
let b_start: i64 = tx
.query_row(
"SELECT row_id_start FROM ducklake_data_file WHERE path = 'b.parquet'",
[],
|r| r.get(0),
)
.unwrap();
let (records, next, bytes): (i64, i64, i64) = tx
.query_row(
"SELECT record_count, next_row_id, file_size_bytes
FROM ducklake_table_stats WHERE table_id = ?",
params![setup1.table_id],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
)
.unwrap();
tx.commit().unwrap();
assert_eq!(a_start, 0, "first file starts at 0");
assert_eq!(b_start, 3, "second file starts after the first file's rows");
assert_eq!(records, 10, "record_count = 3 + 7");
assert_eq!(next, 10, "next_row_id advances by sum of record_counts");
assert_eq!(bytes, 350, "file_size_bytes accumulates");
}
}