use std::collections::{BTreeMap, BTreeSet};
use std::ffi::OsString;
use std::ops::ControlFlow;
use sqlparser::ast::{
CopyOption, CopySource, CopyTarget, Expr, FunctionArg, FunctionArgExpr, Insert, ObjectName,
Statement, TableFactor, TableFunctionArgs, TableObject, Value, visit_expressions,
};
use sqlparser::dialect::DuckDbDialect;
use sqlparser::parser::Parser;
use crate::asset_kind::AssetKind;
use crate::duckdb_lineage::{self, ArgValue, Relation, TableCall};
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SqlAssets {
pub outputs: BTreeSet<String>,
pub inputs: BTreeSet<String>,
pub internal: BTreeSet<String>,
pub destroys: BTreeSet<String>,
pub kinds: BTreeMap<String, AssetKind>,
pub unread_table_functions: BTreeSet<String>,
}
impl SqlAssets {
fn record_output(&mut self, name: String, kind: AssetKind) {
self.kinds.insert(name.clone(), kind);
self.outputs.insert(name);
}
fn record_input(&mut self, name: String, kind: AssetKind) {
self.kinds.insert(name.clone(), kind);
self.inputs.insert(name);
}
}
fn is_glob(path: &str) -> bool {
path.contains(['*', '?', '['])
}
fn read_path_kind(path: &str) -> AssetKind {
if is_glob(path) {
AssetKind::Pattern
} else {
AssetKind::File
}
}
const DIRECTORY_WRITING_COPY_OPTIONS: [&str; 4] = [
"PARTITION_BY",
"PER_THREAD_OUTPUT",
"FILE_SIZE_BYTES",
"ROW_GROUPS_PER_FILE",
];
#[derive(Debug, Clone, PartialEq)]
enum CopyOptionValue {
Absent,
Boolean(bool),
Number(String),
String(String),
EmptyList,
Other,
}
fn copy_to_target_kind(options: &[(String, CopyOptionValue)]) -> AssetKind {
let writes_a_directory = options.iter().any(|(name, value)| {
DIRECTORY_WRITING_COPY_OPTIONS
.iter()
.any(|known| name.eq_ignore_ascii_case(known))
&& copy_option_is_on(name, value)
});
if writes_a_directory {
AssetKind::Directory
} else {
AssetKind::File
}
}
fn sqlparser_copy_options(options: &[CopyOption]) -> Vec<(String, CopyOptionValue)> {
let value = |value: &Option<Expr>| match value {
None => CopyOptionValue::Absent,
Some(Expr::Tuple(items)) if items.is_empty() => CopyOptionValue::EmptyList,
Some(Expr::Value(v)) => match &v.value {
Value::Boolean(b) => CopyOptionValue::Boolean(*b),
Value::Number(n, _) => CopyOptionValue::Number(n.clone()),
Value::SingleQuotedString(s)
| Value::DoubleQuotedString(s)
| Value::TripleSingleQuotedString(s)
| Value::TripleDoubleQuotedString(s) => CopyOptionValue::String(s.clone()),
_ => CopyOptionValue::Other,
},
Some(_) => CopyOptionValue::Other,
};
options
.iter()
.filter_map(|opt| match opt {
CopyOption::DuckDbOption { name, value: v } => Some((name.value.clone(), value(v))),
_ => None,
})
.collect()
}
fn copy_option_is_on(name: &str, value: &CopyOptionValue) -> bool {
if name.eq_ignore_ascii_case("PARTITION_BY") {
*value != CopyOptionValue::EmptyList
} else if name.eq_ignore_ascii_case("PER_THREAD_OUTPUT") {
boolean_arg(value)
} else {
true
}
}
fn boolean_arg(value: &CopyOptionValue) -> bool {
match value {
CopyOptionValue::Boolean(b) => *b,
CopyOptionValue::Number(n) => n.parse::<f64>().map(|x| x != 0.0).unwrap_or(true),
CopyOptionValue::String(s) => cast_string_to_bool(s).unwrap_or(true),
CopyOptionValue::Absent | CopyOptionValue::EmptyList | CopyOptionValue::Other => true,
}
}
fn cast_string_to_bool(s: &str) -> Option<bool> {
match s.to_ascii_lowercase().as_str() {
"t" | "y" | "1" | "yes" | "true" => Some(true),
"f" | "n" | "0" | "no" | "false" => Some(false),
_ => None,
}
}
pub(crate) const SQL_READER_ENV: &str = "ARC_SQL_READER";
const DUCKDB_READER: &str = "duckdb";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reader {
Sqlparser,
DuckDb,
}
fn reader() -> Result<Reader, String> {
match std::env::var_os(SQL_READER_ENV) {
None => Ok(Reader::Sqlparser),
Some(value) if value == DUCKDB_READER => Ok(Reader::DuckDb),
Some(value) => Err(format!(
"{SQL_READER_ENV} is '{}', and the one value it takes is `{DUCKDB_READER}`, which \
reads a SQL step with DuckDB's own parser; unset it to read steps with arc's own",
value.to_string_lossy()
)),
}
}
fn duckdb_program() -> Result<OsString, String> {
match crate::engine::duckdb_program().map_err(|e| e.to_string())? {
crate::engine::DuckDbProgram::Told(path) => Ok(path.into_os_string()),
crate::engine::DuckDbProgram::SearchPath => Ok(OsString::from("duckdb")),
}
}
pub(crate) fn check_reader(has_sql_steps: bool) -> Result<(), String> {
if reader()? == Reader::DuckDb && has_sql_steps {
duckdb_lineage::read_step(&duckdb_program()?, "")
.map_err(|refusal| format!("{SQL_READER_ENV}={DUCKDB_READER}: {refusal}"))?;
}
Ok(())
}
fn read_with_duckdb(sql: &str) -> Result<Vec<SqlAssets>, Vec<String>> {
let program = duckdb_program().map_err(|e| vec![e])?;
let reading = duckdb_lineage::read_step(&program, sql).map_err(|e| vec![e.to_string()])?;
Ok(reading.statements.iter().map(duckdb_statement).collect())
}
pub fn extract_assets(sql: &str) -> Result<SqlAssets, Vec<String>> {
if reader().map_err(|e| vec![e])? == Reader::DuckDb {
let mut assets = SqlAssets::default();
for statement in read_with_duckdb(sql)? {
assets.outputs.extend(statement.outputs);
assets.inputs.extend(statement.inputs);
assets.kinds.extend(statement.kinds);
assets
.unread_table_functions
.extend(statement.unread_table_functions);
}
return Ok(assets);
}
let dialect = DuckDbDialect {};
let statements = Parser::parse_sql(&dialect, sql).map_err(|e| vec![e.to_string()])?;
let mut assets = SqlAssets::default();
for stmt in &statements {
extract_from_statement(stmt, &mut assets);
}
for cte_name in &assets.internal {
assets.inputs.remove(cte_name);
}
Ok(assets)
}
pub fn extract_per_statement(sql: &str) -> Result<Vec<SqlAssets>, Vec<String>> {
if reader().map_err(|e| vec![e])? == Reader::DuckDb {
return read_with_duckdb(sql);
}
sqlparser_per_statement(sql)
}
fn sqlparser_per_statement(sql: &str) -> Result<Vec<SqlAssets>, Vec<String>> {
let dialect = DuckDbDialect {};
let statements = Parser::parse_sql(&dialect, sql).map_err(|e| vec![e.to_string()])?;
let mut per_statement = Vec::with_capacity(statements.len());
for stmt in &statements {
let mut assets = SqlAssets::default();
extract_from_statement(stmt, &mut assets);
let internal: Vec<String> = assets.internal.iter().cloned().collect();
for cte_name in internal {
assets.inputs.remove(&cte_name);
}
per_statement.push(assets);
}
Ok(per_statement)
}
fn duckdb_statement(statement: &duckdb_lineage::Statement) -> SqlAssets {
let mut assets = SqlAssets::default();
let options: Vec<(String, CopyOptionValue)> = statement
.options
.iter()
.map(|option| (option.name.clone(), duckdb_copy_option(&option.value)))
.collect();
for produced in &statement.produces {
match produced {
Relation::Table(table) => {
assets.record_output(table.name.to_lowercase(), AssetKind::Table)
}
Relation::File(path) => {
assets.record_output(path.clone(), copy_to_target_kind(&options))
}
}
}
for read in &statement.reads {
match read {
Relation::Table(table) => {
assets.record_input(table.name.to_lowercase(), AssetKind::Table)
}
Relation::File(path) => assets.record_input(path.clone(), read_path_kind(path)),
}
}
for call in &statement.calls {
record_table_call(&duckdb_call(call), &mut assets);
}
assets
}
fn duckdb_copy_option(value: &Option<ArgValue>) -> CopyOptionValue {
match value {
None => CopyOptionValue::Absent,
Some(ArgValue::Identifier(word)) if word.eq_ignore_ascii_case("true") => {
CopyOptionValue::Boolean(true)
}
Some(ArgValue::Identifier(word)) if word.eq_ignore_ascii_case("false") => {
CopyOptionValue::Boolean(false)
}
Some(ArgValue::Number(n)) => CopyOptionValue::Number(n.clone()),
Some(ArgValue::String(s)) => CopyOptionValue::String(s.clone()),
Some(ArgValue::List(items)) if items.is_empty() => CopyOptionValue::EmptyList,
Some(_) => CopyOptionValue::Other,
}
}
fn duckdb_call(call: &TableCall) -> FunctionCall {
fn paths(value: &ArgValue, out: &mut Vec<String>) {
match value {
ArgValue::String(path) => out.push(path.clone()),
ArgValue::List(items) => items.iter().for_each(|item| paths(item, out)),
_ => {}
}
}
fn names_something(value: &ArgValue) -> bool {
match value {
ArgValue::String(_) | ArgValue::QuotedIdentifier(_) => true,
ArgValue::List(items) => items.iter().any(names_something),
ArgValue::Expression { quotes, .. } => *quotes,
ArgValue::Query | ArgValue::Number(_) | ArgValue::Identifier(_) => false,
}
}
let mut unnamed_paths = Vec::new();
for arg in call.args.iter().filter(|arg| arg.name.is_none()) {
paths(&arg.value, &mut unnamed_paths);
}
FunctionCall {
function: call.function.to_lowercase(),
paths: unnamed_paths,
names_something: call.args.iter().any(|arg| names_something(&arg.value)),
reads_a_query: call
.args
.iter()
.any(|arg| matches!(arg.value, ArgValue::Query)),
}
}
struct FunctionCall {
function: String,
paths: Vec<String>,
names_something: bool,
reads_a_query: bool,
}
fn record_table_call(call: &FunctionCall, assets: &mut SqlAssets) {
if is_file_reader(&call.function) || call.function == "glob" {
for path in &call.paths {
assets.record_input(path.clone(), read_path_kind(path));
}
} else if reads_no_table(&call.function) {
} else if call.names_something {
assets.unread_table_functions.insert(call.function.clone());
} else if call.reads_a_query {
} else {
assets.record_input(call.function.clone(), AssetKind::Table);
}
}
pub fn statement_byte_ranges(sql: &str) -> Vec<(usize, usize)> {
let bytes = sql.as_bytes();
let n = bytes.len();
let mut ranges = Vec::new();
let mut seg_start = 0usize;
let mut i = 0usize;
while i < n {
match bytes[i] {
b'\'' => i = skip_string(bytes, i, b'\''),
b'"' => i = skip_string(bytes, i, b'"'),
b'-' if i + 1 < n && bytes[i + 1] == b'-' => i = skip_line_comment(bytes, i),
b'/' if i + 1 < n && bytes[i + 1] == b'*' => i = skip_block_comment(bytes, i),
b'$' => match skip_dollar_quote(bytes, i) {
Some(j) => i = j,
None => i += 1,
},
b';' => {
if let Some(r) = trim_code_span(sql, seg_start, i + 1) {
ranges.push(r);
}
i += 1;
seg_start = i;
}
_ => i += 1,
}
}
if let Some(r) = trim_code_span(sql, seg_start, n) {
ranges.push(r);
}
ranges
}
fn skip_string(bytes: &[u8], open: usize, quote: u8) -> usize {
let n = bytes.len();
let mut i = open + 1;
while i < n {
if bytes[i] == quote {
if i + 1 < n && bytes[i + 1] == quote {
i += 2; } else {
return i + 1;
}
} else {
i += 1;
}
}
n
}
fn skip_line_comment(bytes: &[u8], open: usize) -> usize {
let n = bytes.len();
let mut i = open + 2;
while i < n && bytes[i] != b'\n' {
i += 1;
}
if i < n { i + 1 } else { n }
}
fn skip_block_comment(bytes: &[u8], open: usize) -> usize {
let n = bytes.len();
let mut i = open + 2;
while i + 1 < n {
if bytes[i] == b'*' && bytes[i + 1] == b'/' {
return i + 2;
}
i += 1;
}
n
}
fn skip_dollar_quote(bytes: &[u8], open: usize) -> Option<usize> {
let n = bytes.len();
let mut j = open + 1;
while j < n && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
j += 1;
}
if j >= n || bytes[j] != b'$' {
return None; }
let tag = &bytes[open..=j]; let mut i = j + 1;
while i < n {
if bytes[i] == b'$' && bytes[i..].starts_with(tag) {
return Some(i + tag.len());
}
i += 1;
}
Some(n)
}
fn trim_code_span(sql: &str, start: usize, end: usize) -> Option<(usize, usize)> {
let bytes = sql.as_bytes();
let mut i = start;
loop {
while i < end && bytes[i].is_ascii_whitespace() {
i += 1;
}
if i + 1 < end && bytes[i] == b'-' && bytes[i + 1] == b'-' {
i = skip_line_comment(bytes, i).min(end);
continue;
}
if i + 1 < end && bytes[i] == b'/' && bytes[i + 1] == b'*' {
i = skip_block_comment(bytes, i).min(end);
continue;
}
break;
}
if i >= end {
return None; }
let mut j = end;
while j > i && bytes[j - 1].is_ascii_whitespace() {
j -= 1;
}
Some((i, j))
}
fn extract_from_statement(stmt: &Statement, assets: &mut SqlAssets) {
match stmt {
Statement::CreateTable(create) => {
let name = object_name_to_string(&create.name);
assets.record_output(name, AssetKind::Table);
if let Some(ref query) = create.query {
extract_inputs_from_query(query, assets);
}
}
Statement::CreateView { name, query, .. } => {
assets.record_output(object_name_to_string(name), AssetKind::Table);
extract_inputs_from_query(query, assets);
}
Statement::Insert(Insert { table, source, .. }) => {
if let TableObject::TableName(name) = table {
assets.record_output(object_name_to_string(name), AssetKind::Table);
}
if let Some(src) = source {
extract_inputs_from_query(src.as_ref(), assets);
}
}
Statement::Copy {
source,
target,
options,
..
} => {
match source {
CopySource::Table { table_name, .. } => {
match target {
CopyTarget::File { filename } => {
assets
.record_input(object_name_to_string(table_name), AssetKind::Table);
assets.record_output(
filename.clone(),
copy_to_target_kind(&sqlparser_copy_options(options)),
);
}
CopyTarget::Stdout => {
assets
.record_input(object_name_to_string(table_name), AssetKind::Table);
}
CopyTarget::Stdin => {
assets
.record_output(object_name_to_string(table_name), AssetKind::Table);
}
_ => {}
}
}
CopySource::Query(query) => {
extract_inputs_from_query(query, assets);
if let CopyTarget::File { filename } = target {
assets.record_output(
filename.clone(),
copy_to_target_kind(&sqlparser_copy_options(options)),
);
}
}
}
}
Statement::Drop { names, .. } => {
for name in names {
assets.destroys.insert(object_name_to_string(name));
}
}
Statement::AlterTable { name, .. } => {
assets.record_output(object_name_to_string(name), AssetKind::Table);
}
Statement::AlterView { name, query, .. } => {
assets.record_output(object_name_to_string(name), AssetKind::Table);
extract_inputs_from_query(query, assets);
}
Statement::Merge { table, source, .. } => {
if let TableFactor::Table { name, .. } = table {
assets.record_output(object_name_to_string(name), AssetKind::Table);
}
extract_inputs_from_table_factor(source, assets);
}
Statement::Query(query) => {
extract_inputs_from_query(query, assets);
}
_ => {}
}
}
fn extract_inputs_from_query(query: &sqlparser::ast::Query, assets: &mut SqlAssets) {
extract_inputs_from_set_expr(&query.body, assets);
if let Some(with) = &query.with {
for cte in &with.cte_tables {
assets.internal.insert(cte.alias.name.value.to_lowercase());
extract_inputs_from_query(&cte.query, assets);
}
}
}
fn extract_inputs_from_set_expr(set_expr: &sqlparser::ast::SetExpr, assets: &mut SqlAssets) {
match set_expr {
sqlparser::ast::SetExpr::Select(select) => {
for table in &select.from {
extract_inputs_from_table_factor(&table.relation, assets);
for join in &table.joins {
extract_inputs_from_table_factor(&join.relation, assets);
}
}
}
sqlparser::ast::SetExpr::SetOperation { left, right, .. } => {
extract_inputs_from_set_expr(left, assets);
extract_inputs_from_set_expr(right, assets);
}
sqlparser::ast::SetExpr::Query(query) => {
extract_inputs_from_query(query, assets);
}
sqlparser::ast::SetExpr::Pivot(pivot) => {
extract_inputs_from_table_factor(&pivot.source, assets);
}
sqlparser::ast::SetExpr::Unpivot(unpivot) => {
extract_inputs_from_table_factor(&unpivot.source, assets);
}
_ => {}
}
}
fn extract_inputs_from_table_factor(factor: &TableFactor, assets: &mut SqlAssets) {
match factor {
TableFactor::Table { name, args, .. } => {
let fn_name = object_name_to_string(name);
match args {
Some(table_args) => {
let mut paths = Vec::new();
let mut reads_a_query = false;
for arg in &table_args.args {
if let Some(query) = query_argument(arg) {
extract_inputs_from_query(query, assets);
reads_a_query = true;
} else if let FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) = arg {
path_literals(expr, &mut paths);
}
}
let call = FunctionCall {
function: fn_name,
paths,
names_something: args_name_something(table_args),
reads_a_query,
};
record_table_call(&call, assets);
}
None => assets.record_input(fn_name, AssetKind::Table),
}
}
TableFactor::Derived { subquery, .. } => {
extract_inputs_from_query(subquery, assets);
}
TableFactor::NestedJoin {
table_with_joins, ..
} => {
extract_inputs_from_table_factor(&table_with_joins.relation, assets);
for join in &table_with_joins.joins {
extract_inputs_from_table_factor(&join.relation, assets);
}
}
TableFactor::Pivot { table, .. } => {
extract_inputs_from_table_factor(table, assets);
}
TableFactor::Unpivot { table, .. } => {
extract_inputs_from_table_factor(table, assets);
}
_ => {}
}
}
fn object_name_to_string(name: &ObjectName) -> String {
name.0
.last()
.and_then(|part| part.as_ident())
.map(|ident| ident.value.to_lowercase())
.unwrap_or_default()
}
fn is_file_reader(fn_name: &str) -> bool {
const READERS: [&str; 17] = [
"read_parquet",
"parquet_scan",
"read_csv",
"read_csv_auto",
"read_json",
"read_json_auto",
"read_json_objects",
"read_ndjson",
"read_ndjson_auto",
"read_ndjson_objects",
"read_text",
"read_blob",
"read_xlsx",
"read_xml",
"read_dta",
"read_stat",
"st_read",
];
READERS.contains(&fn_name)
}
fn reads_no_table(fn_name: &str) -> bool {
const NO_TABLE: [&str; 5] = [
"range",
"generate_series",
"duckdb_functions",
"duckdb_tables",
"duckdb_secrets",
];
NO_TABLE.contains(&fn_name)
}
fn args_name_something(table_args: &TableFunctionArgs) -> bool {
table_args
.args
.iter()
.filter(|arg| query_argument(arg).is_none())
.any(arg_names_something)
}
fn arg_names_something(arg: &FunctionArg) -> bool {
visit_expressions(arg, |expr| {
let names = match expr {
Expr::Value(v) => matches!(
v.value,
Value::SingleQuotedString(_)
| Value::DoubleQuotedString(_)
| Value::DollarQuotedString(_)
| Value::EscapedStringLiteral(_)
| Value::NationalStringLiteral(_)
),
Expr::Identifier(ident) => ident.quote_style.is_some(),
Expr::CompoundIdentifier(parts) => parts.iter().any(|p| p.quote_style.is_some()),
_ => false,
};
if names {
ControlFlow::Break(())
} else {
ControlFlow::Continue(())
}
})
.is_break()
}
fn query_argument(arg: &FunctionArg) -> Option<&sqlparser::ast::Query> {
fn query(expr: &Expr) -> Option<&sqlparser::ast::Query> {
match expr {
Expr::Subquery(query) => Some(query),
Expr::Nested(inner) => query(inner),
_ => None,
}
}
match arg {
FunctionArg::Named {
arg: FunctionArgExpr::Expr(expr),
..
}
| FunctionArg::ExprNamed {
arg: FunctionArgExpr::Expr(expr),
..
}
| FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => query(expr),
_ => None,
}
}
fn path_literals(expr: &Expr, paths: &mut Vec<String>) {
match expr {
Expr::Value(v) => {
if let Value::SingleQuotedString(path) = &v.value {
paths.push(path.clone());
}
}
Expr::Array(array) => {
for elem in &array.elem {
path_literals(elem, paths);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::duckdb_lineage::recorded_steps;
fn read_by_duckdb(sql: &str) -> Vec<SqlAssets> {
recorded_steps::read(sql)
.iter()
.map(duckdb_statement)
.collect()
}
fn read_by_sqlparser(sql: &str) -> Vec<SqlAssets> {
sqlparser_per_statement(sql).unwrap_or_else(|e| panic!("sqlparser refused {sql:?}: {e:?}"))
}
fn set(names: &[&str]) -> BTreeSet<String> {
names.iter().map(ToString::to_string).collect()
}
#[test]
fn duckdb_reads_an_asof_join_sqlparser_cannot_parse() {
let sql = recorded_steps::ASOF;
assert!(
sqlparser_per_statement(sql).is_err(),
"sqlparser parses {sql:?} now; the step no longer shows what the switch is for"
);
let read = read_by_duckdb(sql);
assert_eq!(read.len(), 1);
assert_eq!(read[0].outputs, set(&["r"]), "{sql}");
assert_eq!(read[0].inputs, set(&["t", "u"]), "{sql}");
assert_eq!(read[0].kinds.get("r"), Some(&AssetKind::Table));
}
#[test]
fn table_functions_are_read_by_the_same_rules_under_either_reader() {
for sql in [
recorded_steps::READ_XLSX,
recorded_steps::RANGE,
recorded_steps::MLPACK,
] {
assert_eq!(read_by_duckdb(sql), read_by_sqlparser(sql), "{sql}");
}
let xlsx = &read_by_duckdb(recorded_steps::READ_XLSX)[0];
assert_eq!(
xlsx.inputs,
set(&["build/budget.xlsx"]),
"read_xlsx reads its file"
);
assert_eq!(xlsx.kinds.get("build/budget.xlsx"), Some(&AssetKind::File));
let range = &read_by_duckdb(recorded_steps::RANGE)[0];
assert_eq!(range.inputs, set(&[]), "range reads no table");
assert_eq!(range.unread_table_functions, set(&[]));
let mlpack = &read_by_duckdb(recorded_steps::MLPACK)[0];
assert_eq!(
mlpack.inputs,
set(&[]),
"the call names no table arc can read"
);
assert_eq!(
mlpack.unread_table_functions,
set(&["mlpack_random_forest_train"]),
"the call is listed for the warning that asks for depends_on: and produces:"
);
}
#[test]
fn a_table_function_called_on_a_subquery_is_read_by_the_same_rules_under_either_reader() {
let cases: [(&str, &[&str], &[&str]); 3] = [
(recorded_steps::SUBQUERY_CALL, &["edges"], &[]),
(recorded_steps::SUBQUERY_WITH_STRING, &["edges"], &[]),
(
recorded_steps::SUBQUERY_BESIDE_STRING,
&["train"],
&["my_ext_fit"],
),
];
for (sql, reads, unread) in cases {
let by_duckdb = read_by_duckdb(sql);
assert_eq!(by_duckdb, read_by_sqlparser(sql), "{sql}");
assert_eq!(by_duckdb[0].inputs, set(reads), "reads of {sql}");
assert_eq!(
by_duckdb[0].unread_table_functions,
set(unread),
"unread functions of {sql}"
);
}
}
#[test]
fn a_copy_is_read_by_the_same_rules_under_either_reader() {
for (sql, file, kind) in [
(recorded_steps::COPY_QUERY, "out.csv", AssetKind::File),
(
recorded_steps::COPY_PARTITIONED,
"parts",
AssetKind::Directory,
),
(
recorded_steps::COPY_ONE_FILE_PER_THREAD_OFF,
"one.parquet",
AssetKind::File,
),
] {
let read = read_by_duckdb(sql);
assert_eq!(read, read_by_sqlparser(sql), "{sql}");
assert_eq!(read[0].outputs, set(&[file]), "{sql}");
assert_eq!(read[0].kinds.get(file), Some(&kind), "{sql}");
assert_eq!(read[0].inputs, set(&["a"]), "{sql}");
}
}
#[test]
fn a_number_the_rule_cannot_read_leaves_per_thread_output_on() {
let value = duckdb_copy_option(&Some(ArgValue::Number("1_000".to_string())));
assert_eq!(value, CopyOptionValue::Number("1_000".to_string()));
assert_eq!(
copy_to_target_kind(&[("PER_THREAD_OUTPUT".to_string(), value)]),
AssetKind::Directory,
"PER_THREAD_OUTPUT 1_000 writes a directory"
);
}
#[test]
fn a_pivot_joined_to_another_table_is_not_read_as_its_first() {
let read = read_by_duckdb(recorded_steps::PIVOT_JOIN);
assert_eq!(read.len(), 1);
assert_ne!(
read[0].inputs,
set(&["t"]),
"the pivot reads the rows of t and u, and is read as reading t alone"
);
assert_eq!(read[0].inputs, set(&[]), "the statement is unread");
}
#[test]
fn test_create_table_output() {
let sql = "CREATE TABLE customers (id INT, name TEXT);";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("customers"));
assert!(assets.inputs.is_empty());
}
#[test]
fn test_create_view_output() {
let sql = "CREATE VIEW active_customers AS SELECT * FROM customers WHERE active = true;";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("active_customers"));
assert!(assets.inputs.contains("customers"));
}
#[test]
fn test_ctas_output_and_inputs() {
let sql = "CREATE TABLE summary AS SELECT count(*) AS total FROM orders;";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("summary"));
assert!(assets.inputs.contains("orders"));
}
#[test]
fn test_multiple_creates() {
let sql = "CREATE TABLE foo (id INT);\nCREATE TABLE bar (id INT);\nCREATE VIEW baz AS SELECT * FROM foo;";
let assets = extract_assets(sql).unwrap();
assert_eq!(
assets.outputs,
BTreeSet::from(["foo".into(), "bar".into(), "baz".into()])
);
assert!(assets.inputs.contains("foo"));
}
#[test]
fn test_from_clause_inputs() {
let sql = "SELECT * FROM customers;";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
assert!(assets.outputs.is_empty());
}
#[test]
fn test_join_inputs() {
let sql = "SELECT c.name, o.total FROM customers c JOIN orders o ON c.id = o.customer_id;";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
assert!(assets.inputs.contains("orders"));
}
#[test]
fn test_subquery_inputs() {
let sql = "SELECT * FROM (SELECT * FROM raw_data) sub;";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("raw_data"));
}
#[test]
fn test_insert_into_output() {
let sql = "INSERT INTO summary SELECT count(*) FROM customers;";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("summary"));
assert!(assets.inputs.contains("customers"));
}
#[test]
fn test_copy_to_file() {
let sql = "COPY customers TO 'customers.csv';";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
}
#[test]
fn test_unparseable_sql() {
let sql = "THIS IS NOT VALID SQL AT ALL %%%";
let result = extract_assets(sql);
assert!(result.is_err());
}
#[test]
fn test_union_all_inputs() {
let sql = "SELECT * FROM customers UNION ALL SELECT * FROM archived_customers;";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
assert!(assets.inputs.contains("archived_customers"));
}
#[test]
fn test_ctas_union_inputs() {
let sql = "CREATE TABLE all_customers AS SELECT * FROM customers UNION ALL SELECT * FROM archived_customers;";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("all_customers"));
assert!(assets.inputs.contains("customers"));
assert!(assets.inputs.contains("archived_customers"));
}
#[test]
fn test_except_inputs() {
let sql = "SELECT id FROM customers EXCEPT SELECT id FROM blocklist;";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
assert!(assets.inputs.contains("blocklist"));
}
#[test]
fn test_cte_internal_not_inputs() {
let sql =
"WITH recent AS (SELECT * FROM orders WHERE date > '2026-01-01') SELECT * FROM recent;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.inputs.contains("orders"),
"real table should be in inputs"
);
assert!(
!assets.inputs.contains("recent"),
"CTE name should NOT be in inputs"
);
assert!(
assets.internal.contains("recent"),
"CTE name should be in internal"
);
}
#[test]
fn test_qualified_name() {
let sql = "CREATE TABLE main.customers (id INT);";
let assets = extract_assets(sql).unwrap();
assert!(assets.outputs.contains("customers"));
}
#[test]
fn test_empty_sql() {
let sql = "-- just a comment";
let result = extract_assets(sql);
if let Ok(assets) = result {
assert!(assets.outputs.is_empty());
assert!(assets.inputs.is_empty());
}
}
#[test]
fn test_nested_ctes_in_internal() {
let sql = "WITH a AS (SELECT * FROM raw_data), b AS (SELECT * FROM a) SELECT * FROM b;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.internal.contains("a"),
"CTE 'a' should be in internal"
);
assert!(
assets.internal.contains("b"),
"CTE 'b' should be in internal"
);
assert!(
assets.inputs.contains("raw_data"),
"real table should be in inputs"
);
assert!(
!assets.inputs.contains("a"),
"CTE 'a' should NOT be in inputs"
);
assert!(
!assets.inputs.contains("b"),
"CTE 'b' should NOT be in inputs"
);
}
#[test]
fn test_cte_shadows_real_table() {
let sql = "WITH customers AS (SELECT * FROM raw_customers) SELECT * FROM customers;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.internal.contains("customers"),
"CTE 'customers' should be in internal"
);
assert!(
assets.inputs.contains("raw_customers"),
"real table should be in inputs"
);
assert!(
!assets.inputs.contains("customers"),
"CTE 'customers' should NOT be in inputs"
);
}
#[test]
fn test_drop_table_destroys() {
let sql = "DROP TABLE foo;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.destroys.contains("foo"),
"dropped table should be in destroys"
);
assert!(assets.outputs.is_empty(), "drop should not add to outputs");
assert!(assets.inputs.is_empty(), "drop should not add to inputs");
}
#[test]
fn test_drop_view_destroys() {
let sql = "DROP VIEW IF EXISTS my_view;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.destroys.contains("my_view"),
"dropped view should be in destroys"
);
}
#[test]
fn test_drop_then_create() {
let sql = "DROP TABLE IF EXISTS foo; CREATE TABLE foo AS SELECT * FROM bar;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.destroys.contains("foo"),
"dropped table should be in destroys"
);
assert!(
assets.outputs.contains("foo"),
"created table should be in outputs"
);
assert!(
assets.inputs.contains("bar"),
"source table should be in inputs"
);
}
#[test]
fn test_alter_table_outputs_only() {
let sql = "ALTER TABLE customers ADD COLUMN email TEXT;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.outputs.contains("customers"),
"altered table should be in outputs"
);
assert!(assets.inputs.is_empty(), "alter should not add to inputs");
}
#[test]
fn test_merge_into() {
let sql = "MERGE INTO target USING source ON target.id = source.id WHEN MATCHED THEN UPDATE SET target.name = source.name;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.outputs.contains("target"),
"merge target should be in outputs"
);
assert!(
assets.inputs.contains("source"),
"merge source should be in inputs"
);
}
#[test]
fn test_create_or_replace() {
let sql = "CREATE OR REPLACE TABLE foo AS SELECT * FROM bar;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.outputs.contains("foo"),
"replaced table should be in outputs"
);
assert!(
assets.inputs.contains("bar"),
"source table should be in inputs"
);
}
#[test]
fn test_pivot_source_table() {
let sql =
"SELECT * FROM monthly_sales PIVOT (SUM(amount) FOR month IN ('Jan', 'Feb', 'Mar'));";
let result = extract_assets(sql);
match result {
Ok(assets) => {
assert!(
assets.inputs.contains("monthly_sales"),
"pivot source should be in inputs"
);
}
Err(_) => {
}
}
}
#[test]
fn test_unpivot_source_table() {
let sql = "SELECT * FROM quarterly_report UNPIVOT (value FOR quarter IN (q1, q2, q3, q4));";
let result = extract_assets(sql);
match result {
Ok(assets) => {
assert!(
assets.inputs.contains("quarterly_report"),
"unpivot source should be in inputs"
);
}
Err(_) => {
}
}
}
#[test]
fn test_recursive_cte() {
let sql = "WITH RECURSIVE tree AS (SELECT id, parent_id FROM nodes WHERE parent_id IS NULL UNION ALL SELECT n.id, n.parent_id FROM nodes n JOIN tree t ON n.parent_id = t.id) SELECT * FROM tree;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.internal.contains("tree"),
"recursive CTE should be in internal"
);
assert!(
assets.inputs.contains("nodes"),
"real table should be in inputs"
);
assert!(
!assets.inputs.contains("tree"),
"CTE should NOT be in inputs"
);
}
#[test]
fn test_cte_with_subquery() {
let sql = "WITH a AS (SELECT * FROM (SELECT * FROM raw) sub) SELECT * FROM a;";
let assets = extract_assets(sql).unwrap();
assert!(assets.internal.contains("a"), "CTE should be in internal");
assert!(
assets.inputs.contains("raw"),
"subquery source should be in inputs"
);
assert!(!assets.inputs.contains("a"), "CTE should NOT be in inputs");
}
#[test]
fn test_alter_view_outputs_and_inputs() {
let sql = "ALTER VIEW active_customers AS SELECT * FROM customers WHERE active = true;";
let assets = extract_assets(sql).unwrap();
assert!(
assets.outputs.contains("active_customers"),
"altered view should be in outputs"
);
assert!(
assets.inputs.contains("customers"),
"source table should be in inputs"
);
}
#[test]
fn test_drop_multiple_tables() {
let sql = "DROP TABLE foo, bar, baz;";
let assets = extract_assets(sql).unwrap();
assert!(assets.destroys.contains("foo"));
assert!(assets.destroys.contains("bar"));
assert!(assets.destroys.contains("baz"));
}
#[test]
fn test_read_parquet_lifts_path() {
let sql = "CREATE TABLE t AS SELECT * FROM read_parquet('build/edgar.parquet');";
let assets = extract_assets(sql).unwrap();
assert!(
assets.inputs.contains("build/edgar.parquet"),
"path is the input"
);
assert!(
!assets.inputs.contains("read_parquet"),
"fn name is not an input"
);
assert!(assets.outputs.contains("t"));
}
#[test]
fn test_read_csv_preserves_case() {
let sql = "SELECT * FROM read_csv('Data/Raw/GLEIF.csv');";
let assets = extract_assets(sql).unwrap();
assert!(
assets.inputs.contains("Data/Raw/GLEIF.csv"),
"path case preserved"
);
assert!(!assets.inputs.contains("read_csv"));
}
#[test]
fn test_read_json_list_and_options() {
let sql = "CREATE TABLE brew AS SELECT * FROM read_json(['a/30d.json', 'a/90d.json'], format = 'array');";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("a/30d.json"), "first path lifted");
assert!(assets.inputs.contains("a/90d.json"), "second path lifted");
assert!(
!assets.inputs.contains("read_json"),
"fn name is not an input"
);
assert!(
!assets.inputs.contains("array"),
"option value is not an input"
);
assert!(assets.outputs.contains("brew"));
}
#[test]
fn test_five_new_readers_lift_path_not_function_name() {
for (reader, path) in [
("read_xlsx", "build/budget.xlsx"),
("read_xml", "build/catalog.xml"),
("read_dta", "build/survey.dta"),
("read_stat", "build/survey.sas7bdat"),
("ST_Read", "data/shapes.shp"),
] {
let sql = format!("CREATE TABLE r AS SELECT * FROM {reader}('{path}');");
let assets = extract_assets(&sql).unwrap();
assert!(
assets.inputs.contains(path),
"{reader}: path {path} should be the input, got {:?}",
assets.inputs
);
assert!(
!assets.inputs.contains(&reader.to_lowercase()),
"{reader}: fn name must not be recorded as a table input"
);
assert_eq!(assets.kinds.get(path), Some(&AssetKind::File));
}
}
#[test]
fn test_five_new_readers_glob_path_is_a_pattern() {
for (reader, pattern) in [
("read_xlsx", "build/*.xlsx"),
("read_xml", "build/report-?.xml"),
("ST_Read", "data/[a-z].shp"),
] {
let sql = format!("CREATE TABLE r AS SELECT * FROM {reader}('{pattern}');");
let assets = extract_assets(&sql).unwrap();
assert_eq!(
assets.kinds.get(pattern),
Some(&AssetKind::Pattern),
"{reader}: {pattern} should be a Pattern"
);
}
}
#[test]
fn test_row_generators_and_catalog_functions_read_no_table() {
for call in [
"range(3)",
"generate_series(1, 5)",
"duckdb_functions()",
"duckdb_tables()",
"duckdb_secrets()",
] {
let sql = format!("CREATE TABLE c AS SELECT * FROM {call};");
let assets = extract_assets(&sql).unwrap();
assert!(
assets.inputs.is_empty(),
"{call}: no table should be read, got {:?}",
assets.inputs
);
}
}
#[test]
fn test_glob_function_lifts_pattern_not_table_name() {
let sql = "CREATE TABLE c AS SELECT * FROM glob('data/*.csv');";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("data/*.csv"), "pattern is the input");
assert!(!assets.inputs.contains("glob"), "fn name is not an input");
assert_eq!(assets.kinds.get("data/*.csv"), Some(&AssetKind::Pattern));
}
#[test]
fn test_table_function_with_quoted_identifier_args_is_unread_not_recorded() {
let sql = r#"CREATE TABLE fitted AS SELECT * FROM mlpack_random_forest_train("X", "Y", "params", "model");"#;
let assets = extract_assets(sql).unwrap();
assert!(
!assets.inputs.contains("mlpack_random_forest_train"),
"the function's name is not a table, got {:?}",
assets.inputs
);
assert!(
assets.inputs.is_empty(),
"no input at all: {:?}",
assets.inputs
);
assert_eq!(
assets.unread_table_functions,
BTreeSet::from(["mlpack_random_forest_train".to_string()])
);
assert!(
assets.outputs.contains("fitted"),
"the CTAS target is still produced"
);
}
#[test]
fn test_table_function_with_a_string_arg_is_unread() {
for sql in [
"SELECT * FROM query('SELECT 1');",
"SELECT * FROM my_ext_scan(['a', 'b']);",
"SELECT * FROM my_ext_scan(3, source := 'orders');",
"SELECT * FROM my_ext.my_ext_scan('orders');",
] {
let assets = extract_assets(sql).unwrap();
assert!(
assets.inputs.is_empty(),
"{sql}: records no input, got {:?}",
assets.inputs
);
assert_eq!(
assets.unread_table_functions.len(),
1,
"{sql}: one function is unread, got {:?}",
assets.unread_table_functions
);
}
}
#[test]
fn test_unread_table_functions_are_a_set_of_names() {
let sql = "SELECT * FROM my_scan('a'); SELECT * FROM my_scan('b'); SELECT * FROM other_scan('c');";
let assets = extract_assets(sql).unwrap();
assert_eq!(
assets.unread_table_functions,
BTreeSet::from(["my_scan".to_string(), "other_scan".to_string()])
);
}
#[test]
fn test_table_function_with_no_string_or_quoted_identifier_is_not_unread() {
for (sql, records) in [
("SELECT * FROM range(10);", None),
("SELECT * FROM recent();", Some("recent")),
(
"SELECT * FROM recent(days := 7, strict := true);",
Some("recent"),
),
(
"SELECT * FROM my_ext_scan(10, 2.5, x);",
Some("my_ext_scan"),
),
("SELECT * FROM my_ext_scan([1, 2]);", Some("my_ext_scan")),
("SELECT * FROM my_ext_scan(1 + 2, -3);", Some("my_ext_scan")),
] {
let assets = extract_assets(sql).unwrap();
assert!(
assets.unread_table_functions.is_empty(),
"{sql}: not unread, got {:?}",
assets.unread_table_functions
);
assert_eq!(
assets.inputs,
records
.map(str::to_string)
.into_iter()
.collect::<BTreeSet<_>>(),
"{sql}: inputs"
);
}
}
#[test]
fn test_table_function_called_on_a_subquery_reads_the_subquerys_tables() {
for (sql, reads) in [
(
"CREATE TABLE paths AS SELECT * FROM onager_pth_dijkstra((SELECT src, dst, w FROM edges));",
&["edges"][..],
),
(
"CREATE TABLE r AS SELECT * FROM onager_pth_dijkstra((SELECT src, dst, w FROM (SELECT * FROM edges) e));",
&["edges"],
),
(
"CREATE TABLE r AS SELECT * FROM summary((SELECT e.src FROM edges e JOIN nodes n ON n.id = e.src));",
&["edges", "nodes"],
),
(
"CREATE TABLE r AS SELECT * FROM range((SELECT count(*) FROM edges));",
&["edges"],
),
(
"CREATE TABLE r AS SELECT * FROM my_ext_scan(source := (SELECT src FROM edges));",
&["edges"],
),
(
"CREATE TABLE r AS SELECT * FROM my_ext_scan(((SELECT src FROM edges)));",
&["edges"],
),
] {
let assets = extract_assets(sql).unwrap();
assert_eq!(assets.inputs, set(reads), "{sql}: the subquery's tables");
assert!(
assets.unread_table_functions.is_empty(),
"{sql}: the call is read, got {:?}",
assets.unread_table_functions
);
assert_eq!(assets.outputs.len(), 1, "{sql}: the target is produced");
}
}
#[test]
fn test_two_table_functions_called_on_subqueries_and_joined_read_the_table() {
let sql = "CREATE TEMP TABLE result AS \
SELECT d.node_id, CASE WHEN isinf(d.distance) THEN NULL ELSE d.distance END AS distance, c.component \
FROM onager_pth_dijkstra((SELECT src, dst, w FROM edges), directed := false, source := 0) d \
JOIN onager_cmm_components((SELECT src, dst FROM edges)) c USING (node_id);";
let assets = extract_assets(sql).unwrap();
assert_eq!(assets.inputs, set(&["edges"]));
assert_eq!(assets.outputs, set(&["result"]));
assert!(assets.unread_table_functions.is_empty());
}
#[test]
fn test_a_string_inside_a_subquery_argument_does_not_make_the_call_unread() {
for sql in [
"CREATE TABLE r AS SELECT * FROM summary((SELECT src, dst FROM edges WHERE kind = 'road'));",
r#"CREATE TABLE r AS SELECT * FROM summary((SELECT "src" FROM edges));"#,
] {
let assets = extract_assets(sql).unwrap();
assert_eq!(assets.inputs, set(&["edges"]), "{sql}");
assert!(
assets.unread_table_functions.is_empty(),
"{sql}: not unread, got {:?}",
assets.unread_table_functions
);
}
}
#[test]
fn test_a_string_beside_a_subquery_argument_keeps_the_call_unread() {
for sql in [
"CREATE TABLE m AS SELECT * FROM my_ext_fit((SELECT x, y FROM train), 'model');",
r#"CREATE TABLE m AS SELECT * FROM my_ext_fit((SELECT x, y FROM train), "model");"#,
"CREATE TABLE m AS SELECT * FROM my_ext_fit((SELECT x, y FROM train), target := 'model');",
] {
let assets = extract_assets(sql).unwrap();
assert_eq!(
assets.inputs,
set(&["train"]),
"{sql}: no read named for the call"
);
assert_eq!(
assets.unread_table_functions,
set(&["my_ext_fit"]),
"{sql}: the call is listed for the warning"
);
}
}
#[test]
fn test_copy_to_produces_file() {
let sql = "COPY ranking TO 'data/ranking.parquet';";
let assets = extract_assets(sql).unwrap();
assert!(assets.inputs.contains("ranking"), "table is read");
assert!(
assets.outputs.contains("data/ranking.parquet"),
"file is produced"
);
}
#[test]
fn test_copy_from_query_produces_file() {
let sql = "COPY (SELECT * FROM a WHERE x > 1) TO 'out.csv' (HEADER);";
let assets = extract_assets(sql).unwrap();
assert_eq!(
assets.inputs,
BTreeSet::from(["a".to_string()]),
"the query's table is read"
);
assert_eq!(
assets.outputs,
BTreeSet::from(["out.csv".to_string()]),
"the file is produced, and nothing else is"
);
assert_eq!(assets.kinds.get("out.csv"), Some(&AssetKind::File));
}
#[test]
fn test_copy_from_query_reads_every_table_its_query_reads() {
let sql = "COPY (WITH c AS (SELECT * FROM a) \
SELECT * FROM c JOIN b ON c.x = b.x) \
TO 'out.csv';";
let assets = extract_per_statement(sql).unwrap().remove(0);
assert_eq!(
assets.inputs,
["a", "b"].map(str::to_string).into_iter().collect(),
"tables the query reads"
);
assert_eq!(assets.outputs, BTreeSet::from(["out.csv".to_string()]));
}
#[test]
fn test_copy_from_query_target_kind_follows_the_options_as_copy_from_table_does() {
for (options, expected) in [
("(HEADER)", AssetKind::File),
("(FORMAT parquet)", AssetKind::File),
(
"(FORMAT parquet, PARTITION_BY (region))",
AssetKind::Directory,
),
("(PER_THREAD_OUTPUT)", AssetKind::Directory),
("(PER_THREAD_OUTPUT false)", AssetKind::File),
("(FORMAT csv, FILE_SIZE_BYTES '1GB')", AssetKind::Directory),
(
"(FORMAT parquet, ROW_GROUPS_PER_FILE 2)",
AssetKind::Directory,
),
] {
let from_table = extract_assets(&format!("COPY a TO 'out' {options};")).unwrap();
let from_query =
extract_assets(&format!("COPY (SELECT * FROM a) TO 'out' {options};")).unwrap();
assert_eq!(
from_table.kinds.get("out"),
Some(&expected),
"COPY a TO 'out' {options}"
);
assert_eq!(
from_query.kinds.get("out"),
Some(&expected),
"COPY (SELECT * FROM a) TO 'out' {options}"
);
}
}
#[test]
fn test_copy_from_query_to_stdout_produces_nothing() {
let assets = extract_assets("COPY (SELECT * FROM a) TO STDOUT;").unwrap();
assert_eq!(assets.inputs, BTreeSet::from(["a".to_string()]));
assert!(
assets.outputs.is_empty(),
"no output for STDOUT, got {:?}",
assets.outputs
);
}
#[test]
fn test_copy_table_forms_record_what_they_recorded_before() {
let to = extract_assets("COPY a TO 'out.csv' (HEADER);").unwrap();
assert_eq!(to.inputs, BTreeSet::from(["a".to_string()]), "TO: reads");
assert_eq!(
to.outputs,
BTreeSet::from(["out.csv".to_string()]),
"TO: produces"
);
assert_eq!(to.kinds.get("out.csv"), Some(&AssetKind::File));
let from = extract_assets("COPY a FROM 'in.csv';").unwrap();
assert_eq!(
from.inputs,
BTreeSet::from(["a".to_string()]),
"FROM: reads"
);
assert_eq!(
from.outputs,
BTreeSet::from(["in.csv".to_string()]),
"FROM: produces"
);
assert_eq!(from.kinds.get("in.csv"), Some(&AssetKind::File));
}
#[test]
fn test_non_file_table_function_unchanged() {
let sql = "SELECT * FROM recent();";
let assets = extract_assets(sql).unwrap();
assert!(
assets.inputs.contains("recent"),
"a table macro called with no string or quoted identifier still records its name"
);
}
#[test]
fn byte_ranges_split_top_level_statements() {
let sql = "CREATE TABLE foo (id INT);\nSELECT * FROM foo;";
let ranges = statement_byte_ranges(sql);
assert_eq!(ranges.len(), 2);
assert_eq!(&sql[ranges[0].0..ranges[0].1], "CREATE TABLE foo (id INT);");
assert_eq!(&sql[ranges[1].0..ranges[1].1], "SELECT * FROM foo;");
assert_eq!(ranges.len(), extract_per_statement(sql).unwrap().len());
}
#[test]
fn byte_ranges_ignore_semicolons_in_strings() {
let sql = "INSERT INTO t VALUES ('a;b;c');";
let ranges = statement_byte_ranges(sql);
assert_eq!(ranges.len(), 1);
assert_eq!(
&sql[ranges[0].0..ranges[0].1],
"INSERT INTO t VALUES ('a;b;c');"
);
}
#[test]
fn byte_ranges_drop_comment_and_blank_segments() {
let sql = "-- header comment\nSELECT 1; /* trailing */ \n\n";
let ranges = statement_byte_ranges(sql);
assert_eq!(ranges.len(), 1);
assert_eq!(&sql[ranges[0].0..ranges[0].1], "SELECT 1;");
}
#[test]
fn byte_ranges_ignore_semicolons_in_line_comments() {
let sql = "SELECT 1 -- a; b; c\nFROM t;";
let ranges = statement_byte_ranges(sql);
assert_eq!(ranges.len(), 1);
assert_eq!(&sql[ranges[0].0..ranges[0].1], sql.trim_end_matches('\n'));
}
#[test]
fn byte_ranges_ignore_semicolons_in_dollar_quotes() {
let sql = "SELECT $$a; b; c$$ AS s;";
let ranges = statement_byte_ranges(sql);
assert_eq!(ranges.len(), 1);
assert_eq!(&sql[ranges[0].0..ranges[0].1], "SELECT $$a; b; c$$ AS s;");
}
#[test]
fn test_pivot_statement_lifts_source() {
let sql = "PIVOT monthly_sales ON month USING SUM(amount) GROUP BY country;";
let assets = extract_assets(sql).expect("statement-form PIVOT must parse");
assert!(
assets.inputs.contains("monthly_sales"),
"pivot source should be an input, got {:?}",
assets.inputs
);
}
#[test]
fn test_pivot_statement_as_ctas_body() {
let sql =
"CREATE OR REPLACE TABLE installs AS PIVOT wide_sales ON days USING SUM(installs);";
let assets = extract_assets(sql).expect("CTAS over a PIVOT must parse");
assert!(
assets.outputs.contains("installs"),
"CTAS output discovered"
);
assert!(
assets.inputs.contains("wide_sales"),
"pivot source lifted as input, got {:?}",
assets.inputs
);
}
#[test]
fn test_pivot_ctas_with_cte_shape() {
let sql = "CREATE OR REPLACE TABLE installs AS \
WITH install_counts AS (SELECT category, name, days, installs FROM categories) \
PIVOT install_counts ON days USING SUM(installs);";
let assets = extract_assets(sql).expect("brewtrend-shape PIVOT must parse");
assert!(assets.outputs.contains("installs"), "output discovered");
assert!(
assets.inputs.contains("categories"),
"real underlying table is the input, got {:?}",
assets.inputs
);
assert!(
!assets.inputs.contains("install_counts"),
"the pivoted CTE name is internal, not an external input"
);
assert!(
assets.internal.contains("install_counts"),
"CTE tracked as internal"
);
}
#[test]
fn test_unpivot_statement_lifts_source() {
let sql = "UNPIVOT quarterly_report ON q1, q2, q3, q4 INTO NAME quarter VALUE amount;";
let assets = extract_assets(sql).expect("statement-form UNPIVOT must parse");
assert!(
assets.inputs.contains("quarterly_report"),
"unpivot source should be an input, got {:?}",
assets.inputs
);
}
#[test]
fn test_unpivot_statement_shorthand() {
let sql = "UNPIVOT sensor_readings ON temp, humidity, pressure;";
let assets = extract_assets(sql).expect("shorthand UNPIVOT must parse");
assert!(assets.inputs.contains("sensor_readings"), "source lifted");
}
#[test]
fn test_multi_option_copy() {
let sql = "COPY ranking TO 'data/ranking.parquet' (FORMAT parquet, COMPRESSION zstd);";
let assets = extract_assets(sql).expect("multi-option COPY must parse");
assert!(assets.inputs.contains("ranking"), "table is read");
assert!(
assets.outputs.contains("data/ranking.parquet"),
"file is produced (file-path lineage), got {:?}",
assets.outputs
);
}
#[test]
fn test_copy_partition_by() {
let sql = "COPY orders TO 'out/orders' (FORMAT parquet, PARTITION_BY (year, month), OVERWRITE_OR_IGNORE);";
let assets = extract_assets(sql).expect("COPY with PARTITION_BY must parse");
assert!(assets.inputs.contains("orders"), "table is read");
assert!(
assets.outputs.contains("out/orders"),
"output path produced"
);
assert_eq!(
assets.kinds.get("out/orders"),
Some(&AssetKind::Directory),
"PARTITION_BY target must be classified as a directory, not a file"
);
}
#[test]
fn test_copy_without_a_directory_writing_option_is_a_file() {
let sql = "COPY orders TO 'out/orders.parquet' (FORMAT parquet);";
let assets = extract_assets(sql).expect("plain COPY must parse");
assert_eq!(
assets.kinds.get("out/orders.parquet"),
Some(&AssetKind::File)
);
}
#[test]
fn test_per_thread_output_target_is_a_directory() {
let sql = "COPY orders TO 'out/pto' (FORMAT parquet, PER_THREAD_OUTPUT true);";
let assets = extract_assets(sql).expect("PER_THREAD_OUTPUT COPY must parse");
assert_eq!(assets.kinds.get("out/pto"), Some(&AssetKind::Directory));
}
#[test]
fn test_per_thread_output_bare_flag_is_a_directory() {
let sql = "COPY orders TO 'out/pto' (FORMAT parquet, PER_THREAD_OUTPUT);";
let assets = extract_assets(sql).expect("bare-flag COPY must parse");
assert_eq!(assets.kinds.get("out/pto"), Some(&AssetKind::Directory));
}
#[test]
fn test_per_thread_output_false_is_a_file() {
let sql = "COPY orders TO 'out/one.parquet' (FORMAT parquet, PER_THREAD_OUTPUT false);";
let assets = extract_assets(sql).expect("PER_THREAD_OUTPUT false COPY must parse");
assert_eq!(assets.kinds.get("out/one.parquet"), Some(&AssetKind::File));
}
#[test]
fn test_per_thread_output_cast_to_false_is_a_file() {
for arg in ["0", "'false'", "'FALSE'", "'no'", "'f'"] {
let sql = format!(
"COPY orders TO 'out/one.parquet' (FORMAT parquet, PER_THREAD_OUTPUT {arg});"
);
let assets = extract_assets(&sql).expect("COPY must parse");
assert_eq!(
assets.kinds.get("out/one.parquet"),
Some(&AssetKind::File),
"PER_THREAD_OUTPUT {arg} casts to false and writes one file"
);
}
}
#[test]
fn test_per_thread_output_cast_to_true_is_a_directory() {
for arg in ["1", "'true'", "'yes'", "'t'", "'Y'"] {
let sql =
format!("COPY orders TO 'out/pto' (FORMAT parquet, PER_THREAD_OUTPUT {arg});");
let assets = extract_assets(&sql).expect("COPY must parse");
assert_eq!(
assets.kinds.get("out/pto"),
Some(&AssetKind::Directory),
"PER_THREAD_OUTPUT {arg} casts to true and writes a directory"
);
}
}
#[test]
fn test_file_size_bytes_zero_is_still_a_directory() {
let sql = "COPY orders TO 'out/sized' (FORMAT parquet, FILE_SIZE_BYTES 0);";
let assets = extract_assets(sql).expect("FILE_SIZE_BYTES 0 COPY must parse");
assert_eq!(assets.kinds.get("out/sized"), Some(&AssetKind::Directory));
}
#[test]
fn test_file_size_bytes_target_is_a_directory() {
let sql = "COPY orders TO 'out/sized' (FORMAT parquet, FILE_SIZE_BYTES '1MB');";
let assets = extract_assets(sql).expect("FILE_SIZE_BYTES COPY must parse");
assert_eq!(assets.kinds.get("out/sized"), Some(&AssetKind::Directory));
}
#[test]
fn test_row_groups_per_file_target_is_a_directory() {
let sql = "COPY orders TO 'out/rgpf' (FORMAT parquet, ROW_GROUPS_PER_FILE 1);";
let assets = extract_assets(sql).expect("ROW_GROUPS_PER_FILE COPY must parse");
assert_eq!(assets.kinds.get("out/rgpf"), Some(&AssetKind::Directory));
}
#[test]
fn test_neighbouring_copy_options_do_not_make_a_directory() {
for sql in [
"COPY orders TO 'out/o.parquet' (FORMAT parquet, FILENAME_PATTERN 'part_{i}');",
"COPY orders TO 'out/o.parquet' (FORMAT parquet, FILE_EXTENSION 'pq');",
"COPY orders TO 'out/o.parquet' (FORMAT parquet, OVERWRITE_OR_IGNORE);",
"COPY orders TO 'out/o.parquet' (FORMAT parquet, ROW_GROUP_SIZE 100000);",
] {
let assets = extract_assets(sql).expect("COPY must parse");
assert_eq!(
assets.kinds.get("out/o.parquet"),
Some(&AssetKind::File),
"not a directory-writing option: {sql}"
);
}
}
#[test]
fn test_lambda_colon_single_param_does_not_block_introspection() {
let sql = "CREATE TABLE bumped AS \
SELECT list_transform([x], lambda c: c + 1) AS y FROM source_table;";
let assets = extract_assets(sql).expect("lambda colon syntax must parse");
assert!(assets.outputs.contains("bumped"), "CTAS output discovered");
assert!(
assets.inputs.contains("source_table"),
"FROM table still discovered as input, got {:?}",
assets.inputs
);
}
#[test]
fn test_lambda_colon_multi_param_does_not_block_introspection() {
let sql = "CREATE TABLE totals AS \
SELECT list_reduce(xs, lambda acc, v: acc + v) AS total FROM source_table;";
let assets = extract_assets(sql).expect("multi-param lambda colon syntax must parse");
assert!(assets.outputs.contains("totals"));
assert!(assets.inputs.contains("source_table"));
}
}