use crate::Result;
use crate::inlined_filter::{
InlinedDataScan, InlinedFilter, InlinedSqlBind, InlinedSqlDialect, render_inlined_filter,
};
use crate::metadata_provider::{
ColumnWithTable, DataFileChange, DeleteFileChange, DuckLakeFileColumnStatistics,
DuckLakeFileData, DuckLakeFileMetadata, DuckLakeInlinedData, DuckLakeInlinedDelete,
DuckLakeNameMapping, DuckLakeNameMappingEntry, DuckLakeStatistics, DuckLakeTableColumn,
DuckLakeTableColumnStatistics, DuckLakeTableField, DuckLakeTableFile, DuckLakeTableStatistics,
FileWithTable, InlinedDataBackend, MetadataProvider, MetadataSetting, SchemaMetadata,
SnapshotChangeMetadata, SnapshotMetadata, TableMetadata, TableWithSchema, ViewMetadata,
ViewWithSchema, block_on, inlined_delete_table_name, inlined_text_projection,
is_inlined_data_table, parse_inlined_rows_with_present, reconstruct_columns,
reconstruct_columns_with_table, resolve_metadata_settings,
};
use crate::partition::PartitionSpec;
use crate::sort::SortSpec;
use crate::stats_encode::{is_canonical_date, is_canonical_timestamp, is_canonical_timestamptz};
use crate::stats_filter::{RenderedColumnFilter, StatsFilter, StatsLiteral, StatsSqlDialect};
use arrow::datatypes::{DataType, SchemaRef};
use arrow::record_batch::RecordBatch;
use sqlx::AssertSqlSafe;
use sqlx::Row;
use sqlx::postgres::{PgPool, PgPoolOptions, PgRow};
use sqlx::types::chrono::NaiveDateTime;
use std::collections::{HashMap, HashSet};
use std::sync::{Arc, OnceLock};
fn is_missing_statistics_table(error: &sqlx::Error) -> bool {
let message = error.to_string().to_ascii_lowercase();
message.contains("does not exist") || message.contains("undefined table")
}
fn quote_ident(name: &str) -> String {
format!("\"{}\"", name.replace('"', "\"\""))
}
fn decode_view(row: &PgRow) -> Result<ViewMetadata> {
Ok(ViewMetadata {
view_id: row.try_get(0)?,
schema_id: row.try_get(1)?,
begin_snapshot: row.try_get(2)?,
view_name: row.try_get(3)?,
dialect: row.try_get(4)?,
sql: row.try_get(5)?,
column_aliases: row.try_get(6)?,
})
}
fn decode_table_file(row: &PgRow, snapshot_id: i64) -> Result<DuckLakeTableFile> {
let delete_file_id: Option<i64> = row.try_get(8)?;
let (delete_file, delete_count) = if delete_file_id.is_some() {
(
Some(DuckLakeFileData {
path: row.try_get(9)?,
path_is_relative: row.try_get(10)?,
file_size_bytes: row.try_get(11)?,
footer_size: row.try_get(12)?,
encryption_key: row.try_get(13)?,
mapping_id: None,
}),
row.try_get(14)?,
)
} else {
(None, None)
};
Ok(DuckLakeTableFile {
data_file_id: row.try_get(0)?,
file: DuckLakeFileData {
path: row.try_get(1)?,
path_is_relative: row.try_get(2)?,
file_size_bytes: row.try_get(3)?,
footer_size: row.try_get(4)?,
encryption_key: row.try_get(5)?,
mapping_id: row.try_get(19).unwrap_or(None),
},
delete_file_id,
delete_file,
row_id_start: row.try_get(6)?,
snapshot_id: Some(snapshot_id),
begin_snapshot: row.try_get(15)?,
schema_version: row.try_get(17)?,
partial_max: row.try_get(16)?,
max_row_count: row.try_get(7)?,
delete_count,
partition_id: row.try_get(18).unwrap_or(None),
partition_values: Vec::new(),
})
}
fn decode_name_mapping_rows(
requested_mapping_id: i64,
rows: &[PgRow],
) -> Result<DuckLakeNameMapping> {
let first = rows.first().ok_or_else(|| {
crate::DuckLakeError::InvalidConfig(format!(
"DuckLake name mapping {requested_mapping_id} does not exist"
))
})?;
let mut entries = Vec::new();
for row in rows {
if let Some(column_id) = row.try_get::<Option<i64>, _>(3)? {
entries.push(DuckLakeNameMappingEntry {
column_id,
source_name: row.try_get(4)?,
target_field_id: row.try_get(5)?,
parent_column: row.try_get(6)?,
is_partition: row.try_get::<Option<bool>, _>(7)?.unwrap_or(false),
});
}
}
Ok(DuckLakeNameMapping {
mapping_id: first.try_get(0)?,
table_id: first.try_get(1)?,
mapping_type: first.try_get(2)?,
entries,
})
}
macro_rules! bind_repeat {
($query:expr, $value:expr, 1) => {
$query.bind($value)
};
($query:expr, $value:expr, 2) => {
$query.bind($value).bind($value)
};
($query:expr, $value:expr, 3) => {
$query.bind($value).bind($value).bind($value)
};
($query:expr, $value:expr, 4) => {
$query.bind($value).bind($value).bind($value).bind($value)
};
($query:expr, $value:expr, 6) => {
$query
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
};
($query:expr, $value:expr, 8) => {
$query
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
.bind($value)
};
}
#[derive(Debug, Clone, Copy)]
struct SchemaCapabilities {
data_file_partial_max: bool,
delete_file_partial_max: bool,
schema_versions: bool,
data_file_partition_id: bool,
inlined_data_tables: bool,
views: bool,
soft_input_validation: bool,
}
impl SchemaCapabilities {
fn all(&self) -> bool {
self.data_file_partial_max
&& self.delete_file_partial_max
&& self.schema_versions
&& self.data_file_partition_id
&& self.inlined_data_tables
&& self.views
}
}
pub(crate) async fn fetch_data_file_page(
pool: &PgPool,
sql: &str,
table_id: i64,
snapshot_id: i64,
after_data_file_id: i64,
limit: i64,
) -> std::result::Result<Vec<PgRow>, sqlx::Error> {
sqlx::query(AssertSqlSafe(sql))
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(after_data_file_id)
.bind(limit)
.fetch_all(pool)
.await
}
pub(crate) struct PostgresStatsDialect {
pub(crate) soft_input_validation: bool,
}
impl StatsSqlDialect for PostgresStatsDialect {
fn try_cast(&self, expr: &str, literal: &StatsLiteral, data_type: &DataType) -> Option<String> {
if let Some(pattern) = postgres_canonical_temporal_pattern(data_type, literal.text()) {
let text = self.collate_binary(expr);
return Some(format!("CASE WHEN {text} ~ '{pattern}' THEN {text} END"));
}
let _ = literal;
let target = postgres_cast_type(data_type)?;
let pattern = postgres_castable_pattern(target)?;
let shape = format!("({expr} COLLATE \"C\") ~ '{pattern}'");
let guard = if self.soft_input_validation {
format!("{shape} AND pg_input_is_valid({expr}, '{target}')")
} else {
shape
};
Some(format!(
"CASE WHEN {guard} THEN CAST({expr} AS {target}) END"
))
}
fn collate_binary(&self, expr: &str) -> String {
format!("({expr} COLLATE \"C\")")
}
fn boolean_is_not_false(&self, expr: &str) -> String {
format!("{expr} IS NULL OR {expr} <> false")
}
}
pub(crate) fn postgres_cast_type(data_type: &DataType) -> Option<&'static str> {
Some(match data_type {
DataType::Boolean => "boolean",
DataType::Int8
| DataType::Int16
| DataType::Int32
| DataType::Int64
| DataType::UInt8
| DataType::UInt16
| DataType::UInt32
| DataType::UInt64
| DataType::Decimal128(_, _) => "numeric",
DataType::Float16 | DataType::Float32 | DataType::Float64 => "double precision",
_ => return None,
})
}
pub(crate) fn postgres_castable_pattern(target: &str) -> Option<&'static str> {
Some(match target {
"boolean" => r"^(true|false)$",
"numeric" => r"^-?[0-9]{1,255}(\.[0-9]{1,255})?$",
"double precision" => {
r"^-?(inf|[0-9]{1,255}(\.[0-9]{1,255})?|[0-9](\.[0-9]{1,255})?e[-+][0-9]{1,2})$"
},
_ => return None,
})
}
fn postgres_canonical_temporal_pattern(
data_type: &DataType,
constant: &str,
) -> Option<&'static str> {
const DATE: &str = r"^[0-9]{4}-[0-9]{2}-[0-9]{2}$";
const TIMESTAMP: &str =
r"^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]*[1-9])?$";
const TIMESTAMPTZ: &str =
r"^[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}([.][0-9]*[1-9])?[+]00$";
match data_type {
DataType::Date32 | DataType::Date64 if is_canonical_date(constant) => Some(DATE),
DataType::Timestamp(_, None) if is_canonical_timestamp(constant) => Some(TIMESTAMP),
DataType::Timestamp(_, Some(_)) if is_canonical_timestamptz(constant) => Some(TIMESTAMPTZ),
_ => None,
}
}
pub(crate) struct StatsFilterSql {
pub(crate) with_prefix: String,
pub(crate) joins: String,
pub(crate) conditions: String,
}
pub(crate) fn stats_filter_sql(
table_id: i64,
filters: &[RenderedColumnFilter],
) -> Option<StatsFilterSql> {
if filters.is_empty() {
return None;
}
let ctes = filters
.iter()
.map(|filter| {
format!(
"{alias} AS (
SELECT data_file_id, {stats}
FROM ducklake_file_column_stats
WHERE column_id = {column_id} AND table_id = {table_id}
)",
alias = filter.alias,
stats = filter.stats.join(", "),
column_id = filter.column_id,
)
})
.collect::<Vec<_>>()
.join(",\n ");
let joins = filters
.iter()
.map(|filter| {
format!(
"\n LEFT JOIN {alias} ON {alias}.data_file_id = data.data_file_id",
alias = filter.alias
)
})
.collect::<String>();
let conditions = filters
.iter()
.map(|filter| {
format!(
"\n AND {condition}",
condition = filter.condition
)
})
.collect::<String>();
Some(StatsFilterSql {
with_prefix: format!("WITH {ctes}\n "),
joins,
conditions,
})
}
#[derive(Debug, Clone)]
pub struct PostgresMetadataProvider {
pub pool: PgPool,
schema_capabilities: Arc<OnceLock<SchemaCapabilities>>,
}
impl PostgresMetadataProvider {
pub async fn new(connection_string: &str) -> Result<Self> {
let pool = PgPoolOptions::new()
.max_connections(5)
.connect(connection_string)
.await?;
Ok(Self {
pool,
schema_capabilities: Arc::new(OnceLock::new()),
})
}
pub fn from_pool(pool: PgPool) -> Self {
Self {
pool,
schema_capabilities: Arc::new(OnceLock::new()),
}
}
#[doc(hidden)]
pub fn schema_capabilities_cached(&self) -> bool {
self.schema_capabilities.get().is_some()
}
async fn schema_capabilities(&self) -> Result<SchemaCapabilities> {
if let Some(caps) = self.schema_capabilities.get() {
return Ok(*caps);
}
let row: (bool, bool, bool, bool, bool, bool, bool) = sqlx::query_as(
"SELECT
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'ducklake_data_file' AND column_name = 'partial_max'),
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'ducklake_delete_file' AND column_name = 'partial_max'),
to_regclass('ducklake_schema_versions') IS NOT NULL,
EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'ducklake_data_file' AND column_name = 'partition_id'),
to_regclass('ducklake_inlined_data_tables') IS NOT NULL,
to_regclass('ducklake_view') IS NOT NULL,
to_regprocedure('pg_input_is_valid(text,text)') IS NOT NULL",
)
.fetch_one(&self.pool)
.await?;
let caps = SchemaCapabilities {
data_file_partial_max: row.0,
delete_file_partial_max: row.1,
schema_versions: row.2,
data_file_partition_id: row.3,
inlined_data_tables: row.4,
views: row.5,
soft_input_validation: row.6,
};
if caps.all() {
let _ = self.schema_capabilities.set(caps);
}
Ok(caps)
}
fn file_metadata_page(
&self,
table_id: i64,
snapshot_id: i64,
after_data_file_id: Option<i64>,
limit: usize,
filter: Option<&StatsFilter>,
) -> Result<Vec<DuckLakeFileMetadata>> {
if limit == 0 {
return Ok(Vec::new());
}
let limit = i64::try_from(limit).map_err(|_| {
crate::DuckLakeError::InvalidConfig("file metadata page limit exceeds i64".to_string())
})?;
block_on(async {
let caps = self.schema_capabilities().await?;
let partial_max_expr = if caps.data_file_partial_max {
"data.partial_max::bigint"
} else {
"NULL::bigint"
};
let schema_version_expr = if caps.schema_versions {
"(SELECT sv.schema_version::bigint
FROM ducklake_schema_versions sv
WHERE sv.table_id = data.table_id
AND sv.begin_snapshot <= data.begin_snapshot
ORDER BY sv.begin_snapshot DESC LIMIT 1)"
} else {
"NULL::bigint"
};
let dialect = PostgresStatsDialect {
soft_input_validation: caps.soft_input_validation,
};
let rendered = filter.and_then(|filter| filter.render(&dialect));
let stats_sql = rendered
.as_deref()
.and_then(|filters| stats_filter_sql(table_id, filters));
let listing_sql = |stats_sql: Option<&StatsFilterSql>| {
let (with_prefix, joins, conditions) = stats_sql
.map(|sql| {
(
sql.with_prefix.as_str(),
sql.joins.as_str(),
sql.conditions.as_str(),
)
})
.unwrap_or_default();
format!(
"{with_prefix}SELECT data.data_file_id, data.path, data.path_is_relative,
data.file_size_bytes, data.footer_size, data.encryption_key,
data.row_id_start, data.record_count,
del.delete_file_id, del.path, del.path_is_relative,
del.file_size_bytes, del.footer_size, del.encryption_key,
del.delete_count, data.begin_snapshot::bigint,
{partial_max_expr}, {schema_version_expr},
NULL::bigint AS data_partition_id,
data.mapping_id::bigint
FROM ducklake_data_file AS data
LEFT JOIN ducklake_delete_file AS del
ON data.data_file_id = del.data_file_id
AND del.table_id = $1
AND $2 >= del.begin_snapshot
AND ($3 < del.end_snapshot OR del.end_snapshot IS NULL){joins}
WHERE data.table_id = $4
AND $5 >= data.begin_snapshot
AND ($6 < data.end_snapshot OR data.end_snapshot IS NULL)
AND data.data_file_id > $7{conditions}
ORDER BY data.data_file_id
LIMIT $8"
)
};
let after = after_data_file_id.unwrap_or(i64::MIN);
let rows = match fetch_data_file_page(
&self.pool,
&listing_sql(stats_sql.as_ref()),
table_id,
snapshot_id,
after,
limit,
)
.await
{
Ok(rows) => rows,
Err(error) if stats_sql.is_some() => {
tracing::debug!(
%error,
table_id,
"statistics-filtered file listing failed; listing every file"
);
fetch_data_file_page(
&self.pool,
&listing_sql(None),
table_id,
snapshot_id,
after,
limit,
)
.await?
},
Err(error) => return Err(error.into()),
};
let files = rows
.iter()
.map(|row| decode_table_file(row, snapshot_id))
.collect::<Result<Vec<_>>>()?;
let Some(last_data_file_id) = files.last().map(|file| file.data_file_id) else {
return Ok(Vec::new());
};
let page_ids: Option<Vec<i64>> = stats_sql
.as_ref()
.map(|_| files.iter().map(|file| file.data_file_id).collect());
let page_id_filter = |column: &str, parameter: usize| {
page_ids.as_ref().map_or_else(String::new, |_| {
format!("\n AND {column} = ANY(${parameter}::bigint[])")
})
};
let statistics_sql = format!(
"SELECT stats.data_file_id, stats.column_id,
stats.column_size_bytes, stats.value_count, stats.null_count,
stats.min_value, stats.max_value, stats.contains_nan
FROM ducklake_file_column_stats AS stats
INNER JOIN ducklake_data_file AS data
ON data.data_file_id = stats.data_file_id
AND data.table_id = stats.table_id
WHERE stats.table_id = $1
AND $2 >= data.begin_snapshot
AND ($3 < data.end_snapshot OR data.end_snapshot IS NULL)
AND stats.data_file_id > $4
AND stats.data_file_id <= $5{}
ORDER BY stats.data_file_id, stats.column_id",
page_id_filter("stats.data_file_id", 6)
);
let mut statistics_query = sqlx::query(AssertSqlSafe(statistics_sql))
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(after_data_file_id.unwrap_or(i64::MIN))
.bind(last_data_file_id);
if let Some(ids) = page_ids.as_deref() {
statistics_query = statistics_query.bind(ids);
}
let statistics = match statistics_query.fetch_all(&self.pool).await {
Ok(rows) => rows
.into_iter()
.map(|row| {
Ok(DuckLakeFileColumnStatistics {
data_file_id: row.try_get(0)?,
column_id: row.try_get(1)?,
column_size_bytes: row.try_get(2)?,
value_count: row.try_get(3)?,
null_count: row.try_get(4)?,
min_value: row.try_get(5)?,
max_value: row.try_get(6)?,
contains_nan: row.try_get(7)?,
})
})
.collect::<Result<Vec<_>>>()?,
Err(error) if is_missing_statistics_table(&error) => Vec::new(),
Err(error) => return Err(error.into()),
};
let mut statistics_by_file: HashMap<i64, Vec<_>> = HashMap::new();
for statistic in statistics {
statistics_by_file
.entry(statistic.data_file_id)
.or_default()
.push(statistic);
}
let mut values_by_file: HashMap<i64, Vec<(i32, Option<String>)>> = HashMap::new();
let partition_values_sql = format!(
"SELECT data_file_id, partition_key_index, partition_value
FROM ducklake_file_partition_value
WHERE table_id = $1 AND data_file_id > $2 AND data_file_id <= $3{}",
page_id_filter("data_file_id", 4)
);
let mut partition_values_query = sqlx::query(AssertSqlSafe(partition_values_sql))
.bind(table_id)
.bind(after_data_file_id.unwrap_or(i64::MIN))
.bind(last_data_file_id);
if let Some(ids) = page_ids.as_deref() {
partition_values_query = partition_values_query.bind(ids);
}
match partition_values_query.fetch_all(&self.pool).await {
Ok(rows) => {
for row in rows {
let data_file_id: i64 = row.try_get(0)?;
let key_index: i32 = i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0);
let value: Option<String> = row.try_get(2)?;
values_by_file
.entry(data_file_id)
.or_default()
.push((key_index, value));
}
},
Err(error) if is_missing_statistics_table(&error) => {},
Err(error) => return Err(error.into()),
}
Ok(files
.into_iter()
.map(|mut file| {
if let Some(values) = values_by_file.remove(&file.data_file_id) {
file.partition_values = values;
}
DuckLakeFileMetadata {
column_statistics: statistics_by_file
.remove(&file.data_file_id)
.unwrap_or_default(),
file,
}
})
.collect())
})
}
}
impl MetadataProvider for PostgresMetadataProvider {
fn get_current_snapshot(&self) -> Result<i64> {
block_on(async {
let row = sqlx::query("SELECT COALESCE(MAX(snapshot_id), 0) FROM ducklake_snapshot")
.fetch_one(&self.pool)
.await?;
Ok(row.try_get(0)?)
})
}
fn get_data_path(&self) -> Result<String> {
self.get_metadata_settings(None, None)?
.remove("data_path")
.ok_or_else(|| {
crate::error::DuckLakeError::InvalidConfig(
"Missing required catalog metadata: 'data_path' not configured. \
The catalog may be uninitialized or corrupted."
.to_string(),
)
})
}
fn get_metadata_settings(
&self,
schema_id: Option<i64>,
table_id: Option<i64>,
) -> Result<HashMap<String, String>> {
block_on(async {
let has_scope_columns: bool = sqlx::query_scalar(
"SELECT COUNT(*) = 2 FROM information_schema.columns \
WHERE table_schema = current_schema() \
AND table_name = 'ducklake_metadata' \
AND column_name IN ('scope', 'scope_id')",
)
.fetch_one(&self.pool)
.await?;
let rows = if has_scope_columns {
sqlx::query("SELECT key, value, scope, scope_id FROM ducklake_metadata")
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|row| {
Ok(MetadataSetting {
key: row.try_get(0)?,
value: row.try_get(1)?,
scope: row.try_get(2)?,
scope_id: row.try_get(3)?,
})
})
.collect::<Result<Vec<_>>>()?
} else {
sqlx::query("SELECT key, value FROM ducklake_metadata")
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|row| {
Ok(MetadataSetting {
key: row.try_get(0)?,
value: row.try_get(1)?,
scope: None,
scope_id: None,
})
})
.collect::<Result<Vec<_>>>()?
};
resolve_metadata_settings(rows, schema_id, table_id)
})
}
fn list_snapshots(&self) -> Result<Vec<SnapshotMetadata>> {
block_on(async {
let rows = sqlx::query(
"SELECT snapshot_id, snapshot_time
FROM ducklake_snapshot ORDER BY snapshot_id",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let snapshot_id: i64 = row.try_get(0)?;
let timestamp: Option<NaiveDateTime> = row.try_get(1)?;
let timestamp_str = timestamp
.map(|ts: NaiveDateTime| ts.format("%Y-%m-%d %H:%M:%S%.6f").to_string());
Ok(SnapshotMetadata {
snapshot_id,
timestamp: timestamp_str,
})
})
.collect()
})
}
fn list_snapshot_changes(&self) -> Result<Vec<SnapshotChangeMetadata>> {
block_on(async {
let rows = sqlx::query(
"SELECT snapshot.snapshot_id,
snapshot.snapshot_time::text AS snapshot_time,
changes.changes_made,
changes.author,
changes.commit_message,
changes.commit_extra_info
FROM ducklake_snapshot AS snapshot
LEFT JOIN ducklake_snapshot_changes AS changes
ON changes.snapshot_id = snapshot.snapshot_id
ORDER BY snapshot.snapshot_id",
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(SnapshotChangeMetadata {
snapshot_id: row.try_get("snapshot_id")?,
timestamp: row.try_get("snapshot_time")?,
changes_made: row.try_get("changes_made")?,
author: row.try_get("author")?,
commit_message: row.try_get("commit_message")?,
commit_extra_info: row.try_get("commit_extra_info")?,
})
})
.collect()
})
}
fn find_snapshot_by_commit_extra_info(&self, needle: &str) -> Result<Option<i64>> {
block_on(async {
let row = sqlx::query(
"SELECT changes.snapshot_id
FROM ducklake_snapshot_changes AS changes
WHERE (changes.commit_extra_info = $1
OR strpos(changes.commit_extra_info, $2) > 0)
AND EXISTS (
SELECT 1 FROM ducklake_data_file AS files
WHERE files.begin_snapshot = changes.snapshot_id
AND files.end_snapshot IS NULL
)
ORDER BY changes.snapshot_id
LIMIT 1",
)
.bind(needle)
.bind(needle)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|row| row.try_get("snapshot_id")).transpose()?)
})
}
fn list_schemas(&self, snapshot_id: i64) -> Result<Vec<SchemaMetadata>> {
block_on(async {
let rows = sqlx::query(
"SELECT schema_id, schema_name, path, path_is_relative FROM ducklake_schema
WHERE $1 >= begin_snapshot AND ($2 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(SchemaMetadata {
schema_id: row.try_get(0)?,
schema_name: row.try_get(1)?,
path: row.try_get(2)?,
path_is_relative: row.try_get(3)?,
})
})
.collect()
})
}
fn list_tables(&self, schema_id: i64, snapshot_id: i64) -> Result<Vec<TableMetadata>> {
block_on(async {
let rows = sqlx::query(
"SELECT table_id, table_name, path, path_is_relative FROM ducklake_table
WHERE schema_id = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(schema_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(TableMetadata {
table_id: row.try_get(0)?,
table_name: row.try_get(1)?,
path: row.try_get(2)?,
path_is_relative: row.try_get(3)?,
})
})
.collect()
})
}
fn list_views(&self, schema_id: i64, snapshot_id: i64) -> Result<Vec<ViewMetadata>> {
block_on(async {
if !self.schema_capabilities().await?.views {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT view_id, schema_id, begin_snapshot, view_name, dialect, sql, column_aliases
FROM ducklake_view
WHERE schema_id = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(schema_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(|row| decode_view(&row)).collect()
})
}
fn get_table_structure(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<Vec<DuckLakeTableColumn>> {
block_on(async {
let rows = sqlx::query(
"SELECT column_id, column_name, column_type, nulls_allowed, parent_column,
initial_default, default_value, default_value_type, default_value_dialect
FROM ducklake_column
WHERE table_id = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)
ORDER BY column_order",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
let raw: Result<Vec<(DuckLakeTableColumn, Option<i64>)>> = rows
.into_iter()
.map(|row| {
let nulls_allowed: Option<bool> = row.try_get(3)?;
let parent_column: Option<i64> = row.try_get(4)?;
Ok((
DuckLakeTableColumn::new(
row.try_get(0)?,
row.try_get(1)?,
row.try_get(2)?,
nulls_allowed.unwrap_or(true),
)
.with_defaults(
row.try_get(5)?,
row.try_get(6)?,
row.try_get(7)?,
row.try_get(8)?,
),
parent_column,
))
})
.collect();
reconstruct_columns(raw?)
})
}
fn get_table_fields(&self, table_id: i64, snapshot_id: i64) -> Result<Vec<DuckLakeTableField>> {
block_on(async {
let rows = sqlx::query(
"SELECT column_id, column_name, column_type, nulls_allowed, parent_column
FROM ducklake_column
WHERE table_id = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)
ORDER BY column_order",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(DuckLakeTableField {
column_id: row.try_get(0)?,
column_name: row.try_get(1)?,
column_type: row.try_get(2)?,
is_nullable: row.try_get::<Option<bool>, _>(3)?.unwrap_or(true),
parent_column: row.try_get(4)?,
})
})
.collect()
})
}
fn get_name_mapping(&self, mapping_id: i64) -> Result<DuckLakeNameMapping> {
block_on(async {
let rows = sqlx::query(
"SELECT mapping.mapping_id, mapping.table_id, mapping.type,
name.column_id, name.source_name, name.target_field_id,
name.parent_column, name.is_partition
FROM ducklake_column_mapping AS mapping
JOIN ducklake_name_mapping AS name
ON name.mapping_id = mapping.mapping_id
WHERE mapping.mapping_id = $1
ORDER BY name.parent_column NULLS FIRST, name.column_id",
)
.bind(mapping_id)
.fetch_all(&self.pool)
.await?;
decode_name_mapping_rows(mapping_id, &rows)
})
}
fn get_table_files_for_select(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<Vec<DuckLakeTableFile>> {
block_on(async {
let caps = self.schema_capabilities().await?;
let partial_max_expr = if caps.data_file_partial_max {
"data.partial_max::bigint"
} else {
"NULL::bigint"
};
let partition_id_expr = if caps.data_file_partition_id {
"data.partition_id::bigint"
} else {
"NULL::bigint"
};
let schema_version_expr = if caps.schema_versions {
"(SELECT sv.schema_version::bigint
FROM ducklake_schema_versions sv
WHERE sv.table_id = data.table_id
AND sv.begin_snapshot <= data.begin_snapshot
ORDER BY sv.begin_snapshot DESC
LIMIT 1)"
} else {
"NULL::bigint"
};
let sql = format!(
"SELECT
data.data_file_id,
data.path AS data_file_path,
data.path_is_relative AS data_path_is_relative,
data.file_size_bytes AS data_file_size,
data.footer_size AS data_footer_size,
data.encryption_key AS data_encryption_key,
data.row_id_start AS data_row_id_start,
data.record_count AS data_record_count,
del.delete_file_id,
del.path AS delete_file_path,
del.path_is_relative AS delete_path_is_relative,
del.file_size_bytes AS delete_file_size,
del.footer_size AS delete_footer_size,
del.encryption_key AS delete_encryption_key,
del.delete_count,
data.begin_snapshot::bigint AS data_begin_snapshot,
{partial_max_expr} AS data_partial_max,
{schema_version_expr} AS data_schema_version,
{partition_id_expr} AS data_partition_id,
data.mapping_id::bigint AS data_mapping_id
FROM ducklake_data_file AS data
LEFT JOIN ducklake_delete_file AS del
ON data.data_file_id = del.data_file_id
AND del.table_id = $1
AND $2 >= del.begin_snapshot
AND ($3 < del.end_snapshot OR del.end_snapshot IS NULL)
WHERE data.table_id = $4
AND $5 >= data.begin_snapshot
AND ($6 < data.end_snapshot OR data.end_snapshot IS NULL)"
);
let rows = sqlx::query(AssertSqlSafe(sql.as_str()))
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
let mut files: Vec<DuckLakeTableFile> = rows
.iter()
.map(|row| decode_table_file(row, snapshot_id))
.collect::<Result<Vec<_>>>()?;
if let (Some(min), Some(max)) = (
files.iter().map(|f| f.data_file_id).min(),
files.iter().map(|f| f.data_file_id).max(),
) {
let mut values_by_file: HashMap<i64, Vec<(i32, Option<String>)>> = HashMap::new();
match sqlx::query(
"SELECT data_file_id, partition_key_index, partition_value
FROM ducklake_file_partition_value
WHERE table_id = $1 AND data_file_id >= $2 AND data_file_id <= $3",
)
.bind(table_id)
.bind(min)
.bind(max)
.fetch_all(&self.pool)
.await
{
Ok(rows) => {
for row in rows {
let data_file_id: i64 = row.try_get(0)?;
let key_index: i32 =
i32::try_from(row.try_get::<i64, _>(1)?).unwrap_or(0);
let value: Option<String> = row.try_get(2)?;
values_by_file
.entry(data_file_id)
.or_default()
.push((key_index, value));
}
},
Err(error) if is_missing_statistics_table(&error) => {},
Err(error) => return Err(error.into()),
}
for file in &mut files {
if let Some(values) = values_by_file.remove(&file.data_file_id) {
file.partition_values = values;
}
}
}
Ok(files)
})
}
fn get_partition_spec(&self, table_id: i64, snapshot_id: i64) -> Result<Option<PartitionSpec>> {
block_on(async {
let generation_count: i64 = match sqlx::query_scalar(
"SELECT COUNT(*) FROM ducklake_partition_info WHERE table_id = $1",
)
.bind(table_id)
.fetch_one(&self.pool)
.await
{
Ok(count) => count,
Err(error) if is_missing_statistics_table(&error) => return Ok(None),
Err(error) => return Err(error.into()),
};
let prune_safe = generation_count == 1;
let rows = match 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 = $1
AND $2 >= pi.begin_snapshot
AND ($3 < pi.end_snapshot OR pi.end_snapshot IS NULL)
ORDER BY pc.partition_key_index",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows,
Err(error) if is_missing_statistics_table(&error) => return Ok(None),
Err(error) => return Err(error.into()),
};
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(PartitionSpec::from_rows(parsed, prune_safe))
})
}
fn get_sort_spec(&self, table_id: i64, snapshot_id: i64) -> Result<Option<SortSpec>> {
block_on(async {
let rows = match 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 = $1
AND $2 >= si.begin_snapshot
AND ($3 < si.end_snapshot OR si.end_snapshot IS NULL)
ORDER BY se.sort_key_index",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows,
Err(error) if is_missing_statistics_table(&error) => return Ok(None),
Err(error) => return Err(error.into()),
};
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(SortSpec::from_rows(parsed))
})
}
fn get_table_file_metadata_page(
&self,
table_id: i64,
snapshot_id: i64,
after_data_file_id: Option<i64>,
limit: usize,
) -> Result<Vec<DuckLakeFileMetadata>> {
self.file_metadata_page(table_id, snapshot_id, after_data_file_id, limit, None)
}
fn get_table_file_metadata_page_filtered(
&self,
table_id: i64,
snapshot_id: i64,
after_data_file_id: Option<i64>,
limit: usize,
filter: Option<&StatsFilter>,
) -> Result<Vec<DuckLakeFileMetadata>> {
self.file_metadata_page(table_id, snapshot_id, after_data_file_id, limit, filter)
}
fn get_table_summary_statistics(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<DuckLakeStatistics> {
block_on(async {
let table = match sqlx::query(
"SELECT record_count, file_size_bytes
FROM ducklake_table_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_optional(&self.pool)
.await
{
Ok(row) => row
.map(|row| {
Ok::<_, sqlx::Error>(DuckLakeTableStatistics {
record_count: row.try_get(0)?,
file_size_bytes: row.try_get(1)?,
})
})
.transpose()?,
Err(error) if is_missing_statistics_table(&error) => None,
Err(error) => return Err(error.into()),
};
let column_sizes = match sqlx::query(
"SELECT stats.column_id,
CASE
WHEN COUNT(*) = COUNT(stats.column_size_bytes)
AND COUNT(*) = (
SELECT COUNT(*) FROM ducklake_data_file visible
WHERE visible.table_id = $1
AND $2 >= visible.begin_snapshot
AND ($3 < visible.end_snapshot OR visible.end_snapshot IS NULL)
)
THEN CAST(SUM(stats.column_size_bytes) AS BIGINT)
END
FROM ducklake_file_column_stats stats
INNER JOIN ducklake_data_file data
ON data.data_file_id = stats.data_file_id
AND data.table_id = stats.table_id
WHERE stats.table_id = $4
AND $5 >= data.begin_snapshot
AND ($6 < data.end_snapshot OR data.end_snapshot IS NULL)
GROUP BY stats.column_id",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows
.into_iter()
.filter_map(|row| match row.try_get::<Option<i64>, _>(1) {
Ok(Some(size)) => Some(row.try_get(0).map(|column_id| (column_id, size))),
Ok(None) => None,
Err(error) => Some(Err(error)),
})
.collect::<std::result::Result<HashMap<i64, i64>, _>>()?,
Err(error) if is_missing_statistics_table(&error) => HashMap::new(),
Err(error) => return Err(error.into()),
};
let bounds_are_exact: bool = sqlx::query_scalar(
"SELECT NOT EXISTS (
SELECT 1 FROM ducklake_delete_file
WHERE table_id = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)
)",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_one(&self.pool)
.await?;
let columns = match sqlx::query(
"SELECT column_id, contains_null, min_value, max_value, contains_nan
FROM ducklake_table_column_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows
.into_iter()
.map(|row| {
let column_id = row.try_get(0)?;
Ok(DuckLakeTableColumnStatistics {
column_id,
contains_null: row.try_get(1)?,
min_value: row.try_get(2)?,
max_value: row.try_get(3)?,
contains_nan: row.try_get(4)?,
column_size_bytes: column_sizes.get(&column_id).copied(),
bounds_are_exact,
})
})
.collect::<Result<Vec<_>>>()?,
Err(error) if is_missing_statistics_table(&error) => Vec::new(),
Err(error) => return Err(error.into()),
};
Ok(DuckLakeStatistics {
table,
columns,
files: Vec::new(),
})
})
}
fn get_table_statistics(&self, table_id: i64, snapshot_id: i64) -> Result<DuckLakeStatistics> {
block_on(async {
let table = match sqlx::query(
"SELECT record_count, file_size_bytes
FROM ducklake_table_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_optional(&self.pool)
.await
{
Ok(row) => row
.map(|row| {
Ok::<_, sqlx::Error>(DuckLakeTableStatistics {
record_count: row.try_get(0)?,
file_size_bytes: row.try_get(1)?,
})
})
.transpose()?,
Err(error) if is_missing_statistics_table(&error) => None,
Err(error) => return Err(error.into()),
};
let columns = match sqlx::query(
"SELECT column_id, contains_null, min_value, max_value, contains_nan
FROM ducklake_table_column_stats WHERE table_id = $1",
)
.bind(table_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows
.into_iter()
.map(|row| {
Ok(DuckLakeTableColumnStatistics {
column_id: row.try_get(0)?,
contains_null: row.try_get(1)?,
min_value: row.try_get(2)?,
max_value: row.try_get(3)?,
contains_nan: row.try_get(4)?,
column_size_bytes: None,
bounds_are_exact: false,
})
})
.collect::<Result<Vec<_>>>()?,
Err(error) if is_missing_statistics_table(&error) => Vec::new(),
Err(error) => return Err(error.into()),
};
let files = match sqlx::query(
"SELECT
stats.data_file_id,
stats.column_id,
stats.column_size_bytes,
stats.value_count,
stats.null_count,
stats.min_value,
stats.max_value,
stats.contains_nan
FROM ducklake_file_column_stats AS stats
INNER JOIN ducklake_data_file AS data
ON data.data_file_id = stats.data_file_id
AND data.table_id = stats.table_id
WHERE stats.table_id = $1
AND $2 >= data.begin_snapshot
AND ($3 < data.end_snapshot OR data.end_snapshot IS NULL)",
)
.bind(table_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows
.into_iter()
.map(|row| {
Ok(DuckLakeFileColumnStatistics {
data_file_id: row.try_get(0)?,
column_id: row.try_get(1)?,
column_size_bytes: row.try_get(2)?,
value_count: row.try_get(3)?,
null_count: row.try_get(4)?,
min_value: row.try_get(5)?,
max_value: row.try_get(6)?,
contains_nan: row.try_get(7)?,
})
})
.collect::<Result<Vec<_>>>()?,
Err(error) if is_missing_statistics_table(&error) => Vec::new(),
Err(error) => return Err(error.into()),
};
Ok(DuckLakeStatistics {
table,
columns,
files,
})
})
}
fn get_inlined_data(
&self,
table_id: i64,
snapshot_id: i64,
columns: &[DuckLakeTableColumn],
) -> Result<Vec<RecordBatch>> {
Ok(self
.scan_inlined_data(table_id, snapshot_id, columns, None)?
.batches)
}
fn scan_inlined_data(
&self,
table_id: i64,
snapshot_id: i64,
columns: &[DuckLakeTableColumn],
filter: Option<&InlinedFilter>,
) -> Result<InlinedDataScan> {
block_on(async {
if !self.schema_capabilities().await?.inlined_data_tables {
return Ok(InlinedDataScan::default());
}
let registry = sqlx::query(
"SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = $1",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let schema: SchemaRef = Arc::new(crate::types::build_arrow_schema(columns)?);
let mut batches = Vec::new();
for entry in registry {
let table: String = entry.try_get("table_name")?;
if !is_inlined_data_table(&table) {
continue;
}
let physical_columns = sqlx::query(
"SELECT column_name, data_type FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = $1",
)
.bind(&table)
.fetch_all(&self.pool)
.await?;
let present = physical_columns
.iter()
.map(|row| row.try_get::<String, _>(0))
.collect::<std::result::Result<HashSet<_>, _>>()?;
let physical_types = physical_columns
.iter()
.map(|row| Ok((row.try_get::<String, _>(0)?, row.try_get::<String, _>(1)?)))
.collect::<std::result::Result<HashMap<_, _>, sqlx::Error>>()?;
let projected = columns
.iter()
.zip(schema.fields())
.map(|(column, field)| {
if !present.contains(&column.column_name) {
"NULL::text".to_string()
} else {
let ident = quote_ident(&column.column_name);
inlined_text_projection(
InlinedDataBackend::Postgres,
column,
field.data_type(),
&ident,
)
}
})
.collect::<Vec<_>>()
.join(", ");
let rendered = filter.and_then(|filter| {
render_inlined_filter(
filter,
InlinedSqlDialect::Postgres,
schema.as_ref(),
&physical_types,
2,
)
});
let pushed = rendered
.as_ref()
.map(|rendered| format!(" AND ({})", rendered.sql))
.unwrap_or_default();
let sql = format!(
"SELECT {projected} FROM {} \
WHERE $1 >= begin_snapshot AND ($2 < end_snapshot OR end_snapshot IS NULL){pushed} \
ORDER BY row_id",
quote_ident(&table)
);
let mut query = sqlx::query(AssertSqlSafe(sql.as_str()))
.bind(snapshot_id)
.bind(snapshot_id);
if let Some(rendered) = rendered {
for bind in rendered.binds {
query = match bind {
InlinedSqlBind::Bool(value) => query.bind(value),
InlinedSqlBind::I64(value) => query.bind(value),
InlinedSqlBind::U64(value) => query.bind(value.to_string()),
InlinedSqlBind::F64(value) => query.bind(value),
InlinedSqlBind::Text(value) => query.bind(value),
InlinedSqlBind::Bytes(value) => query.bind(value),
};
}
}
let rows = query.fetch_all(&self.pool).await?;
if rows.is_empty() {
continue;
}
let rows = rows
.into_iter()
.map(|row| {
(0..columns.len())
.map(|index| row.try_get::<Option<String>, _>(index))
.collect::<std::result::Result<Vec<_>, _>>()
})
.collect::<std::result::Result<Vec<_>, _>>()?;
batches.push(parse_inlined_rows_with_present(
schema.clone(),
columns,
rows,
Some(&present),
)?);
}
Ok(InlinedDataScan::from_batches(batches))
})
}
fn get_inlined_data_with_row_ids(
&self,
table_id: i64,
snapshot_id: i64,
columns: &[DuckLakeTableColumn],
) -> Result<Vec<DuckLakeInlinedData>> {
block_on(async {
if !self.schema_capabilities().await?.inlined_data_tables {
return Ok(Vec::new());
}
let entries = sqlx::query(
"SELECT table_name FROM ducklake_inlined_data_tables WHERE table_id = $1",
)
.bind(table_id)
.fetch_all(&self.pool)
.await?;
let schema: SchemaRef = Arc::new(crate::types::build_strict_arrow_schema(columns)?);
let mut batches = Vec::new();
for entry in entries {
let table: String = entry.try_get("table_name")?;
if !is_inlined_data_table(&table) {
continue;
}
let present = sqlx::query(
"SELECT column_name FROM information_schema.columns
WHERE table_schema = current_schema() AND table_name = $1",
)
.bind(&table)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|row| row.try_get::<String, _>(0))
.collect::<std::result::Result<HashSet<_>, _>>()?;
let projected = columns
.iter()
.zip(schema.fields())
.map(|(column, field)| {
if !present.contains(&column.column_name) {
"NULL::text".to_string()
} else {
let ident = quote_ident(&column.column_name);
inlined_text_projection(
InlinedDataBackend::Postgres,
column,
field.data_type(),
&ident,
)
}
})
.collect::<Vec<_>>()
.join(", ");
let sql = format!(
"SELECT row_id, begin_snapshot, {projected} FROM {}
WHERE $1 >= begin_snapshot AND ($2 < end_snapshot OR end_snapshot IS NULL)
ORDER BY begin_snapshot, row_id",
quote_ident(&table)
);
let rows = sqlx::query(AssertSqlSafe(sql.as_str()))
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
if rows.is_empty() {
continue;
}
let row_ids = rows
.iter()
.map(|row| row.try_get("row_id"))
.collect::<std::result::Result<Vec<i64>, _>>()?;
let begin_snapshots = rows
.iter()
.map(|row| row.try_get("begin_snapshot"))
.collect::<std::result::Result<Vec<i64>, _>>()?;
let decoded_rows = rows
.into_iter()
.map(|row| {
(0..columns.len())
.map(|index| row.try_get::<Option<String>, _>(index + 2))
.collect::<std::result::Result<Vec<_>, _>>()
})
.collect::<std::result::Result<Vec<_>, _>>()?;
batches.push(DuckLakeInlinedData {
table_name: table,
row_ids,
begin_snapshots,
batch: parse_inlined_rows_with_present(
schema.clone(),
columns,
decoded_rows,
Some(&present),
)?,
});
}
Ok(batches)
})
}
fn get_inlined_deletes(
&self,
table_id: i64,
snapshot_id: i64,
) -> Result<Vec<DuckLakeInlinedDelete>> {
block_on(async {
let table = inlined_delete_table_name(table_id)?;
let sql = format!(
"SELECT file_id, row_id FROM {} WHERE begin_snapshot <= $1 ORDER BY file_id, row_id",
quote_ident(&table)
);
match sqlx::query(AssertSqlSafe(sql.as_str()))
.bind(snapshot_id)
.fetch_all(&self.pool)
.await
{
Ok(rows) => rows
.into_iter()
.map(|row| {
Ok(DuckLakeInlinedDelete {
data_file_id: row.try_get(0)?,
row_id: row.try_get(1)?,
})
})
.collect(),
Err(error) if is_missing_statistics_table(&error) => Ok(Vec::new()),
Err(error) => Err(error.into()),
}
})
}
fn get_schema_by_name(&self, name: &str, snapshot_id: i64) -> Result<Option<SchemaMetadata>> {
block_on(async {
let row = sqlx::query(
"SELECT schema_id, schema_name, path, path_is_relative FROM ducklake_schema
WHERE schema_name = $1
AND $2 >= begin_snapshot
AND ($3 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(name)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_optional(&self.pool)
.await?;
match row {
Some(r) => Ok(Some(SchemaMetadata {
schema_id: r.try_get(0)?,
schema_name: r.try_get(1)?,
path: r.try_get(2)?,
path_is_relative: r.try_get(3)?,
})),
None => Ok(None),
}
})
}
fn get_table_by_name(
&self,
schema_id: i64,
name: &str,
snapshot_id: i64,
) -> Result<Option<TableMetadata>> {
block_on(async {
let row = sqlx::query(
"SELECT table_id, table_name, path, path_is_relative FROM ducklake_table
WHERE schema_id = $1
AND table_name = $2
AND $3 >= begin_snapshot
AND ($4 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(schema_id)
.bind(name)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_optional(&self.pool)
.await?;
match row {
Some(r) => Ok(Some(TableMetadata {
table_id: r.try_get(0)?,
table_name: r.try_get(1)?,
path: r.try_get(2)?,
path_is_relative: r.try_get(3)?,
})),
None => Ok(None),
}
})
}
fn get_view_by_name(
&self,
schema_id: i64,
name: &str,
snapshot_id: i64,
) -> Result<Option<ViewMetadata>> {
block_on(async {
if !self.schema_capabilities().await?.views {
return Ok(None);
}
let row = sqlx::query(
"SELECT view_id, schema_id, begin_snapshot, view_name, dialect, sql, column_aliases
FROM ducklake_view
WHERE schema_id = $1
AND view_name = $2
AND $3 >= begin_snapshot
AND ($4 < end_snapshot OR end_snapshot IS NULL)",
)
.bind(schema_id)
.bind(name)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_optional(&self.pool)
.await?;
row.map(|row| decode_view(&row)).transpose()
})
}
fn table_exists(&self, schema_id: i64, name: &str, snapshot_id: i64) -> Result<bool> {
block_on(async {
let row = sqlx::query(
"SELECT EXISTS(
SELECT 1 FROM ducklake_table
WHERE schema_id = $1
AND table_name = $2
AND $3 >= begin_snapshot
AND ($4 < end_snapshot OR end_snapshot IS NULL)
)",
)
.bind(schema_id)
.bind(name)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_one(&self.pool)
.await?;
Ok(row.try_get(0)?)
})
}
fn list_all_tables(&self, snapshot_id: i64) -> Result<Vec<TableWithSchema>> {
block_on(async {
let rows = bind_repeat!(
sqlx::query(
"SELECT s.schema_name, t.table_id, t.table_name, t.path, t.path_is_relative
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
WHERE $1 >= s.begin_snapshot
AND ($2 < s.end_snapshot OR s.end_snapshot IS NULL)
AND $3 >= t.begin_snapshot
AND ($4 < t.end_snapshot OR t.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name"
),
snapshot_id,
4
)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let schema_name: String = row.try_get(0)?;
let table = TableMetadata {
table_id: row.try_get(1)?,
table_name: row.try_get(2)?,
path: row.try_get(3)?,
path_is_relative: row.try_get(4)?,
};
Ok(TableWithSchema {
schema_name,
table,
})
})
.collect()
})
}
fn list_all_views(&self, snapshot_id: i64) -> Result<Vec<ViewWithSchema>> {
block_on(async {
if !self.schema_capabilities().await?.views {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT s.schema_name, v.view_id, v.schema_id, v.begin_snapshot, v.view_name,
v.dialect, v.sql, v.column_aliases
FROM ducklake_schema s
JOIN ducklake_view v ON s.schema_id = v.schema_id
WHERE $1 >= s.begin_snapshot
AND ($1 < s.end_snapshot OR s.end_snapshot IS NULL)
AND $1 >= v.begin_snapshot
AND ($1 < v.end_snapshot OR v.end_snapshot IS NULL)
ORDER BY s.schema_name, v.view_name",
)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(ViewWithSchema {
schema_name: row.try_get(0)?,
view: ViewMetadata {
view_id: row.try_get(1)?,
schema_id: row.try_get(2)?,
begin_snapshot: row.try_get(3)?,
view_name: row.try_get(4)?,
dialect: row.try_get(5)?,
sql: row.try_get(6)?,
column_aliases: row.try_get(7)?,
},
})
})
.collect()
})
}
fn list_all_columns(&self, snapshot_id: i64) -> Result<Vec<ColumnWithTable>> {
block_on(async {
let rows = sqlx::query(
"SELECT s.schema_name, t.table_name, c.column_id, c.column_name, c.column_type,
c.nulls_allowed, c.parent_column, c.initial_default, c.default_value,
c.default_value_type, c.default_value_dialect
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
JOIN ducklake_column c ON t.table_id = c.table_id
WHERE $1 >= s.begin_snapshot
AND ($2 < s.end_snapshot OR s.end_snapshot IS NULL)
AND $3 >= t.begin_snapshot
AND ($4 < t.end_snapshot OR t.end_snapshot IS NULL)
AND $5 >= c.begin_snapshot
AND ($6 < c.end_snapshot OR c.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name, c.column_order",
)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
let raw: Result<Vec<(ColumnWithTable, Option<i64>)>> = rows
.into_iter()
.map(|row| {
let schema_name: String = row.try_get(0)?;
let table_name: String = row.try_get(1)?;
let nulls_allowed: Option<bool> = row.try_get(5)?;
let parent_column: Option<i64> = row.try_get(6)?;
let column = DuckLakeTableColumn::new(
row.try_get(2)?,
row.try_get(3)?,
row.try_get(4)?,
nulls_allowed.unwrap_or(true),
)
.with_defaults(
row.try_get(7)?,
row.try_get(8)?,
row.try_get(9)?,
row.try_get(10)?,
);
Ok((
ColumnWithTable {
schema_name,
table_name,
column,
},
parent_column,
))
})
.collect();
reconstruct_columns_with_table(raw?)
})
}
fn list_all_files(&self, snapshot_id: i64) -> Result<Vec<FileWithTable>> {
block_on(async {
let rows = sqlx::query(
"SELECT
s.schema_name,
t.table_name,
data.data_file_id,
data.path AS data_file_path,
data.path_is_relative AS data_path_is_relative,
data.file_size_bytes AS data_file_size,
data.footer_size AS data_footer_size,
data.encryption_key AS data_encryption_key,
del.delete_file_id,
del.path AS delete_file_path,
del.path_is_relative AS delete_path_is_relative,
del.file_size_bytes AS delete_file_size,
del.footer_size AS delete_footer_size,
del.encryption_key AS delete_encryption_key,
del.delete_count
FROM ducklake_schema s
JOIN ducklake_table t ON s.schema_id = t.schema_id
JOIN ducklake_data_file data ON t.table_id = data.table_id
LEFT JOIN ducklake_delete_file del
ON data.data_file_id = del.data_file_id
AND del.table_id = t.table_id
AND $1 >= del.begin_snapshot
AND ($2 < del.end_snapshot OR del.end_snapshot IS NULL)
WHERE $3 >= s.begin_snapshot
AND ($4 < s.end_snapshot OR s.end_snapshot IS NULL)
AND $5 >= t.begin_snapshot
AND ($6 < t.end_snapshot OR t.end_snapshot IS NULL)
AND $7 >= data.begin_snapshot
AND ($8 < data.end_snapshot OR data.end_snapshot IS NULL)
ORDER BY s.schema_name, t.table_name, data.path",
)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.bind(snapshot_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
let data_file = DuckLakeFileData {
path: row.try_get(3)?,
path_is_relative: row.try_get(4)?,
file_size_bytes: row.try_get(5)?,
footer_size: row.try_get(6)?,
encryption_key: row.try_get(7)?,
mapping_id: None,
};
let delete_file = if row.try_get::<Option<i64>, _>(8)?.is_some() {
Some(DuckLakeFileData {
path: row.try_get(9)?,
path_is_relative: row.try_get(10)?,
file_size_bytes: row.try_get(11)?,
footer_size: row.try_get(12)?,
encryption_key: row.try_get(13)?,
mapping_id: None,
})
} else {
None
};
Ok(FileWithTable {
schema_name: row.try_get(0)?,
table_name: row.try_get(1)?,
file: DuckLakeTableFile {
data_file_id: row.try_get(2)?,
file: data_file,
delete_file_id: row.try_get(8)?,
delete_file,
row_id_start: None,
snapshot_id: None,
begin_snapshot: None,
schema_version: None,
partial_max: None,
max_row_count: row.try_get(14)?,
delete_count: None,
partition_id: None,
partition_values: Vec::new(),
},
})
})
.collect()
})
}
fn get_data_files_added_between_snapshots(
&self,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
) -> Result<Vec<DataFileChange>> {
block_on(async {
let pm = if self.schema_capabilities().await?.data_file_partial_max {
"data.partial_max::bigint"
} else {
"NULL::bigint"
};
let rows = sqlx::query(AssertSqlSafe(format!(
"SELECT
data.begin_snapshot,
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.encryption_key,
data.row_id_start,
{pm},
data.mapping_id
FROM ducklake_data_file AS data
WHERE data.table_id = $1
AND data.begin_snapshot <= $3
AND (data.begin_snapshot >= $2
OR ({pm} IS NOT NULL AND {pm} >= $2))
ORDER BY data.begin_snapshot"
)))
.bind(table_id)
.bind(start_snapshot)
.bind(end_snapshot)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(DataFileChange {
begin_snapshot: row.try_get(0)?,
path: row.try_get(1)?,
path_is_relative: row.try_get(2)?,
file_size_bytes: row.try_get(3)?,
footer_size: row.try_get(4)?,
encryption_key: row.try_get(5)?,
row_id_start: row.try_get(6)?,
partial_max: row.try_get(7)?,
mapping_id: row.try_get(8)?,
})
})
.collect()
})
}
fn get_delete_files_added_between_snapshots(
&self,
table_id: i64,
start_snapshot: i64,
end_snapshot: i64,
) -> Result<Vec<DeleteFileChange>> {
block_on(async {
let pm = if self.schema_capabilities().await?.delete_file_partial_max {
"ddf.partial_max::bigint"
} else {
"NULL::bigint"
};
let rows = sqlx::query(AssertSqlSafe(format!(
r#"
WITH current_delete AS (
SELECT
ddf.data_file_id,
ddf.begin_snapshot,
ddf.path,
ddf.path_is_relative,
ddf.file_size_bytes,
ddf.footer_size,
ddf.encryption_key
FROM ducklake_delete_file ddf
WHERE ddf.table_id = $1
AND ddf.begin_snapshot <= $3
AND (ddf.begin_snapshot >= $2
OR ({pm} IS NOT NULL AND {pm} >= $2))
),
data_files AS (
SELECT df.*
FROM ducklake_data_file df
WHERE df.table_id = $1
)
-- Part 1: Incremental deletes
SELECT
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.row_id_start,
data.record_count,
data.mapping_id,
current_delete.path,
current_delete.path_is_relative,
current_delete.file_size_bytes,
current_delete.footer_size,
prev.path,
prev.path_is_relative,
prev.file_size_bytes,
prev.footer_size,
current_delete.begin_snapshot
FROM current_delete
JOIN data_files data USING (data_file_id)
LEFT JOIN LATERAL (
SELECT
ddf.path,
ddf.path_is_relative,
ddf.file_size_bytes,
ddf.footer_size
FROM ducklake_delete_file ddf
WHERE ddf.table_id = $1
AND ddf.data_file_id = current_delete.data_file_id
AND ddf.begin_snapshot < current_delete.begin_snapshot
ORDER BY ddf.begin_snapshot DESC
LIMIT 1
) prev ON true
UNION ALL
-- Part 2: Full file deletes
SELECT
data.path,
data.path_is_relative,
data.file_size_bytes,
data.footer_size,
data.row_id_start,
data.record_count,
data.mapping_id,
NULL::VARCHAR,
NULL::BOOLEAN,
NULL::BIGINT,
NULL::BIGINT,
prev.path,
prev.path_is_relative,
prev.file_size_bytes,
prev.footer_size,
data.end_snapshot
FROM ducklake_data_file data
LEFT JOIN LATERAL (
SELECT
ddf.path,
ddf.path_is_relative,
ddf.file_size_bytes,
ddf.footer_size
FROM ducklake_delete_file ddf
WHERE ddf.table_id = $1
AND ddf.data_file_id = data.data_file_id
AND ddf.begin_snapshot < data.end_snapshot
ORDER BY ddf.begin_snapshot DESC
LIMIT 1
) prev ON true
WHERE data.table_id = $1
AND data.end_snapshot >= $2
AND data.end_snapshot <= $3
"#
)))
.bind(table_id)
.bind(start_snapshot)
.bind(end_snapshot)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(DeleteFileChange {
data_file_path: row.try_get(0)?,
data_file_path_is_relative: row.try_get(1)?,
data_file_size_bytes: row.try_get(2)?,
data_file_footer_size: row.try_get(3)?,
data_row_id_start: row.try_get(4)?,
data_record_count: row.try_get(5)?,
data_mapping_id: row.try_get(6)?,
current_delete_path: row.try_get(7)?,
current_delete_path_is_relative: row.try_get(8)?,
current_delete_file_size_bytes: row.try_get(9)?,
current_delete_footer_size: row.try_get(10)?,
previous_delete_path: row.try_get(11)?,
previous_delete_path_is_relative: row.try_get(12)?,
previous_delete_file_size_bytes: row.try_get(13)?,
previous_delete_footer_size: row.try_get(14)?,
snapshot_id: row.try_get(15)?,
})
})
.collect()
})
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema, TimeUnit};
use datafusion::common::ScalarValue;
use datafusion::logical_expr::Operator;
use datafusion::physical_expr::PhysicalExpr;
use datafusion::physical_expr::expressions::{BinaryExpr, Column, lit};
use super::{PostgresStatsDialect, StatsFilterSql, stats_filter_sql};
use crate::metadata_provider::DuckLakeTableColumn;
use crate::stats_filter::lower_predicate;
fn column(name: &str, data_type: DataType, column_id: i64) -> (Field, DuckLakeTableColumn) {
(
Field::new(name, data_type, true),
DuckLakeTableColumn {
column_id,
column_name: name.to_string(),
column_type: "int32".to_string(),
is_nullable: true,
data_type: None,
nested_column_ids: Vec::new(),
initial_default: None,
default_value: None,
default_value_type: None,
default_value_dialect: None,
},
)
}
fn splice(
predicate: Arc<dyn PhysicalExpr>,
field: (Field, DuckLakeTableColumn),
table_id: i64,
soft_input_validation: bool,
) -> Option<StatsFilterSql> {
let schema = Schema::new(vec![field.0]);
let rendered = lower_predicate(&predicate, &schema, &[field.1])
.expect("predicate lowers")
.render(&PostgresStatsDialect {
soft_input_validation,
})?;
stats_filter_sql(table_id, &rendered)
}
fn int_range_predicate() -> Arc<dyn PhysicalExpr> {
let a = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
Arc::new(BinaryExpr::new(
Arc::new(BinaryExpr::new(Arc::clone(&a), Operator::Gt, lit(5i32))),
Operator::And,
Arc::new(BinaryExpr::new(a, Operator::Lt, lit(10i32))),
))
}
#[test]
fn two_conjunct_integer_filter_renders_the_official_shape() {
let spliced = splice(
int_range_predicate(),
column("a", DataType::Int32, 7),
3,
true,
)
.expect("filter splices");
assert_eq!(
spliced.with_prefix,
"WITH col_7_stats AS (
SELECT data_file_id, min_value, max_value, value_count
FROM ducklake_file_column_stats
WHERE column_id = 7 AND table_id = 3
)
"
);
assert_eq!(
spliced.joins,
"\n LEFT JOIN col_7_stats ON col_7_stats.data_file_id = data.data_file_id"
);
assert_eq!(
spliced.conditions,
"\n AND ((col_7_stats.data_file_id IS NULL OR \
((col_7_stats.value_count IS NULL OR col_7_stats.value_count > 0) AND \
(col_7_stats.min_value IS NULL OR col_7_stats.max_value IS NULL OR \
(CASE WHEN (col_7_stats.max_value COLLATE \"C\") ~ \
'^-?[0-9]{1,255}(\\.[0-9]{1,255})?$' AND \
pg_input_is_valid(col_7_stats.max_value, 'numeric') \
THEN CAST(col_7_stats.max_value AS numeric) END > 5) AND \
(CASE WHEN (col_7_stats.min_value COLLATE \"C\") ~ \
'^-?[0-9]{1,255}(\\.[0-9]{1,255})?$' AND \
pg_input_is_valid(col_7_stats.min_value, 'numeric') \
THEN CAST(col_7_stats.min_value AS numeric) END < 10))))) IS NOT FALSE"
);
}
#[test]
fn integer_filter_falls_back_to_a_pattern_before_postgresql_16() {
let spliced = splice(
int_range_predicate(),
column("a", DataType::Int32, 7),
3,
false,
)
.expect("filter splices");
assert!(
spliced.conditions.contains(
"CASE WHEN (col_7_stats.max_value COLLATE \"C\") ~ \
'^-?[0-9]{1,255}(\\.[0-9]{1,255})?$' \
THEN CAST(col_7_stats.max_value AS numeric) END > 5"
),
"unexpected condition:\n{}",
spliced.conditions
);
assert!(!spliced.conditions.contains("pg_input_is_valid"));
}
#[test]
fn soft_validation_still_requires_the_stat_shape() {
let float_filter = |soft| {
let f = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
let predicate =
Arc::new(BinaryExpr::new(f, Operator::Eq, lit(5.0f64))) as Arc<dyn PhysicalExpr>;
splice(predicate, column("a", DataType::Float64, 2), 3, soft)
.expect("a float equality pushes down")
.conditions
};
for soft in [false, true] {
let sql = float_filter(soft);
assert!(
sql.contains("COLLATE \"C\") ~ '^-?(inf|"),
"no shape gate (soft_input_validation = {soft}): {sql}"
);
assert!(!sql.contains("nan"), "nan admitted: {sql}");
}
}
#[test]
fn temporal_is_compared_as_text_on_every_server_version() {
let render = |value: ScalarValue, data_type: DataType, soft| {
let t = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
let predicate =
Arc::new(BinaryExpr::new(t, Operator::Lt, lit(value))) as Arc<dyn PhysicalExpr>;
splice(predicate, column("a", data_type, 2), 3, soft).map(|sql| sql.conditions)
};
for soft in [false, true] {
let date = render(ScalarValue::Date32(Some(19_723)), DataType::Date32, soft)
.expect("a date pushes down");
assert!(
date.contains(
"(col_2_stats.min_value COLLATE \"C\") ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'"
),
"date not compared as text (soft = {soft}): {date}"
);
assert!(!date.contains("CAST("), "date was cast: {date}");
assert!(!date.contains("pg_input_is_valid"), "date parsed: {date}");
let nanos = render(
ScalarValue::TimestampNanosecond(Some(1_577_836_800_123_456_700), None),
DataType::Timestamp(TimeUnit::Nanosecond, None),
soft,
)
.expect("a nanosecond timestamp pushes down");
assert!(nanos.contains("'2020-01-01 00:00:00.1234567'"), "{nanos}");
assert!(!nanos.contains("CAST("), "nanosecond was cast: {nanos}");
let zoned = render(
ScalarValue::TimestampMicrosecond(
Some(1_577_836_800_000_000),
Some("+00:00".into()),
),
DataType::Timestamp(TimeUnit::Microsecond, Some("+00:00".into())),
soft,
)
.expect("a zoned timestamp pushes down");
assert!(zoned.contains("[+]00$"), "{zoned}");
}
}
#[test]
fn temporal_declines_encodings_that_do_not_order_as_text() {
let declines = |value: ScalarValue, data_type: DataType| {
let t = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
let predicate =
Arc::new(BinaryExpr::new(t, Operator::Lt, lit(value))) as Arc<dyn PhysicalExpr>;
splice(predicate, column("a", data_type, 2), 3, true).is_none()
};
assert!(declines(
ScalarValue::Date32(Some(4_000_000)),
DataType::Date32
));
assert!(declines(
ScalarValue::Date32(Some(-800_000)),
DataType::Date32
));
assert!(!declines(
ScalarValue::Date32(Some(-719_528)),
DataType::Date32
));
let t = Arc::new(Column::new("a", 0)) as Arc<dyn PhysicalExpr>;
let predicate = Arc::new(BinaryExpr::new(
t,
Operator::Lt,
lit(ScalarValue::TimestampMicrosecond(
Some(1_577_836_800_500_000),
None,
)),
)) as Arc<dyn PhysicalExpr>;
let sql = splice(
predicate,
column("a", DataType::Timestamp(TimeUnit::Microsecond, None), 2),
3,
true,
)
.expect("a canonical timestamp pushes down")
.conditions;
assert!(sql.contains("([.][0-9]*[1-9])?$"), "{sql}");
}
#[test]
fn string_bounds_are_compared_under_the_c_collation() {
let s = Arc::new(Column::new("s", 0)) as Arc<dyn PhysicalExpr>;
let predicate =
Arc::new(BinaryExpr::new(s, Operator::Eq, lit("abc"))) as Arc<dyn PhysicalExpr>;
let spliced =
splice(predicate, column("s", DataType::Utf8, 1), 3, true).expect("filter splices");
assert!(
spliced.conditions.contains(
"'abc' BETWEEN (col_1_stats.min_value COLLATE \"C\") \
AND (col_1_stats.max_value COLLATE \"C\")"
),
"unexpected condition:\n{}",
spliced.conditions
);
assert!(!spliced.conditions.contains("CAST"));
}
}