use crate::Result;
use crate::error::{TypeChangeOperation, TypeChangeWriteMode};
use crate::metadata_provider::block_on;
use crate::metadata_writer::{
ColumnDef, ColumnStat, CommitIds, DataFileInfo, ExistingCatalogColumn, MetadataWriter,
SnapshotCommitMetadata, WriteMode, WriteSetupResult, assign_column_ids, catalog_column_defs,
catalog_column_type_equal, catalog_column_type_requires_migration, catalog_columns_differ,
quote_snapshot_name, quote_snapshot_table, table_write_changes, top_level_column_ids,
validate_name,
};
use crate::partition::PartitionTransform;
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_snapshot_changes (
snapshot_id BIGINT NOT NULL PRIMARY KEY,
changes_made TEXT,
author TEXT,
commit_message TEXT,
commit_extra_info TEXT
) 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,
partition_id 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"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_partition_info (
partition_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_partition_column (
partition_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
partition_key_index BIGINT NOT NULL,
column_id BIGINT NOT NULL,
transform VARCHAR(1024) NOT NULL
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_file_partition_value (
data_file_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
partition_key_index BIGINT NOT NULL,
partition_value TEXT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_sort_info (
sort_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT
) ENGINE = InnoDB"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_sort_expression (
sort_id BIGINT NOT NULL,
table_id BIGINT NOT NULL,
sort_key_index BIGINT NOT NULL,
expression VARCHAR(1024) NOT NULL,
dialect VARCHAR(256) NOT NULL,
sort_direction VARCHAR(16) NOT NULL,
null_order VARCHAR(16) NOT NULL
) 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: &'static 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?;
sqlx::query(
"INSERT INTO ducklake_snapshot_changes (snapshot_id, changes_made)
VALUES (?, NULL)",
)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok((snapshot_id, schema_version))
}
async fn record_snapshot_changes(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
snapshot_id: i64,
changes_made: &str,
commit_metadata: &SnapshotCommitMetadata,
) -> Result<()> {
let changes_made = (!changes_made.is_empty()).then_some(changes_made);
sqlx::query(
"UPDATE ducklake_snapshot_changes
SET changes_made = CASE
WHEN changes_made IS NULL THEN ?
WHEN ? IS NULL THEN changes_made
ELSE CONCAT(changes_made, ',', ?)
END,
author = ?,
commit_message = ?,
commit_extra_info = ?
WHERE snapshot_id = ?",
)
.bind(changes_made)
.bind(changes_made)
.bind(changes_made)
.bind(commit_metadata.author())
.bind(commit_metadata.message())
.bind(commit_metadata.extra_info())
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn record_table_write_changes(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
snapshot_id: i64,
table_id: i64,
schema_name: &str,
table_name: &str,
mode: WriteMode,
commit_metadata: &SnapshotCommitMetadata,
) -> Result<()> {
let row = sqlx::query(
"SELECT s.begin_snapshot AS schema_begin_snapshot,
t.begin_snapshot AS table_begin_snapshot
FROM ducklake_table t
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE t.table_id = ?",
)
.bind(table_id)
.fetch_one(&mut **tx)
.await?;
let schema_begin_snapshot: i64 = row.try_get("schema_begin_snapshot")?;
let table_begin_snapshot: i64 = row.try_get("table_begin_snapshot")?;
let altered: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM ducklake_schema_versions
WHERE table_id = ? AND begin_snapshot = ?
)",
)
.bind(table_id)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
let replaced_existing_data: bool = sqlx::query_scalar(
"SELECT EXISTS(
SELECT 1 FROM ducklake_data_file
WHERE table_id = ? AND end_snapshot = ?
)",
)
.bind(table_id)
.bind(snapshot_id)
.fetch_one(&mut **tx)
.await?;
let mut changes = Vec::new();
if schema_begin_snapshot == snapshot_id {
changes.push(format!(
"created_schema:{}",
quote_snapshot_name(schema_name)
));
}
if table_begin_snapshot == snapshot_id {
changes.push(format!(
"created_table:{}",
quote_snapshot_table(schema_name, table_name)
));
} else if altered {
changes.push(format!("altered_table:{table_id}"));
}
changes.push(table_write_changes(
table_id,
mode,
false,
replaced_existing_data,
));
record_snapshot_changes(tx, snapshot_id, &changes.join(","), commit_metadata).await
}
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 insert_partition_metadata(
tx: &mut sqlx::Transaction<'_, sqlx::MySql>,
table_id: i64,
data_file_id: i64,
file: &DataFileInfo,
) -> Result<()> {
if let Some(partition_id) = file.partition_id {
sqlx::query("UPDATE ducklake_data_file SET partition_id = ? WHERE data_file_id = ?")
.bind(partition_id)
.bind(data_file_id)
.execute(&mut **tx)
.await?;
}
for (key_index, value) in &file.partition_values {
sqlx::query(
"INSERT INTO ducklake_file_partition_value
(data_file_id, table_id, partition_key_index, partition_value)
VALUES (?, ?, ?, ?)",
)
.bind(data_file_id)
.bind(table_id)
.bind(i64::from(*key_index))
.bind(value.clone())
.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 catalog_columns = catalog_column_defs(columns)?;
let column_ids = top_level_column_ids(&catalog_columns, column_ids)?;
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 proposed = catalog_column_defs(columns)?;
if proposed.len() != column_ids.len() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"column_ids has {} entries for {} catalog column nodes",
column_ids.len(),
proposed.len()
)));
}
let current = sqlx::query(
"SELECT column_id, column_name, column_type, column_order, nulls_allowed, parent_column
FROM ducklake_column
WHERE table_id = ? AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
let existing_catalog_columns = current
.iter()
.map(|row| {
Ok::<_, sqlx::Error>(ExistingCatalogColumn {
column_id: row.try_get("column_id")?,
name: row.try_get("column_name")?,
ducklake_type: row.try_get("column_type")?,
parent_column: row.try_get("parent_column")?,
})
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let existing_nullability = current
.iter()
.map(|row| {
Ok::<_, sqlx::Error>(
row.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true),
)
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let committed_ids = assign_column_ids(&proposed, &existing_catalog_columns, column_ids)?;
if committed_ids != column_ids {
return Err(crate::DuckLakeError::Conflict(
"table columns were created concurrently with different field ids; retry the write"
.to_string(),
));
}
let is_ddl = current.is_empty()
|| catalog_columns_differ(
&existing_catalog_columns,
&existing_nullability,
&proposed,
column_ids,
);
if is_ddl {
schema_version = bump_schema_version(tx, snapshot_id).await?;
}
let proposed_ids = column_ids.iter().copied().collect::<HashSet<_>>();
let mut current_by_id: HashMap<i64, (i64, bool, String)> = HashMap::new();
for row in ¤t {
let column_id: i64 = row.try_get("column_id")?;
let order: i64 = row.try_get("column_order")?;
let nullable: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
let ducklake_type: String = row.try_get("column_type")?;
if !proposed_ids.contains(&column_id) {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
}
current_by_id.insert(column_id, (order, nullable, ducklake_type));
}
for (order, (column, column_id)) in proposed.iter().zip(column_ids).enumerate() {
let parent_id = column.parent_index.map(|index| column_ids[index]);
match current_by_id.get(column_id) {
Some((cur_order, cur_nullable, cur_type)) => {
let migrate_type = catalog_column_type_requires_migration(cur_type, column);
if migrate_type {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
} else if *cur_order != order as i64 || *cur_nullable != column.is_nullable {
sqlx::query(
"UPDATE ducklake_column
SET column_order = ?, nulls_allowed = ?
WHERE table_id = ? AND column_id = ? AND end_snapshot IS NULL",
)
.bind(order as i64)
.bind(column.is_nullable)
.bind(table_id)
.bind(column_id)
.execute(&mut **tx)
.await?;
}
},
None => {
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.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 mut tx = self.pool.begin().await?;
let existing = sqlx::query(
"SELECT schema_id FROM ducklake_schema
WHERE schema_name = ? AND end_snapshot IS NULL",
)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
tx.commit().await?;
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(&mut *tx)
.await?;
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!("created_schema:{}", quote_snapshot_name(name)),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().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 mut tx = self.pool.begin().await?;
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(&mut *tx)
.await?;
if let Some(row) = existing {
tx.commit().await?;
return Ok((row.try_get(0)?, false));
}
let schema_name: String =
sqlx::query_scalar("SELECT schema_name FROM ducklake_schema WHERE schema_id = ?")
.bind(schema_id)
.fetch_one(&mut *tx)
.await?;
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(&mut *tx)
.await?;
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!("created_table:{}", quote_snapshot_table(&schema_name, name)),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().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 catalog_columns = catalog_column_defs(columns)?;
let n = catalog_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 field_ids = (first_column_id..=last_column_id).collect::<Vec<_>>();
for (order, (column, column_id)) in
catalog_columns.iter().zip(field_ids.iter()).enumerate()
{
let parent_id = column.parent_index.map(|index| field_ids[index]);
sqlx::query(
"INSERT INTO ducklake_column
(column_id, table_id, column_name, column_type, column_order,
nulls_allowed, parent_column, begin_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(column_id)
.bind(table_id)
.bind(&column.name)
.bind(&column.ducklake_type)
.bind(order as i64)
.bind(column.is_nullable)
.bind(parent_id)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
let table_begin_snapshot: i64 =
sqlx::query_scalar("SELECT begin_snapshot FROM ducklake_table WHERE table_id = ?")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
if table_begin_snapshot != snapshot_id {
record_snapshot_changes(
&mut tx,
snapshot_id,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
}
tx.commit().await?;
top_level_column_ids(&catalog_columns, &field_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> {
self.register_data_file_with_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
file,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
fn register_data_file_with_commit_metadata(
&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],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(crate::DuckLakeError::InvalidConfig(
"conditional writes are not supported by the MySQL metadata writer".to_string(),
));
}
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 live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
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?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).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?;
record_table_write_changes(
&mut tx,
snapshot_id,
table_id,
schema_name,
table_name,
mode,
commit_metadata,
)
.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,
})
})
}
#[allow(clippy::too_many_arguments)]
fn register_data_files(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
self.register_data_files_with_commit_metadata(
table_id,
schema_name,
table_name,
snapshot_id,
files,
mode,
base_snapshot,
columns,
column_ids,
&SnapshotCommitMetadata::default(),
None,
)
}
fn register_data_files_with_commit_metadata(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
files: &[DataFileInfo],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
commit_metadata: &SnapshotCommitMetadata,
expected_base_snapshot_id: Option<i64>,
) -> Result<CommitIds> {
if expected_base_snapshot_id.is_some() {
return Err(crate::DuckLakeError::InvalidConfig(
"conditional multi-file writes are not supported by the MySQL metadata writer"
.to_string(),
));
}
if files.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"register_data_files: files must be non-empty".to_string(),
));
}
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 live_partition_id: Option<i64> = sqlx::query_scalar(
"SELECT partition_id FROM ducklake_partition_info
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
for file in files {
crate::metadata_writer::enforce_partition_fence(table_id, live_partition_id, file)?;
}
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 mut next_row_id: 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 mut total_records: i64 = 0;
let mut total_bytes: i64 = 0;
for file in files {
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(next_row_id)
.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?;
insert_partition_metadata(&mut tx, table_id, data_file_id, file).await?;
next_row_id += file.record_count;
total_records += file.record_count;
total_bytes += file.file_size_bytes;
}
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(total_records)
.bind(total_records)
.bind(total_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
record_table_write_changes(
&mut tx,
snapshot_id,
table_id,
schema_name,
table_name,
mode,
commit_metadata,
)
.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 set_partition_spec(
&self,
table_id: i64,
columns: &[(String, PartitionTransform)],
) -> Result<i64> {
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"set_partition_spec: partition spec must have at least one column; \
use reset_partition_spec to remove partitioning"
.to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let partition_id = reserve_ids(&mut tx, "next_partition_id", 1).await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let mut column_ids: Vec<i64> = Vec::with_capacity(columns.len());
for (name, _transform) in columns {
let column_id: i64 = sqlx::query_scalar(
"SELECT column_id FROM ducklake_column
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL
AND parent_column IS NULL",
)
.bind(table_id)
.bind(name)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"set_partition_spec: no live column '{name}' in table {table_id}"
))
})?;
column_ids.push(column_id);
}
sqlx::query(
"UPDATE ducklake_partition_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_partition_info
(partition_id, table_id, begin_snapshot, end_snapshot)
VALUES (?, ?, ?, NULL)",
)
.bind(partition_id)
.bind(table_id)
.bind(new_snapshot)
.execute(&mut *tx)
.await?;
for (key_index, column_id) in column_ids.iter().enumerate() {
sqlx::query(
"INSERT INTO ducklake_partition_column
(partition_id, table_id, partition_key_index, column_id, transform)
VALUES (?, ?, ?, ?, ?)",
)
.bind(partition_id)
.bind(table_id)
.bind(key_index as i64)
.bind(*column_id)
.bind(columns[key_index].1.to_catalog_string())
.execute(&mut *tx)
.await?;
}
let new_schema_version = bump_schema_version(&mut tx, new_snapshot).await?;
record_schema_version(&mut tx, new_snapshot, new_schema_version, table_id).await?;
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn live_partition_spec(
&self,
table_id: i64,
) -> Result<Option<crate::partition::PartitionSpec>> {
block_on(async {
let rows = sqlx::query(
"SELECT pi.partition_id, pc.partition_key_index, pc.column_id, pc.transform
FROM ducklake_partition_info AS pi
JOIN ducklake_partition_column AS pc
ON pc.partition_id = pi.partition_id AND pc.table_id = pi.table_id
WHERE pi.table_id = ? AND pi.end_snapshot IS NULL
ORDER BY pc.partition_key_index",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let parsed = rows
.iter()
.map(|row| {
Ok::<_, crate::DuckLakeError>((
row.try_get::<i64, _>(0)?,
i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0),
row.try_get::<i64, _>(2)?,
row.try_get::<String, _>(3)?,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(crate::partition::PartitionSpec::from_rows(parsed, false))
})
}
fn reset_partition_spec(&self, table_id: i64) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let ended = sqlx::query(
"UPDATE ducklake_partition_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?
.rows_affected();
if ended == 0 {
drop(tx);
let head: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.fetch_one(&self.pool)
.await?;
return Ok(head);
}
let new_schema_version = bump_schema_version(&mut tx, new_snapshot).await?;
record_schema_version(&mut tx, new_snapshot, new_schema_version, table_id).await?;
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn live_sort_spec(&self, table_id: i64) -> Result<Option<crate::sort::SortSpec>> {
block_on(async {
let rows = sqlx::query(
"SELECT si.sort_id, se.sort_key_index, se.expression, se.dialect,
se.sort_direction, se.null_order
FROM ducklake_sort_info AS si
JOIN ducklake_sort_expression AS se
ON se.sort_id = si.sort_id AND se.table_id = si.table_id
WHERE si.table_id = ? AND si.end_snapshot IS NULL
ORDER BY se.sort_key_index",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let parsed = rows
.iter()
.map(|row| {
Ok::<_, crate::DuckLakeError>((
row.try_get::<i64, _>(0)?,
i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0),
row.try_get::<String, _>(2)?,
row.try_get::<String, _>(3)?,
row.try_get::<String, _>(4)?,
row.try_get::<String, _>(5)?,
))
})
.collect::<Result<Vec<_>>>()?;
Ok(crate::sort::SortSpec::from_rows(parsed))
})
}
fn set_sort_spec(&self, table_id: i64, fields: &[crate::sort::SortField]) -> Result<i64> {
if fields.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"set_sort_spec: at least one sort key is required (use reset_sort_spec to clear)"
.to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
let sort_id = reserve_ids(&mut tx, "next_sort_id", 1).await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
for field in fields {
let column = field.column_candidate().ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"set_sort_spec: sort key '{}' is not a bare column; only column \
sort keys are supported",
field.expression
))
})?;
let exists: Option<i64> = sqlx::query_scalar(
"SELECT column_id FROM ducklake_column
WHERE table_id = ? AND column_name = ? AND end_snapshot IS NULL
AND parent_column IS NULL",
)
.bind(table_id)
.bind(&column)
.fetch_optional(&mut *tx)
.await?;
if exists.is_none() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"set_sort_spec: no live column '{column}' in table {table_id}"
)));
}
}
sqlx::query(
"UPDATE ducklake_sort_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_sort_info
(sort_id, table_id, begin_snapshot, end_snapshot)
VALUES (?, ?, ?, NULL)",
)
.bind(sort_id)
.bind(table_id)
.bind(new_snapshot)
.execute(&mut *tx)
.await?;
for field in fields {
sqlx::query(
"INSERT INTO ducklake_sort_expression
(sort_id, table_id, sort_key_index, expression, dialect,
sort_direction, null_order)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(sort_id)
.bind(table_id)
.bind(field.sort_key_index as i64)
.bind(&field.expression)
.bind(&field.dialect)
.bind(field.direction.to_catalog_string())
.bind(field.null_order.to_catalog_string())
.execute(&mut *tx)
.await?;
}
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
fn reset_sort_spec(&self, table_id: i64) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let (new_snapshot, _carried) = insert_snapshot(&mut tx).await?;
let ended = sqlx::query(
"UPDATE ducklake_sort_info SET end_snapshot = ?
WHERE table_id = ? AND end_snapshot IS NULL",
)
.bind(new_snapshot)
.bind(table_id)
.execute(&mut *tx)
.await?
.rows_affected();
if ended == 0 {
drop(tx);
let head: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot",
)
.fetch_one(&self.pool)
.await?;
return Ok(head);
}
record_snapshot_changes(
&mut tx,
new_snapshot,
&format!("altered_table:{table_id}"),
&SnapshotCommitMetadata::default(),
)
.await?;
tx.commit().await?;
Ok(new_snapshot)
})
}
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?;
record_table_write_changes(
&mut tx,
snapshot_id,
table_id,
schema_name,
table_name,
mode,
&SnapshotCommitMetadata::default(),
)
.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?;
}
let has_partition_id: i64 = sqlx::query_scalar(
"SELECT COUNT(*) FROM information_schema.columns \
WHERE table_schema = DATABASE() \
AND table_name = 'ducklake_data_file' \
AND column_name = 'partition_id'",
)
.fetch_one(&self.pool)
.await?;
if has_partition_id == 0 {
sqlx::query("ALTER TABLE ducklake_data_file ADD COLUMN partition_id BIGINT")
.execute(&self.pool)
.await?;
}
let changes_nullable: String = sqlx::query_scalar(
"SELECT is_nullable FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'ducklake_snapshot_changes'
AND column_name = 'changes_made'",
)
.fetch_one(&self.pool)
.await?;
if changes_nullable == "NO" {
sqlx::query("ALTER TABLE ducklake_snapshot_changes MODIFY changes_made TEXT NULL")
.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?;
seed_counter(
&self.pool,
"next_partition_id",
"SELECT COALESCE(MAX(partition_id), 0) FROM ducklake_partition_info",
)
.await?;
seed_counter(
&self.pool,
"next_sort_id",
"SELECT COALESCE(MAX(sort_id), 0) FROM ducklake_sort_info",
)
.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 catalog_columns = catalog_column_defs(columns)?;
let n = catalog_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, column_id, parent_column
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_catalog_columns = Vec::with_capacity(rows.len());
for row in rows {
let name: String = row.try_get(0)?;
let ducklake_type: String = row.try_get(1)?;
let column_id: i64 = row.try_get(2)?;
let parent_column: Option<i64> = row.try_get(3)?;
existing_catalog_columns.push(ExistingCatalogColumn {
column_id,
name,
ducklake_type,
parent_column,
});
}
let field_ids =
assign_column_ids(&catalog_columns, &existing_catalog_columns, &fresh_ids)?;
if !existing_catalog_columns.is_empty() {
use std::collections::HashMap;
let existing_map: HashMap<i64, &ExistingCatalogColumn> = existing_catalog_columns
.iter()
.map(|column| (column.column_id, column))
.collect();
for (new_column, column_id) in catalog_columns.iter().zip(&field_ids) {
if let Some(existing_column) = existing_map.get(column_id) {
let same_type =
catalog_column_type_equal(&existing_column.ducklake_type, new_column);
if !same_type {
return Err(crate::error::DuckLakeError::UnsupportedTypeChange {
operation: TypeChangeOperation::DataWrite {
mode: match mode {
WriteMode::Replace => TypeChangeWriteMode::Replace,
WriteMode::Append => TypeChangeWriteMode::Append,
},
},
column: new_column.name.clone(),
from: existing_column.ducklake_type.clone(),
to: new_column.ducklake_type.clone(),
});
}
} else if mode == WriteMode::Append
&& new_column.parent_index.is_none()
&& !new_column.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_column.name
)));
}
}
}
tx.commit().await?;
Ok(WriteSetupResult {
snapshot_id,
base_snapshot_id,
schema_id,
table_id,
column_ids: top_level_column_ids(&catalog_columns, &field_ids)?,
field_ids,
})
})
}
}