use crate::Result;
use crate::error::{TypeChangeOperation, TypeChangeWriteMode};
use crate::metadata_provider::block_on;
use crate::metadata_writer::{
ColumnDef, ColumnStat, CommitIds, DataFileInfo, DeleteFileEntry, DeleteFileInfo,
MetadataWriter, WriteMode, WriteSetupResult, columns_differ, validate_delete_entries,
validate_name,
};
use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions};
const DEFAULT_MAX_CONNECTIONS: u32 = 5;
pub const DEFAULT_LOCK_TIMEOUT_MS: u32 = 30_000;
pub(crate) const SQL_CREATE_STANDARD_TABLES: &[&str] = &[
r#"CREATE TABLE IF NOT EXISTS ducklake_metadata (
key VARCHAR NOT NULL,
value VARCHAR NOT NULL,
scope VARCHAR
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_snapshot (
snapshot_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
snapshot_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
schema_version BIGINT NOT NULL DEFAULT 0
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_schema (
schema_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_table (
table_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_column (
column_id BIGINT GENERATED ALWAYS AS IDENTITY,
table_id BIGINT NOT NULL,
column_name VARCHAR NOT NULL,
column_type VARCHAR NOT NULL,
column_order BIGINT NOT NULL,
nulls_allowed BOOLEAN DEFAULT TRUE,
parent_column BIGINT,
begin_snapshot BIGINT NOT NULL,
end_snapshot BIGINT,
PRIMARY KEY (table_id, column_id, begin_snapshot)
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_data_file (
data_file_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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,
partial_max BIGINT
)"#,
r#"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
)"#,
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 VARCHAR,
max_value VARCHAR,
contains_nan BOOLEAN,
extra_stats VARCHAR
)"#,
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 VARCHAR,
max_value VARCHAR,
extra_stats VARCHAR
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_delete_file (
delete_file_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
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
)"#,
r#"ALTER TABLE ducklake_snapshot
ADD COLUMN IF NOT EXISTS schema_version BIGINT NOT NULL DEFAULT 0"#,
r#"ALTER TABLE ducklake_data_file
ADD COLUMN IF NOT EXISTS partial_max BIGINT"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_snapshot_changes (
snapshot_id BIGINT PRIMARY KEY,
changes_made VARCHAR NOT NULL,
author VARCHAR,
commit_message VARCHAR,
commit_extra_info VARCHAR
)"#,
];
pub(crate) const SQL_CREATE_MULTICATALOG_TABLES: &[&str] = &[
r#"CREATE TABLE IF NOT EXISTS ducklake_catalog (
catalog_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
catalog_name VARCHAR NOT NULL UNIQUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_catalog_snapshot_map (
catalog_id BIGINT NOT NULL,
snapshot_id BIGINT NOT NULL,
PRIMARY KEY (catalog_id, snapshot_id)
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_catalog_schema_map (
catalog_id BIGINT NOT NULL,
schema_id BIGINT NOT NULL,
PRIMARY KEY (catalog_id, schema_id)
)"#,
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)
)"#,
r#"CREATE TABLE IF NOT EXISTS ducklake_files_scheduled_for_deletion (
catalog_id BIGINT NOT NULL,
data_file_id BIGINT NOT NULL,
path VARCHAR NOT NULL,
path_is_relative BOOLEAN NOT NULL DEFAULT TRUE,
schedule_start TIMESTAMPTZ DEFAULT NOW()
)"#,
r#"CREATE INDEX IF NOT EXISTS idx_scheduled_for_deletion_catalog
ON ducklake_files_scheduled_for_deletion(catalog_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_catalog_snapshot_map_snapshot
ON ducklake_catalog_snapshot_map(snapshot_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_catalog_schema_map_schema
ON ducklake_catalog_schema_map(schema_id)"#,
r#"CREATE INDEX IF NOT EXISTS idx_schema_versions_table
ON ducklake_schema_versions(table_id, begin_snapshot)"#,
r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_active_table_per_schema
ON ducklake_table(schema_id, table_name) WHERE end_snapshot IS NULL"#,
r#"CREATE UNIQUE INDEX IF NOT EXISTS idx_ducklake_column_live
ON ducklake_column(table_id, column_id) WHERE end_snapshot IS NULL"#,
];
pub(crate) async fn execute_ddl_statements(pool: &PgPool, statements: &[&str]) -> Result<()> {
for stmt in statements {
sqlx::query(stmt).execute(pool).await?;
}
Ok(())
}
pub(crate) async fn migrate_ducklake_column_to_composite_pk(pool: &PgPool) -> Result<()> {
sqlx::query(
r#"DO $$
DECLARE pk_name text;
BEGIN
-- Find the PRIMARY KEY iff it is a single-column PK (the legacy shape).
SELECT conname INTO pk_name
FROM pg_constraint
WHERE conrelid = 'ducklake_column'::regclass
AND contype = 'p'
AND array_length(conkey, 1) = 1;
IF pk_name IS NOT NULL THEN
EXECUTE 'ALTER TABLE ducklake_column DROP CONSTRAINT ' || quote_ident(pk_name);
EXECUTE 'ALTER TABLE ducklake_column ADD PRIMARY KEY (table_id, column_id, begin_snapshot)';
END IF;
END $$;"#,
)
.execute(pool)
.await?;
Ok(())
}
#[derive(Debug, Clone)]
pub struct PostgresMetadataWriter {
pool: PgPool,
catalog_id: i64,
lock_timeout_ms: u32,
}
impl PostgresMetadataWriter {
pub async fn with_pool(pool: PgPool, catalog_id: i64) -> Result<Self> {
Ok(Self {
pool,
catalog_id,
lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
})
}
pub async fn new(connection_string: &str, catalog_id: i64) -> Result<Self> {
Self::with_max_connections(connection_string, catalog_id, DEFAULT_MAX_CONNECTIONS).await
}
pub async fn with_max_connections(
connection_string: &str,
catalog_id: i64,
max_connections: u32,
) -> Result<Self> {
let pool = PgPoolOptions::new()
.max_connections(max_connections)
.connect(connection_string)
.await?;
Ok(Self {
pool,
catalog_id,
lock_timeout_ms: DEFAULT_LOCK_TIMEOUT_MS,
})
}
pub fn with_lock_timeout(mut self, ms: u32) -> Self {
self.lock_timeout_ms = ms;
self
}
pub fn catalog_id(&self) -> i64 {
self.catalog_id
}
}
async fn lock_catalog(
catalog_id: i64,
lock_timeout_ms: u32,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
if lock_timeout_ms > 0 {
sqlx::query(&format!("SET LOCAL lock_timeout = {}", lock_timeout_ms))
.execute(&mut **tx)
.await?;
}
let row =
sqlx::query("SELECT catalog_id FROM ducklake_catalog WHERE catalog_id = $1 FOR UPDATE")
.bind(catalog_id)
.fetch_optional(&mut **tx)
.await?;
if row.is_none() {
return Err(crate::DuckLakeError::CatalogNotFound(format!(
"catalog_id {}",
catalog_id
)));
}
Ok(())
}
async fn assert_schema_in_catalog(
catalog_id: i64,
schema_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
let row = sqlx::query(
"SELECT 1 FROM ducklake_catalog_schema_map
WHERE catalog_id = $1 AND schema_id = $2",
)
.bind(catalog_id)
.bind(schema_id)
.fetch_optional(&mut **tx)
.await?;
if row.is_none() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"schema_id {} does not belong to catalog_id {}",
schema_id, catalog_id
)));
}
Ok(())
}
async fn assert_table_in_catalog(
catalog_id: i64,
table_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
let row = sqlx::query(
"SELECT m.catalog_id FROM ducklake_table t
LEFT JOIN ducklake_catalog_schema_map m ON m.schema_id = t.schema_id
WHERE t.table_id = $1",
)
.bind(table_id)
.fetch_optional(&mut **tx)
.await?;
match row {
None => Err(crate::DuckLakeError::TableNotFound(format!(
"table_id {}",
table_id
))),
Some(r) => {
let owner: Option<i64> = r.try_get(0)?;
if owner != Some(catalog_id) {
Err(crate::DuckLakeError::InvalidConfig(format!(
"table_id {} does not belong to catalog_id {}",
table_id, catalog_id
)))
} else {
Ok(())
}
},
}
}
async fn assert_table_not_in_other_catalog(
catalog_id: i64,
table_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
let row = sqlx::query(
"SELECT m.catalog_id FROM ducklake_table t
LEFT JOIN ducklake_catalog_schema_map m ON m.schema_id = t.schema_id
WHERE t.table_id = $1",
)
.bind(table_id)
.fetch_optional(&mut **tx)
.await?;
if let Some(r) = row {
let owner: Option<i64> = r.try_get(0)?;
if owner != Some(catalog_id) {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"table_id {} does not belong to catalog_id {}",
table_id, catalog_id
)));
}
}
Ok(())
}
async fn reserve_ids(
table: &str,
col: &str,
n: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<Vec<i64>> {
if n <= 0 {
return Ok(Vec::new());
}
let rows =
sqlx::query("SELECT nextval(pg_get_serial_sequence($1, $2)) FROM generate_series(1, $3)")
.bind(table)
.bind(col)
.bind(n)
.fetch_all(&mut **tx)
.await?;
rows.into_iter().map(|r| Ok(r.try_get(0)?)).collect()
}
async fn detect_replace_conflict(
table_id: i64,
base_snapshot: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
let conflict = sqlx::query(
"SELECT 1 WHERE EXISTS (SELECT 1 FROM ducklake_data_file
WHERE table_id = $1 AND (begin_snapshot > $2 OR end_snapshot > $2))
OR EXISTS (SELECT 1 FROM ducklake_column
WHERE table_id = $1 AND (begin_snapshot > $2 OR end_snapshot > $2))",
)
.bind(table_id)
.bind(base_snapshot)
.fetch_optional(&mut **tx)
.await?;
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(
table_id: i64,
snapshot_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = $1
WHERE table_id = $2 AND end_snapshot IS NULL AND begin_snapshot < $1",
)
.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 = $1",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
Ok(())
}
async fn advance_catalog_head(
catalog_id: i64,
snapshot_id: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<()> {
sqlx::query(
"INSERT INTO ducklake_catalog_snapshot_map (catalog_id, snapshot_id)
VALUES ($1, $2)
ON CONFLICT (catalog_id, snapshot_id) DO NOTHING",
)
.bind(catalog_id)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
Ok(())
}
const COMPACTION_RESOLVED_PATH: &str = "CASE
WHEN NOT df.path_is_relative THEN df.path
WHEN NOT t.path_is_relative THEN t.path || '/' || df.path
ELSE s.path || '/' || t.path || '/' || df.path
END";
const COMPACTION_REL_FLAG: &str =
"(df.path_is_relative AND t.path_is_relative AND s.path_is_relative)";
async fn schedule_compaction_files(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
catalog_id: i64,
rows: Vec<sqlx::postgres::PgRow>,
) -> Result<()> {
for row in rows {
let id: i64 = row.try_get(0)?;
let path: String = row.try_get(1)?;
let rel: bool = row.try_get(2)?;
sqlx::query(
"INSERT INTO ducklake_files_scheduled_for_deletion
(catalog_id, data_file_id, path, path_is_relative, schedule_start)
VALUES ($1, $2, $3, $4, NOW())",
)
.bind(catalog_id)
.bind(id)
.bind(&path)
.bind(rel)
.execute(&mut **tx)
.await?;
}
Ok(())
}
async fn insert_file_column_stats(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
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 ($1, $2, $3, $4, $5, $6, $7, $8, $9, 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::Postgres>,
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 = $1 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 = $1 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 = $1")
.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 ($1, $2, $3, $4, $5, $6, 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(())
}
#[allow(clippy::too_many_arguments)]
async fn finalize_snapshot(
catalog_id: i64,
schema_name: &str,
table_name: &str,
table_id_hint: i64,
columns: &[ColumnDef],
column_ids: &[i64],
mode: WriteMode,
base_snapshot: i64,
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
) -> Result<(i64, i64, i64)> {
let (schema_id, schema_was_created): (i64, bool) = {
let existing = sqlx::query(
"SELECT s.schema_id FROM ducklake_schema s
JOIN ducklake_catalog_schema_map m ON m.schema_id = s.schema_id
WHERE m.catalog_id = $1 AND s.schema_name = $2 AND s.end_snapshot IS NULL",
)
.bind(catalog_id)
.bind(schema_name)
.fetch_optional(&mut **tx)
.await?;
if let Some(row) = existing {
(row.try_get(0)?, false)
} else {
let id = reserve_ids("ducklake_schema", "schema_id", 1, tx).await?[0];
(id, true)
}
};
if mode == WriteMode::Replace && !schema_was_created {
let existing_table_id: Option<i64> = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = $1 AND table_name = $2 AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(table_name)
.fetch_optional(&mut **tx)
.await?
.map(|r| r.try_get(0))
.transpose()?;
if let Some(tid) = existing_table_id {
detect_replace_conflict(tid, base_snapshot, tx).await?;
}
}
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
if schema_was_created {
let scoped_schema_path = format!("cat_{catalog_id}/{schema_name}");
sqlx::query(
"INSERT INTO ducklake_schema
(schema_id, schema_name, path, path_is_relative, begin_snapshot)
OVERRIDING SYSTEM VALUE
VALUES ($1, $2, $3, TRUE, $4)",
)
.bind(schema_id)
.bind(schema_name)
.bind(&scoped_schema_path)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_catalog_schema_map (catalog_id, schema_id)
VALUES ($1, $2)",
)
.bind(catalog_id)
.bind(schema_id)
.execute(&mut **tx)
.await?;
}
let (table_id, table_was_created): (i64, bool) = {
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = $1 AND table_name = $2 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)?, false)
} else {
sqlx::query(
"INSERT INTO ducklake_table
(table_id, schema_id, table_name, path, path_is_relative, begin_snapshot)
OVERRIDING SYSTEM VALUE
VALUES ($1, $2, $3, $4, TRUE, $5)",
)
.bind(table_id_hint)
.bind(schema_id)
.bind(table_name)
.bind(table_name)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
(table_id_hint, true)
}
};
let existing_column_rows = sqlx::query(
"SELECT column_name, column_type, nulls_allowed, column_order, column_id
FROM ducklake_column
WHERE table_id = $1 AND end_snapshot IS NULL
ORDER BY column_order",
)
.bind(table_id)
.fetch_all(&mut **tx)
.await?;
let existing_columns: Vec<(String, String, bool)> = existing_column_rows
.iter()
.map(|row| {
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);
Ok::<_, sqlx::Error>((name, col_type, nullable))
})
.collect::<std::result::Result<_, _>>()?;
let mut current_by_name: std::collections::HashMap<String, (i64, i64, bool)> =
std::collections::HashMap::new();
for row in &existing_column_rows {
let name: String = row.try_get(0)?;
let nullable: bool = row.try_get::<Option<bool>, _>(2)?.unwrap_or(true);
let order: i64 = row.try_get(3)?;
let id: i64 = row.try_get(4)?;
current_by_name.insert(name, (id, order, nullable));
}
let is_ddl = table_was_created || columns_differ(&existing_columns, columns);
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1",
)
.bind(catalog_id)
.fetch_one(&mut **tx)
.await?
.try_get(0)?;
let new_schema_version = if is_ddl {
prev_max + 1
} else if prev_max == 0 {
1
} else {
prev_max
};
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(new_schema_version)
.bind(snapshot_id)
.execute(&mut **tx)
.await?;
{
use std::collections::HashSet;
let new_names: HashSet<&str> = columns.iter().map(|c| c.name.as_str()).collect();
for name in current_by_name.keys() {
if !new_names.contains(name.as_str()) {
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = $1
WHERE table_id = $2 AND column_name = $3 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.bind(name)
.execute(&mut **tx)
.await?;
}
}
for (order, (col, column_id)) in columns.iter().zip(column_ids.iter()).enumerate() {
match current_by_name.get(&col.name) {
Some(&(cur_id, cur_order, cur_nullable)) => {
if *column_id != cur_id {
return Err(crate::DuckLakeError::Conflict(format!(
"column '{}' of table {table_id} was created concurrently with a \
different field id ({cur_id}, staged {column_id}); aborting to avoid \
a NULL-filled read (retry the write)",
col.name
)));
}
if cur_order != order as i64 || cur_nullable != col.is_nullable {
sqlx::query(
"UPDATE ducklake_column SET column_order = $1, nulls_allowed = $2
WHERE table_id = $3 AND column_name = $4 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)
OVERRIDING SYSTEM VALUE
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.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 is_ddl {
sqlx::query(
"INSERT INTO ducklake_schema_versions (begin_snapshot, schema_version, table_id)
VALUES ($1, $2, $3)",
)
.bind(snapshot_id)
.bind(new_schema_version)
.bind(table_id)
.execute(&mut **tx)
.await?;
}
if mode == WriteMode::Replace {
sqlx::query(
"INSERT INTO ducklake_table_stats (table_id, record_count, next_row_id, file_size_bytes)
VALUES ($1, 0, 0, 0)
ON CONFLICT (table_id) DO NOTHING",
)
.bind(table_id)
.execute(&mut **tx)
.await?;
retire_prior_generation(table_id, snapshot_id, tx).await?;
}
Ok((snapshot_id, schema_id, table_id))
}
impl MetadataWriter for PostgresMetadataWriter {
fn create_snapshot(&self) -> Result<i64> {
block_on(async {
let mut tx = self.pool.begin().await?;
let row = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (CURRENT_TIMESTAMP, 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?;
let snapshot_id: i64 = row.try_get(0)?;
sqlx::query(
"INSERT INTO ducklake_catalog_snapshot_map (catalog_id, snapshot_id)
VALUES ($1, $2)",
)
.bind(self.catalog_id)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(snapshot_id)
})
}
fn promote_column_type(
&self,
table_id: i64,
column_name: &str,
new_ducklake_type: &str,
) -> Result<i64> {
crate::types::ducklake_to_arrow_type(new_ducklake_type)?;
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_in_catalog(self.catalog_id, table_id, &mut tx).await?;
let row = sqlx::query(
"SELECT column_id, column_type, column_order, nulls_allowed
FROM ducklake_column
WHERE table_id = $1 AND column_name = $2 AND end_snapshot IS NULL",
)
.bind(table_id)
.bind(column_name)
.fetch_optional(&mut *tx)
.await?
.ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"promote_column_type: no live column '{column_name}' in table {table_id}"
))
})?;
let column_id: i64 = row.try_get("column_id")?;
let cur_type: String = row.try_get("column_type")?;
let column_order: i64 = row.try_get("column_order")?;
let nulls_allowed: bool = row
.try_get::<Option<bool>, _>("nulls_allowed")?
.unwrap_or(true);
if crate::types::types_equal_canonical(&cur_type, new_ducklake_type) {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"promote_column_type: column '{column_name}' is already type '{cur_type}' (no change)"
)));
}
if !crate::types::is_promotable(&cur_type, new_ducklake_type) {
return Err(crate::DuckLakeError::UnsupportedTypeChange {
operation: TypeChangeOperation::PromoteColumnType,
column: column_name.to_string(),
from: cur_type,
to: new_ducklake_type.to_string(),
});
}
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
sqlx::query(
"INSERT INTO ducklake_catalog_snapshot_map (catalog_id, snapshot_id) VALUES ($1, $2)",
)
.bind(self.catalog_id)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1 AND s.snapshot_id <> $2",
)
.bind(self.catalog_id)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let new_schema_version = prev_max + 1;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(new_schema_version)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_schema_versions (begin_snapshot, schema_version, table_id)
VALUES ($1, $2, $3)",
)
.bind(snapshot_id)
.bind(new_schema_version)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = $1
WHERE table_id = $2 AND column_id = $3 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, begin_snapshot)
OVERRIDING SYSTEM VALUE
VALUES ($1, $2, $3, $4, $5, $6, $7)",
)
.bind(column_id)
.bind(table_id)
.bind(column_name)
.bind(new_ducklake_type)
.bind(column_order)
.bind(nulls_allowed)
.bind(snapshot_id)
.execute(&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?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
let existing = sqlx::query(
"SELECT s.schema_id FROM ducklake_schema s
JOIN ducklake_catalog_schema_map m ON m.schema_id = s.schema_id
WHERE m.catalog_id = $1 AND s.schema_name = $2 AND s.end_snapshot IS NULL",
)
.bind(self.catalog_id)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
let id: i64 = row.try_get(0)?;
tx.commit().await?;
return Ok((id, false));
}
let schema_path = path.unwrap_or(name);
let row = sqlx::query(
"INSERT INTO ducklake_schema (schema_name, path, path_is_relative, begin_snapshot)
VALUES ($1, $2, TRUE, $3) RETURNING schema_id",
)
.bind(name)
.bind(schema_path)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
let schema_id: i64 = row.try_get(0)?;
sqlx::query(
"INSERT INTO ducklake_catalog_schema_map (catalog_id, schema_id)
VALUES ($1, $2)",
)
.bind(self.catalog_id)
.bind(schema_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
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")?;
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_schema_in_catalog(self.catalog_id, schema_id, &mut tx).await?;
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = $1 AND table_name = $2 AND end_snapshot IS NULL",
)
.bind(schema_id)
.bind(name)
.fetch_optional(&mut *tx)
.await?;
if let Some(row) = existing {
let id: i64 = row.try_get(0)?;
tx.commit().await?;
return Ok((id, false));
}
let table_path = path.unwrap_or(name);
let row = sqlx::query(
"INSERT INTO ducklake_table (schema_id, table_name, path, path_is_relative, begin_snapshot)
VALUES ($1, $2, $3, TRUE, $4) RETURNING table_id",
)
.bind(schema_id)
.bind(name)
.bind(table_path)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
let id: i64 = row.try_get(0)?;
tx.commit().await?;
Ok((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(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_in_catalog(self.catalog_id, table_id, &mut tx).await?;
sqlx::query(
"UPDATE ducklake_column SET end_snapshot = $1
WHERE table_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let mut column_ids = Vec::with_capacity(columns.len());
for (order, col) in columns.iter().enumerate() {
let row = sqlx::query(
"INSERT INTO ducklake_column (table_id, column_name, column_type, column_order, nulls_allowed, begin_snapshot)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING column_id",
)
.bind(table_id)
.bind(&col.name)
.bind(&col.ducklake_type)
.bind(order as i64)
.bind(col.is_nullable)
.bind(snapshot_id)
.fetch_one(&mut *tx)
.await?;
column_ids.push(row.try_get(0)?);
}
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?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let (snapshot_id, schema_id, table_id) = finalize_snapshot(
self.catalog_id,
schema_name,
table_name,
table_id,
columns,
column_ids,
mode,
base_snapshot,
&mut tx,
)
.await?;
sqlx::query(
"INSERT INTO ducklake_table_stats (table_id, record_count, next_row_id, file_size_bytes)
VALUES ($1, 0, 0, 0)
ON CONFLICT (table_id) DO NOTHING",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let stats_row =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = $1")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let row_id_start: i64 = stats_row.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 ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING data_file_id",
)
.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)
.fetch_one(&mut *tx)
.await?;
let data_file_id: i64 = inserted.try_get(0)?;
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 + $1,
record_count = record_count + $2,
file_size_bytes = file_size_bytes + $3
WHERE table_id = $4",
)
.bind(file.record_count)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn register_existing_data_file(
&self,
schema_name: &str,
table_name: &str,
columns: &[ColumnDef],
column_ids: &[i64],
file: &DataFileInfo,
mode: WriteMode,
) -> Result<CommitIds> {
if columns.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"register_existing_data_file requires at least one column".to_string(),
));
}
if column_ids.len() != columns.len() {
return Err(crate::DuckLakeError::InvalidConfig(format!(
"register_existing_data_file: column_ids (len {}) must be 1:1 with columns (len {})",
column_ids.len(),
columns.len()
)));
}
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
let base_snapshot: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_catalog_snapshot_map
WHERE catalog_id = $1",
)
.bind(self.catalog_id)
.fetch_one(&mut *tx)
.await?;
let table_id_hint = reserve_ids("ducklake_table", "table_id", 1, &mut tx).await?[0];
let (snapshot_id, schema_id, table_id) = finalize_snapshot(
self.catalog_id,
schema_name,
table_name,
table_id_hint,
columns,
column_ids,
mode,
base_snapshot,
&mut tx,
)
.await?;
sqlx::query(
"INSERT INTO ducklake_table_stats (table_id, record_count, next_row_id, file_size_bytes)
VALUES ($1, 0, 0, 0)
ON CONFLICT (table_id) DO NOTHING",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let row_id_start: i64 = sqlx::query_scalar(
"SELECT next_row_id FROM ducklake_table_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
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 ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.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?;
sqlx::query(
"UPDATE ducklake_table_stats
SET next_row_id = next_row_id + $1,
record_count = record_count + $2,
file_size_bytes = file_size_bytes + $3
WHERE table_id = $4",
)
.bind(file.record_count)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn set_delete_file(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_snapshot_id: i64,
data_file_id: i64,
expected_prev_delete_file: Option<i64>,
base_snapshot: i64,
delete: &DeleteFileInfo,
) -> Result<CommitIds> {
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT data_file_id FROM ducklake_data_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"delete targets data file {data_file_id}, which was retired by a \
concurrent write since snapshot {base_snapshot}; retry against the \
new generation"
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"delete on data file {data_file_id} conflicts with a concurrent delete \
(expected live delete file {expected_prev_delete_file:?}, found \
{current_prev:?}); retry against the new generation"
)));
}
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1",
)
.bind(self.catalog_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(prev_max.max(1))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
if let Some(prev) = expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = $1
WHERE delete_file_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(data_file_id)
.bind(table_id)
.bind(&delete.path)
.bind(delete.path_is_relative)
.bind(delete.file_size_bytes)
.bind(delete.footer_size)
.bind(delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = $1")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
#[allow(clippy::too_many_arguments)]
fn register_data_file_with_deletes(
&self,
table_id: i64,
schema_name: &str,
table_name: &str,
_snapshot_id: i64,
file: &DataFileInfo,
deletes: &[DeleteFileEntry],
mode: WriteMode,
base_snapshot: i64,
columns: &[ColumnDef],
column_ids: &[i64],
) -> Result<CommitIds> {
validate_delete_entries(mode, deletes)?;
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let (snapshot_id, schema_id, table_id) = finalize_snapshot(
self.catalog_id,
schema_name,
table_name,
table_id,
columns,
column_ids,
mode,
base_snapshot,
&mut tx,
)
.await?;
sqlx::query(
"INSERT INTO ducklake_table_stats (table_id, record_count, next_row_id, file_size_bytes)
VALUES ($1, 0, 0, 0)
ON CONFLICT (table_id) DO NOTHING",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
let stats_row =
sqlx::query("SELECT next_row_id FROM ducklake_table_stats WHERE table_id = $1")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let row_id_start: i64 = stats_row.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 ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING data_file_id",
)
.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)
.fetch_one(&mut *tx)
.await?;
let data_file_id: i64 = inserted.try_get(0)?;
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 + $1,
record_count = record_count + $2,
file_size_bytes = file_size_bytes + $3
WHERE table_id = $4",
)
.bind(file.record_count)
.bind(file.record_count)
.bind(file.file_size_bytes)
.bind(table_id)
.execute(&mut *tx)
.await?;
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT data_file_id FROM ducklake_data_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: the file is no longer \
live as of the catalog's current head (retired since snapshot \
{base_snapshot}). This happens when another writer committed a \
Replace/compaction, OR when an earlier write in THIS session already \
advanced the catalog (the catalog pins its snapshot at creation and does \
not refresh). Re-open the catalog at the latest snapshot and retry.",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"UPDATE/DELETE on data file {} could not commit: its live delete file \
changed from {:?} to {current_prev:?} since snapshot {base_snapshot}. \
Another writer committed a delete on this file, OR an earlier \
UPDATE/DELETE in THIS session did (the catalog pins its snapshot at \
creation and does not refresh). Re-open the catalog at the latest \
snapshot and retry.",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(prev) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = $1
WHERE delete_file_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_positional_deletes(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
base_snapshot: i64,
deletes: &[DeleteFileEntry],
) -> Result<CommitIds> {
if deletes.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_positional_deletes requires at least one delete entry".to_string(),
));
}
validate_delete_entries(WriteMode::Append, deletes)?;
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1",
)
.bind(self.catalog_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(prev_max.max(1))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
for entry in deletes {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT data_file_id FROM ducklake_data_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: the file is no longer live as \
of the catalog's current head (retired since snapshot {base_snapshot}). \
This happens when another writer committed a Replace/compaction, OR when \
an earlier write in THIS session already advanced the catalog (the \
catalog pins its snapshot at creation and does not refresh). Re-open the \
catalog at the latest snapshot and retry.",
entry.data_file_id
)));
}
let current_prev: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(entry.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_prev != entry.expected_prev_delete_file {
return Err(crate::DuckLakeError::Conflict(format!(
"DELETE on data file {} could not commit: its live delete file changed \
from {:?} to {current_prev:?} since snapshot {base_snapshot}. Another \
writer committed a delete on this file, OR an earlier DELETE in THIS \
session did (the catalog pins its snapshot at creation and does not \
refresh). Re-open the catalog at the latest snapshot and retry.",
entry.data_file_id, entry.expected_prev_delete_file
)));
}
if let Some(prev) = entry.expected_prev_delete_file {
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = $1
WHERE delete_file_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(prev)
.execute(&mut *tx)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_delete_file
(data_file_id, table_id, path, path_is_relative, file_size_bytes,
footer_size, delete_count, begin_snapshot)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
)
.bind(entry.data_file_id)
.bind(table_id)
.bind(&entry.delete.path)
.bind(entry.delete.path_is_relative)
.bind(entry.delete.file_size_bytes)
.bind(entry.delete.footer_size)
.bind(entry.delete.delete_count)
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
}
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = $1")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_compaction(
&self,
table_id: i64,
base_snapshot: i64,
sources: &[crate::metadata_writer::CompactionSourceFile],
outputs: &[crate::metadata_writer::CompactionOutputFile],
retirement: crate::metadata_writer::SourceRetirement,
) -> Result<CommitIds> {
use crate::metadata_writer::SourceRetirement;
if sources.is_empty() {
return Err(crate::DuckLakeError::InvalidConfig(
"commit_compaction requires at least one source file".to_string(),
));
}
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1",
)
.bind(self.catalog_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(prev_max.max(1))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
for src in sources {
let target_live: Option<i64> = sqlx::query_scalar(
"SELECT data_file_id FROM ducklake_data_file
WHERE data_file_id = $1 AND table_id = $2 AND end_snapshot IS NULL",
)
.bind(src.data_file_id)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
if target_live.is_none() {
return Err(crate::DuckLakeError::Conflict(format!(
"compaction of table {table_id} could not commit: source data file {} is \
no longer live (retired by a concurrent Replace/compaction since snapshot \
{base_snapshot}). Re-open the catalog at the latest snapshot and re-plan.",
src.data_file_id
)));
}
let current_delete: Option<i64> = sqlx::query_scalar(
"SELECT delete_file_id FROM ducklake_delete_file
WHERE data_file_id = $1 AND end_snapshot IS NULL",
)
.bind(src.data_file_id)
.fetch_optional(&mut *tx)
.await?;
if current_delete != src.delete_file_id {
return Err(crate::DuckLakeError::Conflict(format!(
"compaction of table {table_id} could not commit: the live delete file of \
source data file {} changed from {:?} to {current_delete:?} since snapshot \
{base_snapshot} (a concurrent DELETE/UPDATE). Re-open the catalog at the \
latest snapshot and re-plan.",
src.data_file_id, src.delete_file_id
)));
}
}
let source_data_ids: Vec<i64> = sources.iter().map(|s| s.data_file_id).collect();
match retirement {
SourceRetirement::Remove => {
let dead_data = sqlx::query(&format!(
"SELECT df.data_file_id, {COMPACTION_RESOLVED_PATH} AS resolved_path,
{COMPACTION_REL_FLAG} AS rel
FROM ducklake_data_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE df.data_file_id = ANY($1)"
))
.bind(&source_data_ids)
.fetch_all(&mut *tx)
.await?;
schedule_compaction_files(&mut tx, self.catalog_id, dead_data).await?;
let dead_del = sqlx::query(&format!(
"SELECT df.delete_file_id, {COMPACTION_RESOLVED_PATH} AS resolved_path,
{COMPACTION_REL_FLAG} AS rel
FROM ducklake_delete_file df
JOIN ducklake_table t ON t.table_id = df.table_id
JOIN ducklake_schema s ON s.schema_id = t.schema_id
WHERE df.data_file_id = ANY($1)"
))
.bind(&source_data_ids)
.fetch_all(&mut *tx)
.await?;
schedule_compaction_files(&mut tx, self.catalog_id, dead_del).await?;
sqlx::query("DELETE FROM ducklake_delete_file WHERE data_file_id = ANY($1)")
.bind(&source_data_ids)
.execute(&mut *tx)
.await?;
sqlx::query("DELETE FROM ducklake_data_file WHERE data_file_id = ANY($1)")
.bind(&source_data_ids)
.execute(&mut *tx)
.await?;
sqlx::query(
"DELETE FROM ducklake_file_column_stats WHERE data_file_id = ANY($1)",
)
.bind(&source_data_ids)
.execute(&mut *tx)
.await?;
},
SourceRetirement::Retire => {
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = $1
WHERE data_file_id = ANY($2) AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(&source_data_ids)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = $1
WHERE data_file_id = ANY($2) AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(&source_data_ids)
.execute(&mut *tx)
.await?;
},
}
for out in outputs {
let begin = out.begin_snapshot.unwrap_or(snapshot_id);
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, partial_max)
VALUES ($1, $2, $3, $4, $5, $6, NULL, $7, $8) RETURNING data_file_id",
)
.bind(table_id)
.bind(&out.file.path)
.bind(out.file.path_is_relative)
.bind(out.file.file_size_bytes)
.bind(out.file.footer_size)
.bind(out.file.record_count)
.bind(begin)
.bind(out.partial_max)
.fetch_one(&mut *tx)
.await?;
let data_file_id: i64 = inserted.try_get(0)?;
insert_file_column_stats(&mut tx, table_id, data_file_id, &out.file.column_stats)
.await?;
}
sqlx::query(
"INSERT INTO ducklake_table_stats (table_id, record_count, next_row_id, file_size_bytes)
VALUES ($1, 0, 0, 0) ON CONFLICT (table_id) DO NOTHING",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_table_stats SET
record_count = (SELECT COALESCE(SUM(record_count), 0)
FROM ducklake_data_file
WHERE table_id = $1 AND end_snapshot IS NULL),
file_size_bytes = (SELECT COALESCE(SUM(file_size_bytes), 0)
FROM ducklake_data_file
WHERE table_id = $1 AND end_snapshot IS NULL)
WHERE table_id = $1",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"INSERT INTO ducklake_snapshot_changes (snapshot_id, changes_made, commit_message)
VALUES ($1, $2, $3)",
)
.bind(snapshot_id)
.bind(format!("compacted_table:{table_id}"))
.bind("datafusion compaction")
.execute(&mut *tx)
.await?;
let schema_id: i64 =
sqlx::query_scalar("SELECT schema_id FROM ducklake_table WHERE table_id = $1")
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(CommitIds {
snapshot_id,
schema_id,
table_id,
})
})
}
fn commit_truncate(
&self,
table_id: i64,
_schema_name: &str,
_table_name: &str,
_base_snapshot: i64,
) -> Result<u64> {
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_in_catalog(self.catalog_id, table_id, &mut tx).await?;
let has_live_data: Option<i64> = sqlx::query_scalar(
"SELECT data_file_id FROM ducklake_data_file
WHERE table_id = $1 AND end_snapshot IS NULL LIMIT 1",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
if has_live_data.is_none() {
return Ok(0);
}
let snapshot_id: i64 = sqlx::query(
"INSERT INTO ducklake_snapshot (snapshot_time, schema_version)
VALUES (NOW(), 0) RETURNING snapshot_id",
)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
let prev_max: i64 = sqlx::query(
"SELECT COALESCE(MAX(s.schema_version), 0) FROM ducklake_snapshot s
JOIN ducklake_catalog_snapshot_map m ON m.snapshot_id = s.snapshot_id
WHERE m.catalog_id = $1",
)
.bind(self.catalog_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
sqlx::query("UPDATE ducklake_snapshot SET schema_version = $1 WHERE snapshot_id = $2")
.bind(prev_max.max(1))
.bind(snapshot_id)
.execute(&mut *tx)
.await?;
let gross: Option<i64> = sqlx::query_scalar(
"SELECT COALESCE(record_count, 0) FROM ducklake_table_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_optional(&mut *tx)
.await?;
let deleted: i64 = sqlx::query_scalar(
"SELECT COALESCE(SUM(delete_count), 0)::BIGINT FROM ducklake_delete_file
WHERE table_id = $1 AND end_snapshot IS NULL",
)
.bind(table_id)
.fetch_one(&mut *tx)
.await?;
let live_rows = (gross.unwrap_or(0) - deleted).max(0) as u64;
sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = $1
WHERE table_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
sqlx::query(
"UPDATE ducklake_delete_file SET end_snapshot = $1
WHERE table_id = $2 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 = $1",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
tx.commit().await?;
Ok(live_rows)
})
}
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?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_not_in_other_catalog(self.catalog_id, table_id, &mut tx).await?;
let (snapshot_id, schema_id, table_id) = finalize_snapshot(
self.catalog_id,
schema_name,
table_name,
table_id,
columns,
column_ids,
mode,
base_snapshot,
&mut tx,
)
.await?;
advance_catalog_head(self.catalog_id, snapshot_id, &mut tx).await?;
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?;
lock_catalog(self.catalog_id, self.lock_timeout_ms, &mut tx).await?;
assert_table_in_catalog(self.catalog_id, table_id, &mut tx).await?;
let result = sqlx::query(
"UPDATE ducklake_data_file SET end_snapshot = $1
WHERE table_id = $2 AND end_snapshot IS NULL",
)
.bind(snapshot_id)
.bind(table_id)
.execute(&mut *tx)
.await?;
let n = result.rows_affected();
sqlx::query(
"UPDATE ducklake_table_stats
SET record_count = 0, file_size_bytes = 0
WHERE table_id = $1",
)
.bind(table_id)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(n)
})
}
fn get_data_path(&self) -> Result<String> {
block_on(async {
let row =
sqlx::query("SELECT value FROM ducklake_metadata WHERE key = $1 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 {
let mut tx = self.pool.begin().await?;
let existing: Option<String> = sqlx::query(
"SELECT value FROM ducklake_metadata
WHERE key = 'data_path' AND scope IS NULL FOR UPDATE",
)
.fetch_optional(&mut *tx)
.await?
.map(|r| r.try_get(0))
.transpose()?;
match existing {
Some(cur) if cur == path => {
tx.commit().await?;
return Ok(());
},
Some(cur) => {
return Err(crate::error::DuckLakeError::InvalidConfig(format!(
"data_path already set to {:?}, refusing to overwrite with {:?}",
cur, path
)));
},
None => {},
}
sqlx::query(
"INSERT INTO ducklake_metadata (key, value, scope)
VALUES ('data_path', $1, NULL)",
)
.bind(path)
.execute(&mut *tx)
.await?;
tx.commit().await?;
Ok(())
})
}
fn initialize_schema(&self) -> Result<()> {
block_on(async {
execute_ddl_statements(&self.pool, SQL_CREATE_STANDARD_TABLES).await?;
execute_ddl_statements(&self.pool, SQL_CREATE_MULTICATALOG_TABLES).await?;
migrate_ducklake_column_to_composite_pk(&self.pool).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(),
));
}
let catalog_id = self.catalog_id;
let lock_timeout_ms = self.lock_timeout_ms;
block_on(async {
let mut tx = self.pool.begin().await?;
lock_catalog(catalog_id, lock_timeout_ms, &mut tx).await?;
let schema_id: i64 = {
let existing = sqlx::query(
"SELECT s.schema_id FROM ducklake_schema s
JOIN ducklake_catalog_schema_map m ON m.schema_id = s.schema_id
WHERE m.catalog_id = $1 AND s.schema_name = $2 AND s.end_snapshot IS NULL",
)
.bind(catalog_id)
.bind(schema_name)
.fetch_optional(&mut *tx)
.await?;
match existing {
Some(row) => row.try_get(0)?,
None => reserve_ids("ducklake_schema", "schema_id", 1, &mut tx).await?[0],
}
};
let table_id: i64 = {
let existing = sqlx::query(
"SELECT table_id FROM ducklake_table
WHERE schema_id = $1 AND table_name = $2 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 {
reserve_ids("ducklake_table", "table_id", 1, &mut tx).await?[0]
}
};
let rows = sqlx::query(
"SELECT column_name, column_type, nulls_allowed, column_id
FROM ducklake_column
WHERE table_id = $1 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_map.get(new_col.name.as_str()) {
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.clone(),
from: (*existing_type).to_string(),
to: new_col.ducklake_type.clone(),
});
}
} 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 fresh_ids = reserve_ids(
"ducklake_column",
"column_id",
columns.len() as i64,
&mut tx,
)
.await?;
let column_ids: Vec<i64> = columns
.iter()
.zip(fresh_ids.iter())
.map(|(col, &fresh)| existing_ids.get(&col.name).copied().unwrap_or(fresh))
.collect();
let base_snapshot_id: i64 = sqlx::query(
"SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_catalog_snapshot_map
WHERE catalog_id = $1",
)
.bind(catalog_id)
.fetch_one(&mut *tx)
.await?
.try_get(0)?;
tx.commit().await?;
Ok(WriteSetupResult {
snapshot_id: 0,
base_snapshot_id,
schema_id,
table_id,
column_ids,
})
})
}
fn catalog_id(&self) -> Option<i64> {
Some(self.catalog_id)
}
fn supports_update(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use crate::metadata_writer::{ColumnDef, columns_differ};
#[test]
fn test_columns_differ_identical() {
let existing =
vec![("id".into(), "int64".into(), false), ("name".into(), "varchar".into(), true)];
let proposed = vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
];
assert!(!columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_added_column() {
let existing = vec![("id".into(), "int64".into(), false)];
let proposed = vec![
ColumnDef::new("id", "int64", false).unwrap(),
ColumnDef::new("name", "varchar", true).unwrap(),
];
assert!(columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_renamed_column() {
let existing = vec![("id".into(), "int64".into(), false)];
let proposed = vec![ColumnDef::new("user_id", "int64", false).unwrap()];
assert!(columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_type_change() {
let existing = vec![("id".into(), "int32".into(), false)];
let proposed = vec![ColumnDef::new("id", "varchar", false).unwrap()];
assert!(columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_forward_widening_is_a_change() {
let existing = vec![("id".into(), "int32".into(), false)];
let proposed = vec![ColumnDef::new("id", "int64", false).unwrap()];
assert!(columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_benign_promote_race_is_not_ddl() {
let existing = vec![("id".into(), "int64".into(), false)];
let proposed = vec![ColumnDef::new("id", "int32", false).unwrap()];
assert!(!columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_nullability_change() {
let existing = vec![("id".into(), "int64".into(), false)];
let proposed = vec![ColumnDef::new("id", "int64", true).unwrap()];
assert!(columns_differ(&existing, &proposed));
}
#[test]
fn test_columns_differ_alias_canonical() {
let existing = vec![("id".into(), "bigint".into(), false)];
let proposed = vec![ColumnDef::new("id", "int64", false).unwrap()];
assert!(!columns_differ(&existing, &proposed));
}
}