use std::collections::{BTreeMap, BTreeSet};
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";
const SQLPARSER_READER: &str = "sqlparser";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reader {
DuckDb,
Sqlparser,
}
fn reader() -> Result<Reader, String> {
match std::env::var_os(SQL_READER_ENV) {
None => Ok(Reader::DuckDb),
Some(value) if value == DUCKDB_READER => Ok(Reader::DuckDb),
Some(value) if value == SQLPARSER_READER => Ok(Reader::Sqlparser),
Some(value) => Err(format!(
"{SQL_READER_ENV} is '{}', and the values it takes are `{DUCKDB_READER}`, which \
reads a SQL step with DuckDB's own parser as arc does with it unset, and \
`{SQLPARSER_READER}`, which reads it with arc's own",
value.to_string_lossy()
)),
}
}
pub(crate) fn check_reader() -> Result<(), String> {
reader().map(|_| ())
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct StepSql {
pub statements: Vec<SqlAssets>,
pub read_by: Vec<ReadBy>,
pub byte_ranges: Vec<Option<(usize, usize)>>,
pub unread: Vec<UnreadStatement>,
pub unreadable: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReadBy {
DuckDb,
Sqlparser,
Nothing,
}
impl ReadBy {
pub fn as_str(self) -> &'static str {
match self {
ReadBy::DuckDb => "duckdb",
ReadBy::Sqlparser => "sqlparser",
ReadBy::Nothing => "nothing",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnreadStatement {
pub statement: usize,
pub line: usize,
}
impl StepSql {
pub fn merged(&self) -> SqlAssets {
let mut assets = SqlAssets::default();
for statement in &self.statements {
merge(&mut assets, statement.clone());
}
assets
}
}
fn merge(into: &mut SqlAssets, from: SqlAssets) {
into.outputs.extend(from.outputs);
into.inputs.extend(from.inputs);
into.internal.extend(from.internal);
into.destroys.extend(from.destroys);
into.kinds.extend(from.kinds);
into.unread_table_functions
.extend(from.unread_table_functions);
}
pub fn unread_warning(step: &str, file: &str, unread: &UnreadStatement) -> String {
format!(
"could not read statement {} of {step} ({file}:{}) \u{2014} treating it as reading \
nothing",
unread.statement, unread.line
)
}
pub fn read_sql(sql: &str) -> Result<StepSql, String> {
match reader()? {
Reader::Sqlparser => Ok(sqlparser_step(sql)),
#[cfg(test)]
Reader::DuckDb if !test_reading::is_set() => Ok(sqlparser_step(sql)),
Reader::DuckDb => Ok(from_duckdb(sql, duckdb_reading(sql))),
}
}
#[cfg(not(test))]
fn duckdb_reading(sql: &str) -> Result<duckdb_lineage::StepReading, duckdb_lineage::Refusal> {
let program =
crate::engine::duckdb_program().map_err(|e| duckdb_lineage::Refusal::NotStarted {
program: std::env::var_os(crate::engine::DUCKDB_BIN_ENV).map_or_else(
|| "duckdb".to_string(),
|bin| bin.to_string_lossy().into_owned(),
),
reason: e.to_string(),
})?;
duckdb_lineage::read_step(&program, sql)
}
#[cfg(test)]
fn duckdb_reading(sql: &str) -> Result<duckdb_lineage::StepReading, duckdb_lineage::Refusal> {
test_reading::read(sql)
}
#[cfg(test)]
pub(crate) mod test_reading {
use std::cell::RefCell;
use crate::duckdb_lineage::{Refusal, StepReading};
type Read = Box<dyn Fn(&str) -> Result<StepReading, Refusal>>;
thread_local! {
static READING: RefCell<Option<Read>> = const { RefCell::new(None) };
}
pub(crate) fn is_set() -> bool {
READING.with(|reading| reading.borrow().is_some())
}
pub(super) fn read(sql: &str) -> Result<StepReading, Refusal> {
READING.with(|reading| (reading.borrow().as_ref().expect("a reading is set"))(sql))
}
pub(crate) fn with<T>(
read: impl Fn(&str) -> Result<StepReading, Refusal> + 'static,
body: impl FnOnce() -> T,
) -> T {
READING.with(|reading| *reading.borrow_mut() = Some(Box::new(read)));
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body));
READING.with(|reading| *reading.borrow_mut() = None);
result.unwrap_or_else(|panic| std::panic::resume_unwind(panic))
}
pub(crate) fn recorded(sql: &str) -> Result<StepReading, Refusal> {
Ok(crate::duckdb_lineage::recorded_steps::reading(sql))
}
}
fn from_duckdb(
sql: &str,
reading: Result<duckdb_lineage::StepReading, duckdb_lineage::Refusal>,
) -> StepSql {
use duckdb_lineage::Refusal;
match reading {
Ok(reading) => duckdb_step(&reading),
Err(Refusal::Unreadable(reason)) => StepSql {
unreadable: Some(reason),
..sqlparser_step(sql)
},
Err(
no_duckdb @ (Refusal::NotStarted { .. }
| Refusal::NotRun { .. }
| Refusal::NotDuckDb2 { .. }),
) => {
warn_once_no_duckdb(&no_duckdb);
sqlparser_step(sql)
}
}
}
pub fn unreadable_warning(step: &str, file: &str, reason: &str) -> String {
format!(
"arc read {file} of step '{step}' with its own parser, because DuckDB's answer for it \
could not be read: {reason}"
)
}
pub fn unreadable_refusal(step: &str, file: &str, reason: &str) -> String {
format!(
"step '{step}' ({file}): arc reads what each SQL step reads and produces from DuckDB \
2's own parse, and DuckDB's answer for it could not be read: {reason}; \
{SQL_READER_ENV}={SQLPARSER_READER} reads the steps with arc's own parser instead"
)
}
fn warn_once_no_duckdb(refusal: &duckdb_lineage::Refusal) {
use owo_colors::OwoColorize;
static SAID: std::sync::Mutex<Option<(String, std::time::Instant)>> =
std::sync::Mutex::new(None);
let cause = no_duckdb_cause(refusal);
let now = std::time::Instant::now();
{
let mut said = SAID
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if !no_duckdb_warning_due(
said.as_ref(),
&cause,
now,
crate::duckdb_tokens::NOT_STARTED_LIFETIME,
) {
return;
}
*said = Some((cause, now));
}
let warning = match refusal {
duckdb_lineage::Refusal::NotRun { .. } => no_tokenizer_named(),
_ => None,
}
.unwrap_or_else(|| no_duckdb_warning(&refusal.to_string()));
eprintln!("{} {warning}", "warning:".yellow());
}
fn no_duckdb_cause(refusal: &duckdb_lineage::Refusal) -> String {
use duckdb_lineage::Refusal;
match refusal {
Refusal::NotStarted { program, .. } => format!("not started: {program}"),
Refusal::NotRun { program, .. } => format!("not run: {program}"),
Refusal::NotDuckDb2 { version } => format!("version: {version}"),
Refusal::Unreadable(_) => "unreadable".to_string(),
}
}
fn no_duckdb_warning_due(
said: Option<&(String, std::time::Instant)>,
cause: &str,
now: std::time::Instant,
lifetime: std::time::Duration,
) -> bool {
match said {
None => true,
Some((last, at)) => last != cause || now.saturating_duration_since(*at) >= lifetime,
}
}
#[cfg(not(test))]
fn no_tokenizer_named() -> Option<String> {
let program = crate::engine::duckdb_program().ok()?;
let error = crate::duckdb_tokens::tokenizer_failure(&program)?;
let printed = crate::engine::printed_version(&program);
Some(no_tokenizer_warning(
&crate::engine::which_duckdb(&program, &printed),
&error,
))
}
#[cfg(test)]
fn no_tokenizer_named() -> Option<String> {
None
}
pub(crate) fn no_tokenizer_warning(which: &str, error: &str) -> String {
format!(
"{} The asset graph is arc's own reading of the SQL instead. {}",
crate::engine::no_tokenizer_cause(which, error),
crate::engine::release_install_fix()
)
}
pub(crate) fn no_duckdb_warning(reason: &str) -> String {
format!(
"the asset graph is arc's own reading of the SQL, because no DuckDB 2 answered \
({reason})"
)
}
fn duckdb_step(reading: &duckdb_lineage::StepReading) -> StepSql {
let mut step = StepSql::default();
for (n, statement) in reading.statements.iter().enumerate() {
let mut assets = duckdb_statement(statement);
let mut read_by = ReadBy::DuckDb;
if matches!(
statement.form,
duckdb_lineage::Form::Unread(_) | duckdb_lineage::Form::NotParsed(_)
) {
match sqlparser_per_statement(&statement.text) {
Ok(read) => {
read.into_iter().for_each(|other| merge(&mut assets, other));
read_by = ReadBy::Sqlparser;
}
Err(_) => {
read_by = ReadBy::Nothing;
step.unread.push(UnreadStatement {
statement: n + 1,
line: statement.line,
});
}
}
}
step.statements.push(assets);
step.read_by.push(read_by);
step.byte_ranges.push(statement.byte_range);
}
step
}
fn batched_byte_ranges(sql: &str) -> Option<Vec<(usize, usize)>> {
let mut ranges = Vec::new();
for batch in crate::engine::cli_batches_mapped(sql.as_bytes()) {
let text = String::from_utf8(batch.text).ok()?;
for (start, end) in statement_byte_ranges(&text) {
let placed = batch
.origin
.at(start)
.zip(batch.origin.at(end - 1).map(|last| last + 1));
ranges.push(placed?);
}
}
Some(ranges)
}
fn lexical_ranges(sql: &str, count: usize) -> Vec<Option<(usize, usize)>> {
let ranges = batched_byte_ranges(sql).unwrap_or_default();
if ranges.len() == count {
ranges.into_iter().map(Some).collect()
} else {
vec![None; count]
}
}
fn sqlparser_step(sql: &str) -> StepSql {
if let Ok(statements) = sqlparser_per_statement(sql) {
return StepSql {
read_by: vec![ReadBy::Sqlparser; statements.len()],
byte_ranges: lexical_ranges(sql, statements.len()),
statements,
..StepSql::default()
};
}
let mut step = StepSql::default();
let (cut, placed) = match batched_byte_ranges(sql) {
Some(ranges) => (ranges, true),
None => (statement_byte_ranges(sql), false),
};
for (n, (start, end)) in cut.into_iter().enumerate() {
step.byte_ranges.push(placed.then_some((start, end)));
match sql
.get(start..end)
.ok_or_else(Vec::new)
.and_then(sqlparser_per_statement)
{
Ok(read) => {
let mut assets = SqlAssets::default();
read.into_iter().for_each(|other| merge(&mut assets, other));
step.statements.push(assets);
step.read_by.push(ReadBy::Sqlparser);
}
Err(_) => {
step.statements.push(SqlAssets::default());
step.read_by.push(ReadBy::Nothing);
step.unread.push(UnreadStatement {
statement: n + 1,
line: sql[..start].matches('\n').count() + 1,
});
}
}
}
step
}
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.internal.extend(statement.with.iter().cloned());
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;
#[test]
fn the_no_duckdb_warning_repeats_on_a_new_cause_or_after_its_lifetime() {
let lifetime = std::time::Duration::from_millis(30);
let start = std::time::Instant::now();
let said = ("not started: duckdb".to_string(), start);
assert!(no_duckdb_warning_due(
None,
"not started: duckdb",
start,
lifetime
));
assert!(!no_duckdb_warning_due(
Some(&said),
"not started: duckdb",
start + lifetime / 2,
lifetime
));
assert!(no_duckdb_warning_due(
Some(&said),
"not run: duckdb",
start + lifetime / 2,
lifetime
));
assert!(no_duckdb_warning_due(
Some(&said),
"not started: duckdb",
start + lifetime,
lifetime
));
let not_started = |reason: &str| duckdb_lineage::Refusal::NotStarted {
program: "duckdb".to_string(),
reason: reason.to_string(),
};
assert_eq!(
no_duckdb_cause(¬_started("No such file")),
no_duckdb_cause(¬_started("Permission denied")),
"the cause is the failure and the program, not the reason"
);
}
fn read_by_duckdb(sql: &str) -> Vec<SqlAssets> {
recorded_steps::read(sql)
.iter()
.map(duckdb_statement)
.collect()
}
fn sqlparser_assets(sql: &str) -> Result<SqlAssets, Vec<String>> {
let mut assets = SqlAssets::default();
for statement in sqlparser_per_statement(sql)? {
merge(&mut assets, statement);
}
Ok(assets)
}
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 the_reader_mapping_cases_are_read_by_the_same_rules_under_either_reader() {
use AssetKind::{File, Pattern, Table};
type Case = (
&'static str,
&'static [(&'static str, AssetKind)],
&'static str,
);
let cases: [Case; 6] = [
(
recorded_steps::READ_JSON_LIST,
&[("a.json", File), ("b.json", File)],
"j",
),
(recorded_steps::READ_CSV_NAMED, &[("x.csv", File)], "c"),
(
recorded_steps::STRING_ARGUMENT,
&[("data/in.parquet", File)],
"p",
),
(
recorded_steps::EXPRESSION_ARGUMENT,
&[("recent", Table)],
"w",
),
(
recorded_steps::MIXED_CASE,
&[("mytable", Table), ("other", Table)],
"out",
),
(recorded_steps::GLOB, &[("data/*.csv", Pattern)], "d"),
];
for (sql, inputs, output) in cases {
let read = read_by_duckdb(sql);
assert_eq!(read, read_by_sqlparser(sql), "{sql}");
assert_eq!(read.len(), 1, "{sql}");
let names: Vec<&str> = inputs.iter().map(|(name, _)| *name).collect();
assert_eq!(read[0].inputs, set(&names), "reads of {sql}");
for (name, kind) in inputs {
assert_eq!(read[0].kinds.get(*name), Some(kind), "{name} in {sql}");
}
assert_eq!(read[0].outputs, set(&[output]), "produces of {sql}");
assert_eq!(read[0].unread_table_functions, set(&[]), "{sql}");
}
}
#[test]
fn duckdb_reads_a_bare_name_as_a_read_of_that_table() {
let sql = recorded_steps::BARE_NAME;
assert!(
sqlparser_per_statement(sql).is_err(),
"sqlparser parses {sql:?} now; compare the two readings instead"
);
let read = read_by_duckdb(sql);
assert_eq!(read.len(), 1);
assert_eq!(read[0].inputs, set(&["tbl"]), "{sql}");
assert_eq!(read[0].kinds.get("tbl"), Some(&AssetKind::Table));
assert_eq!(read[0].outputs, set(&[]), "{sql}");
}
fn read_by_default(sql: &str) -> StepSql {
duckdb_step(&recorded_steps::reading(sql))
}
#[test]
fn a_step_duckdb_did_not_answer_keeps_the_lexical_ranges() {
let sql = "SELECT 1;\nCREATE TABLE t AS SELECT 2;\n";
let step = test_reading::with(
|_| Err(duckdb_lineage::Refusal::Unreadable("no answer".to_string())),
|| read_sql(sql).unwrap(),
);
assert_eq!(step.unreadable.as_deref(), Some("no answer"));
assert_eq!(
step.byte_ranges,
[Some((0, 9)), Some((10, 37))],
"sqlparser-rs read the step, and the ranges are the lexical cut's"
);
}
#[test]
fn the_sqlparser_reader_places_ranges_past_the_lines_the_command_line_drops() {
let sql = ".mode list\n# note\nSELECT 1;\r\n.print x\nSELECT 2;\n";
let step = read_sql(sql).unwrap();
let slices: Vec<_> = step
.byte_ranges
.iter()
.map(|r| r.map(|(start, end)| &sql[start..end]))
.collect();
assert_eq!(slices, [Some("SELECT 1;"), Some("SELECT 2;")]);
}
#[test]
fn a_step_the_batches_cannot_place_is_still_read_and_has_no_ranges() {
let sql = format!(
"SELECT * FROM t;\nSELECT * FROM u;\n{}\0{}\u{e9};\n",
"x".repeat(50),
"y".repeat(47)
);
assert_eq!(batched_byte_ranges(&sql), None);
let step = sqlparser_step(&sql);
assert_eq!(step.statements.len(), 3, "{step:?}");
assert!(step.statements[0].inputs.contains("t"));
assert!(step.statements[1].inputs.contains("u"));
assert_eq!(step.byte_ranges, [None, None, None]);
}
#[test]
fn lexical_ranges_are_dropped_when_the_cut_counts_statements_differently() {
assert_eq!(lexical_ranges("SELECT 1; SELECT 2;", 3), [None; 3]);
assert_eq!(
lexical_ranges("SELECT 1; SELECT 2;", 2),
[Some((0, 9)), Some((10, 19))]
);
}
#[test]
fn duckdbs_range_for_a_statement_follows_its_tokens() {
let sql = ".mode list\nSELECT * FROM a;\n";
let step = read_by_default(sql);
assert_eq!(
step.byte_ranges,
[Some((11, 27))],
"DuckDB's tokens start the range at SELECT"
);
assert_eq!(
statement_byte_ranges(sql),
[(0, 27)],
"the lexical cut of the whole file starts at the dot command"
);
assert_eq!(
sqlparser_step(sql).byte_ranges,
[Some((11, 27))],
"the fallback cuts the command line's batches, so it does not"
);
}
#[test]
fn a_statement_duckdbs_reading_cannot_take_is_read_by_sqlparser() {
let sql = "SET VARIABLE m = (SELECT max(d) FROM events);\n\
SET threads TO 4;\n\
FORCE INSTALL spatial;\n\
DELETE FROM t USING u WHERE t.k = u.k;\n\
CREATE TABLE r AS PIVOT t ON c USING sum(v);";
let step = read_by_default(sql);
assert_eq!(
step.unread,
[],
"sqlparser-rs reads each statement DuckDB's cannot"
);
let by_sqlparser = read_by_sqlparser(sql);
assert_eq!(
step.statements[3], by_sqlparser[3],
"the DELETE, which DuckDB's reading does not frame, is sqlparser-rs's reading"
);
assert_eq!(step.statements[4].outputs, set(&["r"]));
assert_eq!(
step.statements[4].inputs,
set(&["t"]),
"the PIVOT DuckDB gives no tree for reads its table, as sqlparser-rs reads it"
);
}
#[test]
fn a_statement_neither_reader_can_read_is_listed_with_its_line() {
let sql = "SELECT 1;\nCREATE TABL r AS SELECT 1;";
let step = read_by_default(sql);
assert_eq!(
step.unread,
[UnreadStatement {
statement: 2,
line: 2
}],
"the statement DuckDB and sqlparser-rs both refuse"
);
assert_eq!(step.statements.len(), 2);
assert_eq!(step.statements[1], SqlAssets::default(), "it reads nothing");
}
#[test]
fn sqlparser_reads_each_statement_of_a_step_it_cannot_parse_whole() {
let sql = "SELECT * FROM a;\n\n\
CREATE TABLE r AS SELECT * EXCLUDE (a) FROM t ASOF LEFT JOIN u USING (k);\n\
CREATE TABLE s AS SELECT * FROM b;";
let step = sqlparser_step(sql);
let reads: Vec<BTreeSet<String>> =
step.statements.iter().map(|s| s.inputs.clone()).collect();
assert_eq!(reads, [set(&["a"]), set(&[]), set(&["b"])]);
assert_eq!(step.statements[2].outputs, set(&["s"]));
assert_eq!(
step.unread,
[UnreadStatement {
statement: 2,
line: 3
}],
"the ASOF join sqlparser-rs cannot parse reads nothing and is listed"
);
}
#[test]
fn a_duckdb_that_cannot_be_asked_leaves_the_step_to_sqlparser() {
use crate::duckdb_lineage::Refusal;
let sql = "CREATE TABLE r AS SELECT * FROM t;";
for refusal in [
Refusal::NotStarted {
program: "duckdb".to_string(),
reason: "No such file".to_string(),
},
Refusal::NotRun {
program: "duckdb".to_string(),
reason: "exit 1".to_string(),
},
Refusal::NotDuckDb2 {
version: "v1.5.6".to_string(),
},
] {
let step = from_duckdb(sql, Err(refusal.clone()));
assert_eq!(step, sqlparser_step(sql), "{refusal:?}");
assert_eq!(step.unreadable, None, "{refusal:?}: no DuckDB 2 answered");
}
}
#[test]
fn an_answer_that_cannot_be_read_is_named_for_the_step() {
let sql = "CREATE TABLE r AS SELECT * FROM t;";
let step = from_duckdb(
sql,
Err(crate::duckdb_lineage::Refusal::Unreadable(
"binder: m".to_string(),
)),
);
assert_eq!(step.unreadable.as_deref(), Some("binder: m"));
assert_eq!(step.statements, sqlparser_step(sql).statements);
assert_eq!(step.read_by, [ReadBy::Sqlparser]);
}
#[test]
fn each_statement_says_which_reader_read_it() {
let step = read_by_default(
"SET VARIABLE m = (SELECT max(d) FROM events);\n\
SET threads TO 4;\n\
FORCE INSTALL spatial;\n\
DELETE FROM t USING u WHERE t.k = u.k;\n\
CREATE TABLE r AS PIVOT t ON c USING sum(v);",
);
use ReadBy::{DuckDb, Sqlparser};
assert_eq!(step.read_by, [DuckDb, DuckDb, DuckDb, Sqlparser, Sqlparser]);
let step = read_by_default("SELECT 1;\nCREATE TABL r AS SELECT 1;");
assert_eq!(step.read_by, [DuckDb, ReadBy::Nothing]);
}
#[test]
fn the_unread_statement_warning_names_the_step_the_file_and_the_line() {
let unread = UnreadStatement {
statement: 2,
line: 7,
};
assert_eq!(
unread_warning("load", "models/load.sql", &unread),
"could not read statement 2 of load (models/load.sql:7) \u{2014} treating it as \
reading nothing"
);
}
#[test]
fn the_no_tokenizer_warning_names_the_cause_and_the_fix() {
assert_eq!(
no_tokenizer_warning(
"the duckdb on PATH, `/usr/bin/duckdb`, which prints `v2.0.0`,",
"Catalog Error: Table Function with name sql_tokenize does not exist!"
),
"the duckdb on PATH, `/usr/bin/duckdb`, which prints `v2.0.0`, cannot read SQL into tokens, because \
it was built without DuckDB's autocomplete extension. arc needs those tokens to \
check what each SQL step installs. The asset graph is arc's own reading of the SQL \
instead. Install DuckDB's release build with DuckDB's own installer, pinned to the \
version arc is tested on: `curl https://install.duckdb.org | DUCKDB_VERSION=2.0.0 \
sh`, which installs it to ~/.duckdb/cli/2.0.0. Then put ~/.duckdb/cli/2.0.0 first \
on PATH, or set ARC_DUCKDB_BIN=~/.duckdb/cli/2.0.0/duckdb."
);
}
#[test]
fn the_no_duckdb_warning_says_whose_reading_the_graph_is() {
assert_eq!(
no_duckdb_warning("no such file"),
"the asset graph is arc's own reading of the SQL, because no DuckDB 2 answered \
(no such file)"
);
}
#[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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_assets(sql).unwrap();
assert!(assets.inputs.contains("customers"));
}
#[test]
fn test_unparseable_sql() {
let sql = "THIS IS NOT VALID SQL AT ALL %%%";
let result = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_assets(sql).unwrap();
assert!(assets.outputs.contains("customers"));
}
#[test]
fn test_empty_sql() {
let sql = "-- just a comment";
let result = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_assets(&format!("COPY a TO 'out' {options};")).unwrap();
let from_query =
sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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(), sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_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 = sqlparser_assets(sql).expect("multi-param lambda colon syntax must parse");
assert!(assets.outputs.contains("totals"));
assert!(assets.inputs.contains("source_table"));
}
}