use crate::Result;
use crate::error::{TypeChangeOperation, TypeChangeWriteMode};
use crate::metadata_provider::block_on;
use crate::metadata_writer::{
ColumnDef, ColumnStat, CommitIds, DataFileInfo, MetadataWriter, WriteMode, WriteSetupResult,
columns_differ, validate_name,
};
use sqlx::Row;
use sqlx::mysql::{MySqlPool, MySqlPoolOptions};
const DEFAULT_MAX_CONNECTIONS: u32 = 5;
const SQL_CREATE_TABLES: &[&str] = &[
r#"CREATE TABLE IF NOT EXISTS ducklake_metadata (
`key` VARCHAR(1024) NOT NULL,
`value` TEXT NOT NULL,
scope VARCHAR(1024)
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_snapshot (
snapshot_id BIGINT NOT NULL PRIMARY KEY,
snapshot_time DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6),
schema_version BIGINT NOT NULL DEFAULT 0
) ENGINE = InnoDB"#,
r#"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)
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_schema (
schema_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
schema_name VARCHAR(1024) NOT NULL,
path TEXT NOT NULL,
path_is_relative TINYINT(1) NOT NULL DEFAULT 1,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_table (
table_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
schema_id BIGINT NOT NULL,
table_name VARCHAR(1024) NOT NULL,
path TEXT NOT NULL,
path_is_relative TINYINT(1) NOT NULL DEFAULT 1,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"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(1024),
column_type VARCHAR(1024),
initial_default TEXT,
default_value TEXT,
nulls_allowed TINYINT(1),
parent_column BIGINT,
default_value_type VARCHAR(1024),
default_value_dialect VARCHAR(1024)
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_data_file (
data_file_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
table_id BIGINT NOT NULL,
path TEXT NOT NULL,
path_is_relative TINYINT(1) NOT NULL DEFAULT 1,
file_size_bytes BIGINT NOT NULL,
footer_size BIGINT,
encryption_key VARCHAR(1024),
record_count BIGINT,
row_id_start BIGINT,
mapping_id BIGINT,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_table_stats (
table_id BIGINT NOT NULL 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
) ENGINE = InnoDB"#,
r#"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 TEXT,
max_value TEXT,
contains_nan BOOLEAN,
extra_stats TEXT
) ENGINE = InnoDB"#,
r#"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 TEXT,
max_value TEXT,
extra_stats TEXT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_delete_file (
delete_file_id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY,
data_file_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
path TEXT NOT NULL,
path_is_relative TINYINT(1) NOT NULL DEFAULT 1,
file_size_bytes BIGINT NOT NULL,
footer_size BIGINT,
encryption_key VARCHAR(1024),
delete_count BIGINT,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_files_scheduled_for_deletion (
data_file_id BIGINT NOT NULL,
path TEXT NOT NULL,
path_is_relative TINYINT(1) NOT NULL DEFAULT 1,
schedule_start DATETIME(6) DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE = InnoDB"#,
];
#[derive(Debug, Clone)]
pub struct MySqlMetadataWriter {
pool: MySqlPool,
}
impl MySqlMetadataWriter {
pub async fn new(connection_string: &str) -> Result<Self> {
Self::with_max_connections(connection_string, DEFAULT_MAX_CONNECTIONS).await
}
pub async fn with_max_connections(
connection_string: &str,
max_connections: u32,
) -> Result<Self> {
let pool = MySqlPoolOptions::new()
.max_connections(max_connections)
.connect(connection_string)
.await?;
Ok(Self {
pool,
})
}
pub async fn new_with_init(connection_string: &str) -> Result<Self> {
let writer = Self::new(connection_string).await?;
writer.initialize_schema()?;
Ok(writer)
}
}
async fn reserve_ids(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
key: &str,
n: i64,
) -> Result<i64> {
sqlx::query(
"UPDATE ducklake_metadata
SET `value` = CAST(CAST(`value` AS SIGNED) + ? AS CHAR)
WHERE `key` = ? AND scope IS NULL",
)
.bind(n)
.bind(key)
.execute(&mut **tx)
.await?;
let last: i64 = sqlx::query(
"SELECT CAST(`value` AS SIGNED) FROM ducklake_metadata WHERE `key` = ? AND scope IS NULL",
)
.bind(key)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
Ok(last)
}
async fn seed_counter(pool: &MySqlPool, key: &str, max_sql: &str) -> Result<()> {
let exists: i64 =
sqlx::query("SELECT COUNT(*) FROM ducklake_metadata WHERE `key` = ? AND scope IS NULL")
.bind(key)
.fetch_one(pool)
.await?
.try_get(0)?;
if exists == 0 {
let start: i64 = sqlx::query(max_sql).fetch_one(pool).await?.try_get(0)?;
sqlx::query("INSERT INTO ducklake_metadata (`key`, `value`, scope) VALUES (?, ?, NULL)")
.bind(key)
.bind(start.to_string())
.execute(pool)
.await?;
}
Ok(())
}
async fn detect_replace_conflict(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
base_snapshot: i64,
) -> Result<()> {
let conflict: Option<i64> = sqlx::query(
"SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND (begin_snapshot > ? OR end_snapshot > ?)
LIMIT 1",
)
.bind(table_id)
.bind(base_snapshot)
.bind(base_snapshot)
.fetch_optional(&mut **tx)
.await?
.map(|row| row.try_get(0))
.transpose()?;
if conflict.is_some() {
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(())
}
async fn retire_prior_generation(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
snapshot_id: i64,
) -> Result<()> {
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL AND begin_snapshot < ?",
)
.bind(snapshot_id)
.bind(table_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats SET record_count = 0, file_size_bytes = 0 WHERE table_id = ?",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_snapshot(tx: &mut sqlx::Transaction<'_, sqlx::MySql>) -> Result<(i64, i64)> {
let snapshot_id = reserve_ids(tx, "next_snapshot_id", 1).await?;
let schema_version: i64 =
sqlx::query("SELECT COALESCE(MAX(schema_version), 0) FROM ducklake_snapshot")
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_id, snapshot_time, schema_version)
VALUES (?, NOW(6), ?)",
)
.bind(snapshot_id)
.bind(schema_version)
.execute(&mut **tx)
.await?;
Ok((snapshot_id, schema_version))
}
async fn bump_schema_version(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
snapshot_id: i64,
) -> Result<i64> {
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(schema_version), 0) FROM ducklake_snapshot WHERE snapshot_id <> ?",
)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
let new_version = prev_max + 1;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = ? WHERE snapshot_id = ?")
.bind(new_version)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok(new_version)
}
async fn record_schema_version(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
snapshot_id: i64,
schema_version: i64,
table_id: i64,
) -> Result<()> {
sqlx::query(
"INSERT INTO ducklake_schema_versions (begin_snapshot, schema_version, table_id)
VALUES (?, ?, ?)",
)
.bind(snapshot_id)
.bind(schema_version)
.bind(table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn insert_file_column_stats(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
data_file_id: i64,
column_stats: &[ColumnStat],
) -> Result<()> {
for stat in column_stats {
sqlx::query(
"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)",
)
.bind(data_file_id)
.bind(table_id)
.bind(stat.column_id)
.bind(stat.column_size_bytes)
.bind(stat.value_count)
.bind(stat.null_count)
.bind(stat.min_value.as_deref())
.bind(stat.max_value.as_deref())
.bind(stat.contains_nan)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn recompute_table_column_stats(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<()> {
use crate::stats_encode::{FileColumnStat, aggregate_global_column_stats};
let live_file_count: i64 = sqlx::query(
"SELECT COUNT(*) FROM ducklake_data_file WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
let mut per_file: Vec<FileColumnStat> = Vec::new();
for row in sqlx::query(
"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",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?
{
per_file.push(FileColumnStat {
column_id: row.try_get(0)?,
min_value: row.try_get(1)?,
max_value: row.try_get(2)?,
null_count: row.try_get(3)?,
contains_nan: row.try_get(4)?,
});
}
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);
sqlx::query("DELETE FROM ducklake_table_column_stats WHERE table_id = ?")
.bind(table_id)
.execute(&mut **tx)
.await?;
for g in globals {
sqlx::query(
"INSERT INTO ducklake_table_column_stats
(table_id, column_id, contains_null, contains_nan, min_value, max_value, extra_stats)
VALUES (?, ?, ?, ?, ?, ?, NULL)",
)
.bind(table_id)
.bind(g.column_id)
.bind(g.contains_null)
.bind(g.contains_nan)
.bind(g.min_value)
.bind(g.max_value)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn finalize_snapshot(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
columns: &[ColumnDef],
column_ids: &[i64],
mode: WriteMode,
base_snapshot: i64,
) -> Result<i64> {
let (snapshot_id, mut schema_version) = insert_snapshot(tx).await?;
use std::collections::{HashMap, HashSet};
let current = sqlx::query(
"SELECT column_name, column_type, column_order, nulls_allowed
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
let mut existing: Vec<(String, String, bool)> = Vec::with_capacity(current.len());
for row in ¤t {
let name: String = row.try_get("column_name")?;
let ty: String = row.try_get("column_type")?;
let nullable: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
existing.push((name, ty, nullable));
}
let is_ddl = existing.is_empty() || columns_differ(&existing, columns);
if is_ddl {
schema_version = bump_schema_version(tx, snapshot_id).await?;
}
let new_names: HashSet<&str> = columns.iter().map(|c| c.name()).collect();
let mut current_by_name: HashMap<String, (i64, bool)> = HashMap::new();
for row in ¤t {
let name: String = row.try_get("column_name")?;
let order: i64 = row.try_get("column_order")?;
let nullable: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
if !new_names.contains(name.as_str()) {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(&name)
.execute(&mut **tx)
.await?;
}
current_by_name.insert(name, (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() {
sqlx::query(
"UPDATE ducklake_column SET column_order = ?, nulls_allowed = ?
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL",
)
.bind(order as i64)
.bind(col.is_nullable())
.bind(table_id)
.bind(col.name())
.execute(&mut **tx)
.await?;
}
},
None => {
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(col.name())
.bind(col.ducklake_type())
.bind(order as i64)
.bind(col.is_nullable())
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
},
}
}
if mode == WriteMode::Replace {
detect_replace_conflict(tx, table_id, base_snapshot).await?;
sqlx::query(
"INSERT IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
retire_prior_generation(tx, table_id, snapshot_id).await?;
}
if is_ddl {
record_schema_version(tx, snapshot_id, schema_version, table_id).await?;
}
Ok(snapshot_id)
}
impl MetadataWriter for MySqlMetadataWriter {
fn create_snapshot(&self) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (snapshot_id, _schema_version) = insert_snapshot(&mut tx).await?;
tx.commit().await?;
Ok(snapshot_id)
})
}
fn get_or_create_schema(
&self,
name: &str,
path: Option<&str>,
snapshot_id: i64,
) -> Result<(i64, bool)> {
validate_name(name, "Schema")?;
block_on(async {
let existing = sqlx::query(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
)
.bind(name)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = existing {
return Ok((row.try_get(0)?, false));
}
let schema_path = path.unwrap_or(name);
let result = sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, 1, ?)",
)
.bind(name)
.bind(schema_path)
.bind(snapshot_id)
.execute(&self.pool)
.await?;
Ok((result.last_insert_id() as i64, 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")?;
block_on(async {
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(name)
.fetch_optional(&self.pool)
.await?;
if let Some(row) = existing {
return Ok((row.try_get(0)?, false));
}
let table_path = path.unwrap_or(name);
let result = sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, 1, ?)",
)
.bind(schema_id)
.bind(name)
.bind(table_path)
.bind(snapshot_id)
.execute(&self.pool)
.await?;
Ok((result.last_insert_id() as i64, 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(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let n = columns.len() as i64;
let last_column_id = reserve_ids(&mut tx, "next_column_id", n).await?;
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;
sqlx::query(
"INSERT INTO ducklake_column (column_id, table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(col.name())
.bind(col.ducklake_type())
.bind(order as i64)
.bind(col.is_nullable())
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
column_ids.push(column_id);
}
tx.commit().await?;
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> {
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id =
finalize_snapshot(&mut tx, table_id, columns, column_ids, mode, base_snapshot)
.await?;
sqlx::query(
"INSERT IGNORE INTO ducklake_table_stats
(table_id, record_count, next_row_id, file_size_bytes)
VALUES (?, 0, 0, 0)",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let row_id_start: i64 =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let inserted = sqlx::query(
"INSERT INTO ducklake_data_file
(table_id, path, path_is_relative, file_size_bytes,
footer_size, record_count, row_id_start, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(table_id)
.bind(&file.path)
.bind(file.path_is_relative)
.bind(file.file_size_bytes)
.bind(file.footer_size)
.bind(file.record_count)
.bind(row_id_start)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let data_file_id = inserted.last_insert_id() as i64;
insert_file_column_stats(&mut tx, table_id, data_file_id, &file.column_stats).await?;
recompute_table_column_stats(&mut tx, table_id, columns, column_ids).await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + ?,
record_count = record_count + ?,
file_size_bytes = file_size_bytes + ?
WHERE table_id = ?",
)
.bind(file.record_count)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
let schema_id: i64 =
sqlx::query("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
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> {
block_on(async {
let mut tx = self.pool.begin().await?;
let snapshot_id =
finalize_snapshot(&mut tx, table_id, columns, column_ids, mode, base_snapshot)
.await?;
let schema_id: i64 =
sqlx::query("SELECT schema_id FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn end_table_files(&self, table_id: i64, snapshot_id: i64) -> Result<u64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let result = sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats
SET record_count = 0, file_size_bytes = 0
WHERE table_id = ?",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(result.rows_affected())
})
}
fn get_data_path(&self) -> Result<String> {
block_on(async {
let row = sqlx::query(
"SELECT `value` FROM ducklake_metadata WHERE `key` = ? AND scope IS NULL",
)
.bind("data_path")
.fetch_optional(&self.pool)
.await?;
match row {
Some(r) => Ok(r.try_get(0)?),
None => Err(crate::error::DuckLakeError::InvalidConfig(
"Missing required catalog metadata: 'data_path' not configured.".to_string(),
)),
}
})
}
fn set_data_path(&self, path: &str) -> Result<()> {
block_on(async {
sqlx::query(
"DELETE FROM ducklake_metadata WHERE `key` = 'data_path' AND scope IS NULL",
)
.execute(&self.pool)
.await?;
sqlx::query(
"INSERT INTO ducklake_metadata (`key`, `value`, scope)
VALUES ('data_path', ?, NULL)",
)
.bind(path)
.execute(&self.pool)
.await?;
Ok(())
})
}
fn initialize_schema(&self) -> Result<()> {
block_on(async {
for ddl in SQL_CREATE_TABLES {
sqlx::query(ddl).execute(&self.pool).await?;
}
seed_counter(
&self.pool,
"next_column_id",
"SELECT COALESCE(MAX(column_id), 0) FROM ducklake_column",
)
.await?;
seed_counter(
&self.pool,
"next_snapshot_id",
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.await?;
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(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let n = columns.len() as i64;
let last_column_id = reserve_ids(&mut tx, "next_column_id", n).await?;
let fresh_ids: Vec<i64> = ((last_column_id - n + 1)..=last_column_id).collect();
let base_snapshot_id: i64 =
sqlx::query("SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot")
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let snapshot_id: i64 = base_snapshot_id + 1;
let schema_id: i64 = {
let existing = sqlx::query(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
)
.bind(schema_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
row.try_get(0)?
} else {
let result = sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, 1, ?)",
)
.bind(schema_name)
.bind(schema_name)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
result.last_insert_id() as i64
}
};
let table_id: i64 = {
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = ? AND table_name = ? AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(table_name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
row.try_get(0)?
} else {
let result = sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES (?, ?, ?, 1, ?)",
)
.bind(schema_id)
.bind(table_name)
.bind(table_name)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
result.last_insert_id() as i64
}
};
let rows = sqlx::query(
"SELECT column_name, column_type, nulls_allowed, column_id
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut *tx)
.await?;
let mut existing_columns: Vec<(String, String, bool)> = Vec::with_capacity(rows.len());
let mut existing_ids: std::collections::HashMap<String, i64> =
std::collections::HashMap::new();
for row in rows {
let name: String = row.try_get(0)?;
let col_type: String = row.try_get(1)?;
let nullable: bool = row.try_get::<Option<bool>, _>(2)?.unwrap_or(true);
let cid: i64 = row.try_get(3)?;
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().await?;
Ok(WriteSetupResult {
snapshot_id,
base_snapshot_id,
schema_id,
table_id,
column_ids,
})
})
}
}