mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-19 16:03:29 +00:00
* fix(parsers): keep s3 asset path suffix verbatim to preserve storage distinction Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01An2pTqSmqJd2XwnagvX4kM * package json * fix(pipelines): preserve named storage in generated TS/Python S3 URIs The TS/Python templates emitted `s3:///${s3Key(path)}`, stripping the leading slash and pinning the URI to default storage. For a named-storage asset path (`secondary/key`) that produced `s3:///secondary/key`, which resolves to the default storage with key `secondary/key`, dropping the named-storage dependency and reading/writing the wrong object. Emit the path verbatim after `s3://` (matching the DuckDB template) so a named-storage input/output keeps its storage; identical to the previous output for default-storage paths. Removes the now-unused `s3Key` helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(cli): align bun.lock parser versions with frontend The PR bumped windmill-parser-wasm-asset (1.749.0→1.753.0) and windmill-parser-wasm-regex (1.692.0→1.764.0) in package.json and the npm package-lock.json for both cli and frontend, but cli/bun.lock was left pinned to the old versions. Sync it so the CLI's wasm asset parser (used by localGraph inference) matches the frontend and deploy-time parser. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Ruben Fiszel <ruben@windmill.dev>
2819 lines
106 KiB
Rust
2819 lines
106 KiB
Rust
use std::collections::{BTreeMap, HashSet};
|
|
|
|
use sqlparser::{
|
|
ast::{
|
|
CopyTarget, Expr, FunctionArg, FunctionArgExpr, ObjectName, ObjectNamePart, SelectItem,
|
|
TableFactor, TableObject, Value, ValueWithSpan, Visit, Visitor,
|
|
},
|
|
dialect::DuckDbDialect,
|
|
parser::Parser,
|
|
};
|
|
use windmill_parser::asset_parser::{
|
|
asset_was_used, merge_assets, merge_column_lineage, parse_asset_syntax,
|
|
parse_pipeline_annotations, AssetKind, AssetUsageAccessType, ColumnLineage, ColumnRef,
|
|
ParseAssetsOutput, ParseAssetsResult,
|
|
};
|
|
use AssetUsageAccessType::*;
|
|
|
|
pub fn parse_assets(input: &str) -> anyhow::Result<ParseAssetsOutput> {
|
|
let statements = Parser::parse_sql(&DuckDbDialect, input)?;
|
|
|
|
let mut collector = AssetCollector::new();
|
|
for statement in statements {
|
|
let _ = statement.visit(&mut collector);
|
|
}
|
|
|
|
for (_, (kind, path)) in collector.var_identifiers {
|
|
if !asset_was_used(&collector.assets, (kind, &path)) {
|
|
collector.assets.push(ParseAssetsResult {
|
|
kind,
|
|
access_type: None,
|
|
path: path,
|
|
columns: None,
|
|
});
|
|
}
|
|
}
|
|
|
|
let mut pipeline = parse_pipeline_annotations(input);
|
|
// Scope inferred lineage to a single output asset so columns from an
|
|
// auxiliary CTAS aren't attributed to the materialized one (the flat list
|
|
// has no per-entry output on the wire). The `// materialize` target, when
|
|
// declared, IS the output: keep entries tagged with it plus untagged
|
|
// top-level-SELECT entries (which describe that target). Without a declared
|
|
// target, keep inference only when every tagged entry shares one output
|
|
// asset — otherwise it's ambiguous which asset the flat list describes, so
|
|
// drop it rather than show false dependencies.
|
|
let target = pipeline
|
|
.materialize
|
|
.as_ref()
|
|
.map(|m| (m.target_kind, m.target_path.clone()));
|
|
let inferred: Vec<ColumnLineage> = match &target {
|
|
Some(t) => collector
|
|
.column_lineage
|
|
.into_iter()
|
|
.filter(|(out, _)| out.as_ref().map_or(true, |o| o == t))
|
|
.map(|(_, cl)| cl)
|
|
.collect(),
|
|
None => {
|
|
let mut first: Option<&(AssetKind, String)> = None;
|
|
let mut ambiguous = false;
|
|
for (out, _) in &collector.column_lineage {
|
|
if let Some(o) = out {
|
|
match first {
|
|
None => first = Some(o),
|
|
Some(f) if f != o => {
|
|
ambiguous = true;
|
|
break;
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
if ambiguous {
|
|
Vec::new()
|
|
} else {
|
|
collector
|
|
.column_lineage
|
|
.into_iter()
|
|
.map(|(_, cl)| cl)
|
|
.collect()
|
|
}
|
|
}
|
|
};
|
|
// Body-inferred column lineage, with `// column` annotations taking
|
|
// precedence per output column (explicit declaration overrides inference).
|
|
pipeline.column_lineage = merge_column_lineage(inferred, pipeline.column_lineage);
|
|
// A bare string literal in query position is only weak read evidence: a
|
|
// summary `SELECT 's3:///out.csv' AS target` after `COPY … TO
|
|
// 's3:///out.csv'` must not turn the write into rw (which draws a
|
|
// spurious read edge and an asset⇄script cycle in the pipeline graph).
|
|
// Surface weak reads only for assets with no other recorded usage, so a
|
|
// path that is *merely* mentioned still shows up linked to the script.
|
|
let mut assets = merge_assets(collector.assets);
|
|
for weak in merge_assets(collector.weak_string_reads) {
|
|
if !assets
|
|
.iter()
|
|
.any(|a| a.kind == weak.kind && a.path == weak.path)
|
|
{
|
|
assets.push(weak);
|
|
}
|
|
}
|
|
assets.sort_by(|a, b| a.path.cmp(&b.path));
|
|
Ok(ParseAssetsOutput::new(assets, Vec::new(), pipeline))
|
|
}
|
|
|
|
/// Provenance of the innermost access context. The access type alone can't
|
|
/// tell a definitive read apart from a mere mention: a bare string literal in
|
|
/// generic query position (`QueryRead`) is only *weak* read evidence — e.g. a
|
|
/// summary `SELECT 's3:///out.csv' AS target` echoing a path — while the same
|
|
/// literal as a read-function argument or a `COPY` target is definitive.
|
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
|
enum AccessCtx {
|
|
QueryRead,
|
|
ReadFn,
|
|
CopyWrite,
|
|
}
|
|
|
|
impl AccessCtx {
|
|
fn access_type(self) -> AssetUsageAccessType {
|
|
match self {
|
|
AccessCtx::QueryRead | AccessCtx::ReadFn => R,
|
|
AccessCtx::CopyWrite => W,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Visitor that collects S3 asset literals from SQL statements
|
|
struct AssetCollector {
|
|
assets: Vec<ParseAssetsResult>,
|
|
// Bare string literals seen in generic query position — weak read
|
|
// evidence, surfaced by `parse_assets` only when the script has no other
|
|
// recorded usage of the same asset (a real write must not gain a spurious
|
|
// read edge from a mention).
|
|
weak_string_reads: Vec<ParseAssetsResult>,
|
|
// e.g set to QueryRead when we are inside a SELECT ... FROM ... statement
|
|
current_access_type_stack: Vec<AccessCtx>,
|
|
// e.g ATTACH 'ducklake://a' AS dl; => { "dl": (Ducklake, "a") }
|
|
var_identifiers: BTreeMap<String, (AssetKind, String)>,
|
|
// e.g USE dl;
|
|
currently_used_asset: Option<(AssetKind, String)>,
|
|
// CTE names in scope (stack for nested queries)
|
|
cte_name_stack: Vec<HashSet<String>>,
|
|
// Locally created tables (not attached to an asset)
|
|
local_table_names: HashSet<String>,
|
|
// Inferred column-level lineage: one entry per output column of an
|
|
// output-producing query, mapping it to the upstream source columns its
|
|
// expression reads. Each is tagged with the *output asset* it belongs to —
|
|
// `Some((kind, path))` for a CTAS / CREATE VIEW into a real asset, `None`
|
|
// for a top-level managed-materialize SELECT (its output is the `//
|
|
// materialize` target, known only in `parse_assets`). `parse_assets` uses
|
|
// the tag to scope the flat list to a single output asset so columns from an
|
|
// auxiliary output don't get attributed to the materialized one. Best-effort:
|
|
// dynamic/raw SQL, INSERT…SELECT, and wildcards are left to annotations.
|
|
column_lineage: Vec<(Option<(AssetKind, String)>, ColumnLineage)>,
|
|
}
|
|
|
|
impl AssetCollector {
|
|
fn new() -> Self {
|
|
Self {
|
|
assets: Vec::new(),
|
|
weak_string_reads: Vec::new(),
|
|
current_access_type_stack: Vec::with_capacity(8),
|
|
var_identifiers: BTreeMap::new(),
|
|
currently_used_asset: None,
|
|
cte_name_stack: Vec::new(),
|
|
local_table_names: HashSet::new(),
|
|
column_lineage: Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Record a `CREATE TABLE`/`VIEW` target. A *temporary* table/view is always
|
|
/// local — even a one-part name under an active `USE dl`, which would
|
|
/// otherwise resolve to an asset (`ducklake://…/tmp`) and then leak as a
|
|
/// column source for later references. A non-temp name that resolves to an
|
|
/// attached asset is recorded as that asset; anything else is registered
|
|
/// local so subsequent references aren't attributed to the active asset.
|
|
fn track_table_definition(&mut self, name: &ObjectName, is_temporary: bool) {
|
|
if !is_temporary {
|
|
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
|
self.assets.push(asset);
|
|
return;
|
|
}
|
|
}
|
|
if let Some(simple_name) = get_trivial_obj_name(name) {
|
|
self.local_table_names.insert(simple_name.to_lowercase());
|
|
}
|
|
}
|
|
|
|
fn is_locally_defined(&self, name: &str) -> bool {
|
|
let name_lower = name.to_lowercase();
|
|
self.local_table_names.contains(&name_lower)
|
|
|| self
|
|
.cte_name_stack
|
|
.iter()
|
|
.any(|set| set.contains(&name_lower))
|
|
}
|
|
|
|
// Detect when we do 'a.b' and 'a' is associated with an asset in var_identifiers
|
|
// Or when we access 'b' and we did USE a;
|
|
fn get_associated_asset_from_obj_name(
|
|
&self,
|
|
name: &ObjectName,
|
|
access_type: Option<AssetUsageAccessType>,
|
|
) -> Option<ParseAssetsResult> {
|
|
let access_type = access_type.or_else(|| {
|
|
self.current_access_type_stack
|
|
.last()
|
|
.map(|c| c.access_type())
|
|
});
|
|
if let Some((kind, path)) = &self.currently_used_asset {
|
|
// We don't want to infer that any simple identifier refers to an asset if
|
|
// we are not in a known R/W context
|
|
if access_type.is_none() {
|
|
return None;
|
|
}
|
|
|
|
if name.0.len() == 1 {
|
|
if let Some(ident) = name.0.first().and_then(|id| id.as_ident()) {
|
|
if self.is_locally_defined(&ident.value) {
|
|
return None;
|
|
}
|
|
}
|
|
}
|
|
|
|
if name.0.len() == 1 || name.0.len() == 2 {
|
|
if name
|
|
.0
|
|
.iter()
|
|
.any(|id| id.as_ident().and_then(|id| id.quote_style).is_some())
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let specific_table = &name
|
|
.0
|
|
.iter()
|
|
.map(|id| id.as_ident().map(|id| id.value.clone()))
|
|
.collect::<Option<Vec<String>>>()?
|
|
.join(".");
|
|
|
|
// For Resource assets, use ?table= query parameter syntax
|
|
// For Ducklake and DataTable, maintain /table syntax
|
|
let path = if *kind == AssetKind::Resource {
|
|
format!("{}?table={}", path, specific_table)
|
|
} else {
|
|
format!("{}/{}", path, specific_table)
|
|
};
|
|
return Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None });
|
|
}
|
|
}
|
|
|
|
// Check if the first part of the name (the a in a.b) is associated with an asset
|
|
if name.0.len() < 2 {
|
|
return None;
|
|
}
|
|
let ident = name.0.first()?.as_ident()?;
|
|
let (kind, path) = self.var_identifiers.get(&ident.value)?;
|
|
let path = if name.0.len() == 2 || name.0.len() == 3 {
|
|
let specific_table = &name.0[1..]
|
|
.iter()
|
|
.map(|id| id.as_ident().map(|id| id.value.clone()))
|
|
.collect::<Option<Vec<String>>>()?
|
|
.join(".");
|
|
|
|
// For Resource assets, use ?table= query parameter syntax
|
|
// For Ducklake and DataTable, maintain /table syntax
|
|
if *kind == AssetKind::Resource {
|
|
format!("{}?table={}", path, specific_table)
|
|
} else {
|
|
format!("{}/{}", path, specific_table)
|
|
}
|
|
} else {
|
|
path.clone()
|
|
};
|
|
Some(ParseAssetsResult { kind: *kind, access_type, path, columns: None })
|
|
}
|
|
|
|
/// If `table_factor` is a string literal used directly as a table name (e.g. FROM 's3:///file.parquet'),
|
|
/// return a `ParseAssetsResult` for it.
|
|
fn get_s3_asset_from_str_literal_table(
|
|
&self,
|
|
table_factor: &TableFactor,
|
|
) -> Option<ParseAssetsResult> {
|
|
let name = match table_factor {
|
|
TableFactor::Table { name, args: None, .. } => name,
|
|
_ => return None,
|
|
};
|
|
|
|
let s3_str = get_str_lit_from_obj_name(name)?;
|
|
let (kind, path) = parse_asset_syntax(s3_str, false)?;
|
|
if kind != AssetKind::S3Object {
|
|
return None;
|
|
}
|
|
|
|
Some(ParseAssetsResult {
|
|
kind,
|
|
path: path.to_string(),
|
|
access_type: Some(R),
|
|
columns: None,
|
|
})
|
|
}
|
|
|
|
/// If `table_factor` is a read function (read_parquet/read_csv/read_json) whose first
|
|
/// positional argument is an S3 string literal, return a `ParseAssetsResult` for it.
|
|
fn get_s3_asset_from_table_function(
|
|
&self,
|
|
table_factor: &TableFactor,
|
|
) -> Option<ParseAssetsResult> {
|
|
let (name, args) = match table_factor {
|
|
TableFactor::Table { name, args: Some(args), .. } => (name, args),
|
|
_ => return None,
|
|
};
|
|
|
|
let fname = get_trivial_obj_name(name)?;
|
|
if !is_read_fn(fname) {
|
|
return None;
|
|
}
|
|
|
|
let s3_str = args.args.first().and_then(|arg| match arg {
|
|
FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan {
|
|
value: Value::SingleQuotedString(s),
|
|
..
|
|
})))
|
|
| FunctionArg::Unnamed(FunctionArgExpr::Expr(Expr::Value(ValueWithSpan {
|
|
value: Value::DoubleQuotedString(s),
|
|
..
|
|
}))) => Some(s.as_str()),
|
|
_ => None,
|
|
})?;
|
|
|
|
let (kind, path) = parse_asset_syntax(s3_str, false)?;
|
|
if kind != AssetKind::S3Object {
|
|
return None;
|
|
}
|
|
|
|
Some(ParseAssetsResult {
|
|
kind,
|
|
path: path.to_string(),
|
|
access_type: Some(R),
|
|
columns: None,
|
|
})
|
|
}
|
|
|
|
fn handle_string_literal(&mut self, s: &str) {
|
|
// Check if the string matches our asset syntax patterns
|
|
if let Some((kind, path)) = parse_asset_syntax(s, false) {
|
|
if kind == AssetKind::S3Object {
|
|
let ctx = self.current_access_type_stack.last().copied();
|
|
let result = ParseAssetsResult {
|
|
kind,
|
|
path: path.to_string(),
|
|
access_type: ctx.map(AccessCtx::access_type),
|
|
columns: None,
|
|
};
|
|
if ctx == Some(AccessCtx::QueryRead) {
|
|
self.weak_string_reads.push(result);
|
|
} else {
|
|
self.assets.push(result);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn handle_obj_name_pre(&mut self, name: &ObjectName) {
|
|
if let Some(fname) = get_trivial_obj_name(name) {
|
|
if is_read_fn(fname) {
|
|
self.current_access_type_stack.push(AccessCtx::ReadFn);
|
|
}
|
|
}
|
|
if let Some(str_lit) = get_str_lit_from_obj_name(name) {
|
|
self.handle_string_literal(str_lit);
|
|
}
|
|
}
|
|
|
|
fn handle_obj_name_post(&mut self, name: &ObjectName) {
|
|
if self.current_access_type_stack.is_empty() {
|
|
return;
|
|
}
|
|
if let Some(fname) = get_trivial_obj_name(name) {
|
|
if is_read_fn(fname) {
|
|
self.current_access_type_stack.pop();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collect the table-level reads (and column assets) of a query's top
|
|
// SELECT. Table-level reads are only gathered here and in the statement
|
|
// arms — the generic table-factor visitor picks up read-functions and
|
|
// string literals, not plain `FROM <table>` references. Called for both
|
|
// standalone SELECTs and the `AS SELECT` of CTAS / CREATE VIEW.
|
|
fn handle_query_reads(&mut self, query: &sqlparser::ast::Query) {
|
|
self.cte_name_stack.push(collect_cte_names(query));
|
|
if let Some(select) = query.body.as_select() {
|
|
for t in &select.from {
|
|
self.handle_table_with_joins(t, Some(R));
|
|
}
|
|
self.extract_column_assets(&select.projection, &select.from);
|
|
}
|
|
}
|
|
|
|
// Infer the output-column lineage of a query that produces an asset, tagging
|
|
// each entry with its `output` asset. Called only for an output-producing
|
|
// query — a top-level managed-materialize SELECT (`output: None`, resolved
|
|
// to the `// materialize` target later) or a CTAS / CREATE VIEW into a real
|
|
// asset (`output: Some`). A CTAS into a local/temp staging table is never
|
|
// an output, so it's simply not passed here.
|
|
fn infer_query_output(
|
|
&mut self,
|
|
query: &sqlparser::ast::Query,
|
|
output: Option<(AssetKind, String)>,
|
|
) {
|
|
if let Some(select) = query.body.as_select() {
|
|
self.infer_column_lineage(&select.projection, &select.from, output);
|
|
}
|
|
}
|
|
|
|
fn handle_table_with_joins(
|
|
&mut self,
|
|
table_with_joins: &sqlparser::ast::TableWithJoins,
|
|
access_type: Option<AssetUsageAccessType>,
|
|
) {
|
|
if let TableFactor::Table { name, args, .. } = &table_with_joins.relation {
|
|
if args.is_some() && args.as_ref().map_or(0, |a| a.args.len()) > 0 {
|
|
return;
|
|
}
|
|
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
|
self.assets.push(asset);
|
|
}
|
|
}
|
|
for join in &table_with_joins.joins {
|
|
if let TableFactor::Table { name, .. } = &join.relation {
|
|
if let Some(asset) = self.get_associated_asset_from_obj_name(name, access_type) {
|
|
self.assets.push(asset);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// The alias-map entry (key → asset) for one FROM/JOIN table factor, or
|
|
// `None` if it isn't an asset-backed table. The key is its alias, else the
|
|
// bare table name; S3 table-functions and string-literal tables are only
|
|
// keyed when aliased (an unaliased one is ambiguous). Returns the asset with
|
|
// a single matched relation so the caller can attribute qualified columns.
|
|
fn table_alias_entry(&self, relation: &TableFactor) -> Option<(String, ParseAssetsResult)> {
|
|
let TableFactor::Table { name, alias, args, .. } = relation else {
|
|
return None;
|
|
};
|
|
let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty());
|
|
if has_args {
|
|
let alias = alias.as_ref()?;
|
|
let asset = self.get_s3_asset_from_table_function(relation)?;
|
|
return Some((alias.name.value.clone(), asset));
|
|
}
|
|
let asset = self
|
|
.get_associated_asset_from_obj_name(name, Some(R))
|
|
.or_else(|| self.get_s3_asset_from_str_literal_table(relation))?;
|
|
if get_str_lit_from_obj_name(name).is_some() {
|
|
// String-literal S3 table: only unambiguous when aliased.
|
|
let alias = alias.as_ref()?;
|
|
return Some((alias.name.value.clone(), asset));
|
|
}
|
|
let key = match alias {
|
|
Some(a) => a.name.value.clone(),
|
|
None => name
|
|
.0
|
|
.last()
|
|
.and_then(|id| id.as_ident())
|
|
.map(|id| id.value.clone())
|
|
.unwrap_or_default(),
|
|
};
|
|
Some((key, asset))
|
|
}
|
|
|
|
// Resolve a query's FROM clause into (single-table asset, alias→asset map).
|
|
// `single_table` is `Some` only for an unambiguous one-table FROM with no
|
|
// joins (so bare column refs can be attributed); `table_to_asset` keys by
|
|
// alias/table name for qualified refs and includes every JOINed table.
|
|
// Shared by `extract_column_assets` (read columns) and `infer_column_lineage`
|
|
// (output→input edges) so both resolve identically.
|
|
fn build_from_maps(
|
|
&self,
|
|
from_tables: &[sqlparser::ast::TableWithJoins],
|
|
) -> (
|
|
Option<ParseAssetsResult>,
|
|
BTreeMap<String, ParseAssetsResult>,
|
|
) {
|
|
// Single unambiguous table only when there's exactly one FROM entry AND
|
|
// it has no joins — otherwise a bare column could belong to any side.
|
|
let single_table = if from_tables.len() == 1 && from_tables[0].joins.is_empty() {
|
|
let relation = &from_tables[0].relation;
|
|
if let TableFactor::Table { name, args, .. } = relation {
|
|
let has_args = args.as_ref().map_or(false, |a| !a.args.is_empty());
|
|
if has_args {
|
|
self.get_s3_asset_from_table_function(relation)
|
|
} else {
|
|
self.get_associated_asset_from_obj_name(name, Some(R))
|
|
.or_else(|| self.get_s3_asset_from_str_literal_table(relation))
|
|
}
|
|
} else {
|
|
None
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Alias → asset for qualified column refs, across every FROM entry AND
|
|
// its JOINed tables (so `c.col` in `FROM a JOIN c` resolves).
|
|
let mut table_to_asset: BTreeMap<String, ParseAssetsResult> = BTreeMap::new();
|
|
for table_with_joins in from_tables {
|
|
if let Some((k, a)) = self.table_alias_entry(&table_with_joins.relation) {
|
|
table_to_asset.insert(k, a);
|
|
}
|
|
for join in &table_with_joins.joins {
|
|
if let Some((k, a)) = self.table_alias_entry(&join.relation) {
|
|
table_to_asset.insert(k, a);
|
|
}
|
|
}
|
|
}
|
|
(single_table, table_to_asset)
|
|
}
|
|
|
|
// Extract columns from SELECT items and create individual asset results for each column
|
|
// Only processes columns that reference known assets to avoid false positives
|
|
fn extract_column_assets(
|
|
&mut self,
|
|
projection: &[SelectItem],
|
|
from_tables: &[sqlparser::ast::TableWithJoins],
|
|
) {
|
|
let (single_table, table_to_asset) = self.build_from_maps(from_tables);
|
|
|
|
// Process each SELECT item
|
|
for item in projection {
|
|
match item {
|
|
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
|
| SelectItem::ExprWithAlias { expr: Expr::Identifier(ident), .. } => {
|
|
// Simple column: SELECT a
|
|
// Only add if we have a single table (unambiguous)
|
|
if let Some(asset) = &single_table {
|
|
let mut columns = BTreeMap::new();
|
|
columns.insert(ident.value.clone(), R);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(R),
|
|
columns: Some(columns),
|
|
});
|
|
}
|
|
}
|
|
SelectItem::UnnamedExpr(Expr::CompoundIdentifier(parts))
|
|
| SelectItem::ExprWithAlias { expr: Expr::CompoundIdentifier(parts), .. } => {
|
|
// Qualified column: SELECT table1.a or SELECT x.table1.a
|
|
if parts.len() >= 2 {
|
|
let column_name = parts.last().map(|id| id.value.clone());
|
|
|
|
if let Some(column_name) = column_name {
|
|
// Check if the prefix matches a known table
|
|
let table_prefix = parts.first().map(|id| id.value.clone());
|
|
|
|
if let Some(table_prefix) = table_prefix {
|
|
if let Some(asset) = table_to_asset.get(&table_prefix) {
|
|
// Found a matching table, add column asset
|
|
let mut columns = BTreeMap::new();
|
|
columns.insert(column_name.clone(), R);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(R),
|
|
columns: Some(columns),
|
|
});
|
|
} else if parts.len() >= 3 {
|
|
// Could be x.table1.column format or db.schema.table.column
|
|
// Convert Idents to ObjectNameParts
|
|
let obj_parts: Vec<ObjectNamePart> = parts[..parts.len() - 1]
|
|
.iter()
|
|
.cloned()
|
|
.map(|ident| ObjectNamePart::Identifier(ident))
|
|
.collect();
|
|
let obj_name = ObjectName(obj_parts);
|
|
if let Some(asset) =
|
|
self.get_associated_asset_from_obj_name(&obj_name, Some(R))
|
|
{
|
|
let mut columns = BTreeMap::new();
|
|
columns.insert(column_name.clone(), R);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(R),
|
|
columns: Some(columns),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
// Ignore wildcards, expressions, etc.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Infer column-level lineage for an output-producing query's projection:
|
|
// each *named* output column → the upstream source columns its expression
|
|
// reads. Covers passthroughs (`amount`) and computed columns
|
|
// (`amount + tax AS total`) alike. Skipped: wildcards and unaliased
|
|
// expressions (no stable output name), and inputs that don't resolve to a
|
|
// known asset. A column with no resolved inputs is dropped.
|
|
//
|
|
// Best-effort and intentionally flat: results from every output query in the
|
|
// script accumulate into one list (the graph hangs them off the materialize
|
|
// write-edge), with no per-output-table association. This is exact for the
|
|
// common single-output member (a managed-materialize SELECT, or one CTAS),
|
|
// but a multi-statement script that stages through a TEMP table reports the
|
|
// *intermediate* column names (the final SELECT reads the temp table, whose
|
|
// columns don't resolve to an asset, so they drop out). A `// column`
|
|
// annotation overrides any output column where inference is wrong or coarse.
|
|
fn infer_column_lineage(
|
|
&mut self,
|
|
projection: &[SelectItem],
|
|
from_tables: &[sqlparser::ast::TableWithJoins],
|
|
output: Option<(AssetKind, String)>,
|
|
) {
|
|
let (single_table, table_to_asset) = self.build_from_maps(from_tables);
|
|
for item in projection {
|
|
let (out_col, expr) = match item {
|
|
SelectItem::ExprWithAlias { expr, alias } => (alias.value.clone(), expr),
|
|
SelectItem::UnnamedExpr(expr @ Expr::Identifier(id)) => (id.value.clone(), expr),
|
|
SelectItem::UnnamedExpr(expr @ Expr::CompoundIdentifier(parts)) => {
|
|
match parts.last() {
|
|
Some(last) => (last.value.clone(), expr),
|
|
None => continue,
|
|
}
|
|
}
|
|
_ => continue,
|
|
};
|
|
let mut collector = ColumnIdentCollector { refs: Vec::new(), query_depth: 0 };
|
|
let _ = expr.visit(&mut collector);
|
|
let mut inputs: Vec<ColumnRef> = Vec::new();
|
|
for parts in &collector.refs {
|
|
if let Some(cr) = self.resolve_column_ref(parts, &single_table, &table_to_asset) {
|
|
if !inputs.contains(&cr) {
|
|
inputs.push(cr);
|
|
}
|
|
}
|
|
}
|
|
if !inputs.is_empty() {
|
|
self.column_lineage
|
|
.push((output.clone(), ColumnLineage { column: out_col, inputs }));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Resolve identifier `parts` (e.g. `["t","amount"]` or `["amount"]`) to the
|
|
// source asset column it reads, mirroring `extract_column_assets`'
|
|
// resolution: a bare ident needs an unambiguous single-table FROM; a
|
|
// qualified ident resolves its prefix via the alias map, or (≥3 parts) as a
|
|
// db/schema-qualified object name.
|
|
fn resolve_column_ref(
|
|
&self,
|
|
parts: &[String],
|
|
single_table: &Option<ParseAssetsResult>,
|
|
table_to_asset: &BTreeMap<String, ParseAssetsResult>,
|
|
) -> Option<ColumnRef> {
|
|
let asset_to_ref = |asset: &ParseAssetsResult, col: &str| ColumnRef {
|
|
from_kind: asset.kind,
|
|
from_path: asset.path.clone(),
|
|
from_column: col.to_string(),
|
|
};
|
|
match parts {
|
|
[col] => single_table.as_ref().map(|a| asset_to_ref(a, col)),
|
|
[.., col] => {
|
|
let prefix = parts.first()?;
|
|
if let Some(asset) = table_to_asset.get(prefix) {
|
|
Some(asset_to_ref(asset, col))
|
|
} else if parts.len() >= 3 {
|
|
let obj_parts: Vec<ObjectNamePart> = parts[..parts.len() - 1]
|
|
.iter()
|
|
.map(|p| ObjectNamePart::Identifier(sqlparser::ast::Ident::new(p.clone())))
|
|
.collect();
|
|
let asset =
|
|
self.get_associated_asset_from_obj_name(&ObjectName(obj_parts), Some(R))?;
|
|
Some(asset_to_ref(&asset, col))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
[] => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Collects the identifier paths an expression reads, for column-lineage
|
|
// inference: `Expr::Identifier(a)` → `["a"]`, `Expr::CompoundIdentifier(t.a)` →
|
|
// `["t","a"]`. The derived `Visit` walk recurses through operators, functions,
|
|
// casts and CASE, so every leaf identifier of the outer expression is captured.
|
|
struct ColumnIdentCollector {
|
|
refs: Vec<Vec<String>>,
|
|
// Depth of nested (sub)queries inside the expression. Identifiers are only
|
|
// captured at depth 0: a scalar/correlated subquery's columns belong to ITS
|
|
// own FROM, not the outer projection's, so descending would misattribute
|
|
// (e.g. `(SELECT x FROM other) AS c FROM orders` must NOT bind `c` to
|
|
// `orders.x`). Subquery-derived columns are simply left to annotations.
|
|
query_depth: usize,
|
|
}
|
|
|
|
impl Visitor for ColumnIdentCollector {
|
|
type Break = ();
|
|
|
|
fn pre_visit_query(&mut self, _query: &sqlparser::ast::Query) -> std::ops::ControlFlow<()> {
|
|
self.query_depth += 1;
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn post_visit_query(&mut self, _query: &sqlparser::ast::Query) -> std::ops::ControlFlow<()> {
|
|
self.query_depth = self.query_depth.saturating_sub(1);
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn pre_visit_expr(&mut self, expr: &Expr) -> std::ops::ControlFlow<Self::Break> {
|
|
if self.query_depth == 0 {
|
|
match expr {
|
|
Expr::Identifier(id) => self.refs.push(vec![id.value.clone()]),
|
|
Expr::CompoundIdentifier(parts) => self
|
|
.refs
|
|
.push(parts.iter().map(|id| id.value.clone()).collect()),
|
|
_ => {}
|
|
}
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
}
|
|
|
|
/// Extract the column names a bare SQL expression reads, for validating a
|
|
/// metric measure/dimension body against a captured schema. Parses `expr_sql` as
|
|
/// a standalone expression (so its identifiers sit at query depth 0, unlike a
|
|
/// wrapped `SELECT`) and returns the final segment of each identifier ref:
|
|
/// `sum(amount)` → `["amount"]`, `date_trunc('month', ordered_at)` →
|
|
/// `["ordered_at"]`, `t.amount` → `["amount"]`, `count(*)` → `[]`. A parse
|
|
/// failure yields an empty vec (fail-safe: nothing to check, not a spurious
|
|
/// error), matching the fail-soft stance of the rest of the contract machinery.
|
|
pub fn extract_expr_column_idents(expr_sql: &str) -> Vec<String> {
|
|
let Ok(mut parser) = Parser::new(&DuckDbDialect).try_with_sql(expr_sql) else {
|
|
return Vec::new();
|
|
};
|
|
let Ok(expr) = parser.parse_expr() else {
|
|
return Vec::new();
|
|
};
|
|
let mut collector = ColumnIdentCollector { refs: Vec::new(), query_depth: 0 };
|
|
let _ = expr.visit(&mut collector);
|
|
collector
|
|
.refs
|
|
.into_iter()
|
|
.filter_map(|parts| parts.last().cloned())
|
|
.collect()
|
|
}
|
|
|
|
/// Whether `expr_sql` is exactly one SQL expression, consuming all of its input.
|
|
///
|
|
/// Metric declarations are author text interpolated verbatim into executable SQL
|
|
/// that a *reader* then runs. Without this, `count(*) FROM t; DELETE FROM x; SELECT 1`
|
|
/// would be stored as a "measure" and execute as whoever opened the drawer, so a
|
|
/// declaration that does not parse as a single trailing-token-free expression is
|
|
/// rejected at deploy rather than filtered at render time.
|
|
pub fn is_single_sql_expression(expr_sql: &str) -> bool {
|
|
use sqlparser::tokenizer::{Token, Tokenizer};
|
|
// Reject comment tokens outright: the parser skips them, so `sum(x) --rest`
|
|
// would pass the EOF check below, and a comment interpolated into the composed
|
|
// SQL can blank the rest of its line (defense in depth — it cannot inject a
|
|
// second statement, but a declaration has no business carrying a comment).
|
|
match Tokenizer::new(&DuckDbDialect, expr_sql).tokenize() {
|
|
Ok(tokens) => {
|
|
if tokens.iter().any(|t| {
|
|
matches!(t, Token::Whitespace(w)
|
|
if matches!(w, sqlparser::tokenizer::Whitespace::SingleLineComment { .. }
|
|
| sqlparser::tokenizer::Whitespace::MultiLineComment(_)))
|
|
}) {
|
|
return false;
|
|
}
|
|
}
|
|
Err(_) => return false,
|
|
}
|
|
let Ok(mut parser) = Parser::new(&DuckDbDialect).try_with_sql(expr_sql) else {
|
|
return false;
|
|
};
|
|
if parser.parse_expr().is_err() {
|
|
return false;
|
|
}
|
|
// Anything left over means the expression ended early and the rest would ride
|
|
// along into the generated statement.
|
|
matches!(parser.peek_token().token, Token::EOF)
|
|
}
|
|
|
|
struct FunctionCallFinder {
|
|
found: bool,
|
|
}
|
|
|
|
impl Visitor for FunctionCallFinder {
|
|
type Break = ();
|
|
fn pre_visit_expr(&mut self, expr: &Expr) -> std::ops::ControlFlow<()> {
|
|
if matches!(expr, Expr::Function(_)) {
|
|
self.found = true;
|
|
return std::ops::ControlFlow::Break(());
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
}
|
|
|
|
/// Whether `expr_sql` could be an aggregation. Every SQL aggregate is a function
|
|
/// call (`sum(x)`, `count(*)`, a scalar subquery wrapping one, a user-defined
|
|
/// aggregate), so an expression with *no* function call anywhere provably cannot
|
|
/// aggregate: `revenue = amount` or `amount * 2` is a row-level value, not a
|
|
/// measure. Deliberately lenient — any function call earns the benefit of the
|
|
/// doubt — to avoid maintaining a reserved aggregate-name list, which drifts with
|
|
/// DuckDB versions. A parse failure returns `true` (not our error to raise here;
|
|
/// `is_single_sql_expression` already rejects unparseable declarations).
|
|
pub fn measure_expr_may_aggregate(expr_sql: &str) -> bool {
|
|
let Ok(mut parser) = Parser::new(&DuckDbDialect).try_with_sql(expr_sql) else {
|
|
return true;
|
|
};
|
|
let Ok(expr) = parser.parse_expr() else {
|
|
return true;
|
|
};
|
|
let mut finder = FunctionCallFinder { found: false };
|
|
let _ = expr.visit(&mut finder);
|
|
finder.found
|
|
}
|
|
|
|
/// Whether `expr_sql` is exactly one top-level function call, e.g. `sum(amount)`
|
|
/// or `count(*)` — but not `sum(a) / count(b)` or `sum(a) + 1`.
|
|
///
|
|
/// A `// measure … where <pred>` compiles to `<expr> FILTER (WHERE <pred>)`, and
|
|
/// SQL binds `FILTER` to the single immediately-preceding aggregate call. If the
|
|
/// expression is a composite of several aggregates, the filter silently applies to
|
|
/// only the last one and the canonical number is wrong. Requiring one call makes
|
|
/// the target of `FILTER` unambiguous. (A single *non-aggregate* call still errors
|
|
/// loudly at run time, which is acceptable — the danger is the silent case.)
|
|
pub fn is_single_function_call(expr_sql: &str) -> bool {
|
|
let Ok(mut parser) = Parser::new(&DuckDbDialect).try_with_sql(expr_sql) else {
|
|
return false;
|
|
};
|
|
match parser.parse_expr() {
|
|
Ok(Expr::Function(_)) => {
|
|
matches!(parser.peek_token().token, sqlparser::tokenizer::Token::EOF)
|
|
}
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
impl Visitor for AssetCollector {
|
|
type Break = ();
|
|
|
|
fn pre_visit_table_factor(
|
|
&mut self,
|
|
table_factor: &TableFactor,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
match table_factor {
|
|
TableFactor::Table { name, args, .. } => {
|
|
if args.is_none() {
|
|
// FROM 's3:///…' is a definitive read — record it directly
|
|
// so it isn't demoted to a weak in-query mention.
|
|
if let Some(asset) = self.get_s3_asset_from_str_literal_table(table_factor) {
|
|
self.assets.push(asset);
|
|
}
|
|
}
|
|
// For a read-function table factor this pushes ReadFn, making
|
|
// every literal inside its arguments a definitive read — the
|
|
// direct form (read_csv('s3:///…')) but also list and named
|
|
// arguments (read_parquet(['s3:///…'])). Must run for BOTH the
|
|
// plain-table and table-function branches: post_visit_table_factor
|
|
// pops via handle_obj_name_post unconditionally, so skipping the
|
|
// push here would unbalance the stack.
|
|
self.handle_obj_name_pre(name);
|
|
}
|
|
_ => {}
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn post_visit_table_factor(
|
|
&mut self,
|
|
table_factor: &TableFactor,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
match table_factor {
|
|
TableFactor::Table { name, .. } => self.handle_obj_name_post(name),
|
|
_ => {}
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn pre_visit_expr(&mut self, expr: &Expr) -> std::ops::ControlFlow<Self::Break> {
|
|
match expr {
|
|
Expr::Value(ValueWithSpan { value: Value::SingleQuotedString(s), .. }) => {
|
|
self.handle_string_literal(s)
|
|
}
|
|
Expr::Value(ValueWithSpan { value: Value::DoubleQuotedString(s), .. }) => {
|
|
self.handle_string_literal(s);
|
|
}
|
|
// Read-function call in expression position: its argument literals
|
|
// are definitive reads. Balances the pop in `post_visit_expr`.
|
|
Expr::Function(func) => {
|
|
if get_trivial_obj_name(&func.name).is_some_and(is_read_fn) {
|
|
self.current_access_type_stack.push(AccessCtx::ReadFn);
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn post_visit_expr(&mut self, expr: &Expr) -> std::ops::ControlFlow<Self::Break> {
|
|
match expr {
|
|
Expr::Function(func) => self.handle_obj_name_post(&func.name),
|
|
_ => {}
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn pre_visit_statement(
|
|
&mut self,
|
|
statement: &sqlparser::ast::Statement,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
match statement {
|
|
sqlparser::ast::Statement::Query(q) => {
|
|
// A top-level SELECT is the managed-materialize output, so its
|
|
// columns ARE the materialized asset's columns (output resolved
|
|
// to the `// materialize` target in `parse_assets`).
|
|
self.handle_query_reads(q);
|
|
self.infer_query_output(q, None);
|
|
}
|
|
|
|
sqlparser::ast::Statement::Insert(insert) => {
|
|
let access_type = if insert.returning.is_some() { RW } else { W };
|
|
match insert.table {
|
|
TableObject::TableName(ref name) => {
|
|
if let Some(asset) =
|
|
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
|
{
|
|
// Add table-level asset
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: asset.access_type,
|
|
columns: None,
|
|
});
|
|
|
|
// Extract column information for INSERT with explicit columns (Write access)
|
|
if !insert.columns.is_empty() {
|
|
for col in &insert.columns {
|
|
let columns = BTreeMap::from([(col.value.clone(), W)]);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(W),
|
|
columns: Some(columns),
|
|
});
|
|
}
|
|
}
|
|
|
|
// Extract column information from RETURNING clause (Read access)
|
|
if let Some(returning) = &insert.returning {
|
|
for item in returning {
|
|
match item {
|
|
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
|
| SelectItem::ExprWithAlias {
|
|
expr: Expr::Identifier(ident),
|
|
..
|
|
} => {
|
|
let mut col_map = BTreeMap::new();
|
|
col_map.insert(ident.value.clone(), R);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(R),
|
|
columns: Some(col_map),
|
|
});
|
|
}
|
|
_ => {
|
|
// Ignore wildcards and complex expressions
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::Update { returning, table, from, assignments, .. } => {
|
|
if let Some(from_tables) = from {
|
|
let from_tables = match from_tables {
|
|
sqlparser::ast::UpdateTableFromKind::AfterSet(tables) => tables,
|
|
sqlparser::ast::UpdateTableFromKind::BeforeSet(tables) => tables,
|
|
};
|
|
for table_with_joins in from_tables {
|
|
self.handle_table_with_joins(table_with_joins, Some(R));
|
|
}
|
|
}
|
|
|
|
let access_type = if returning.is_some() { RW } else { W };
|
|
self.handle_table_with_joins(table, Some(access_type));
|
|
|
|
// Extract column information from UPDATE SET clauses (Write access)
|
|
// Only process if it's a single table update
|
|
if let TableFactor::Table { name, .. } = &table.relation {
|
|
if let Some(asset) =
|
|
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
|
{
|
|
// Process each assignment to extract column names
|
|
for assignment in assignments {
|
|
// assignment.target is an AssignmentTarget enum
|
|
// We only handle simple column names (ColumnName variant)
|
|
if let sqlparser::ast::AssignmentTarget::ColumnName(col_name) =
|
|
&assignment.target
|
|
{
|
|
// For simple column updates, this is typically a single ident
|
|
if col_name.0.len() == 1 {
|
|
if let Some(col_ident) =
|
|
col_name.0.first().and_then(|p| p.as_ident())
|
|
{
|
|
let mut col_map = BTreeMap::new();
|
|
col_map.insert(col_ident.value.clone(), W);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(W),
|
|
columns: Some(col_map),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Extract column information from RETURNING clause (Read access)
|
|
if let Some(returning_items) = returning {
|
|
for item in returning_items {
|
|
match item {
|
|
SelectItem::UnnamedExpr(Expr::Identifier(ident))
|
|
| SelectItem::ExprWithAlias {
|
|
expr: Expr::Identifier(ident),
|
|
..
|
|
} => {
|
|
let mut col_map = BTreeMap::new();
|
|
col_map.insert(ident.value.clone(), R);
|
|
self.assets.push(ParseAssetsResult {
|
|
kind: asset.kind,
|
|
path: asset.path.clone(),
|
|
access_type: Some(R),
|
|
columns: Some(col_map),
|
|
});
|
|
}
|
|
_ => {
|
|
// Ignore wildcards and complex expressions
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::Delete(delete) => {
|
|
let access_type = if delete.returning.is_some() { RW } else { W };
|
|
for name in &delete.tables {
|
|
if let Some(asset) =
|
|
self.get_associated_asset_from_obj_name(name, Some(access_type))
|
|
{
|
|
self.assets.push(asset);
|
|
}
|
|
}
|
|
let tables = match &delete.from {
|
|
sqlparser::ast::FromTable::WithFromKeyword(tables) => tables,
|
|
sqlparser::ast::FromTable::WithoutKeyword(tables) => tables,
|
|
};
|
|
for table_with_joins in tables {
|
|
self.handle_table_with_joins(table_with_joins, Some(access_type));
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::CreateTable(create_table) => {
|
|
self.track_table_definition(&create_table.name, create_table.temporary);
|
|
// `CREATE TABLE x AS SELECT … FROM y` reads y. The AS-query
|
|
// isn't a `Statement::Query`, so its FROM tables are only
|
|
// caught here. Only infer output lineage when `x` is a real
|
|
// asset — a CTAS into a local/temp staging table is not the
|
|
// materialized output (its columns aren't the asset's).
|
|
if let Some(query) = &create_table.query {
|
|
self.handle_query_reads(query);
|
|
// Infer only when `x` is a real asset (its output columns
|
|
// ARE that asset's), tagged with it so `parse_assets` can
|
|
// scope lineage per output. A local/temp staging table is
|
|
// not an asset → not inferred.
|
|
if let Some(asset) =
|
|
self.get_associated_asset_from_obj_name(&create_table.name, Some(W))
|
|
{
|
|
self.infer_query_output(query, Some((asset.kind, asset.path)));
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::CreateView { name, query, temporary, .. } => {
|
|
self.track_table_definition(name, *temporary);
|
|
self.handle_query_reads(query);
|
|
if let Some(asset) = self.get_associated_asset_from_obj_name(name, Some(W)) {
|
|
self.infer_query_output(query, Some((asset.kind, asset.path)));
|
|
}
|
|
}
|
|
|
|
// DROP TABLE/VIEW is a write to the dropped object — the
|
|
// canonical idempotent-refresh pattern (`DROP TABLE IF EXISTS x;
|
|
// CREATE TABLE x AS …`) must resolve to a table-level write.
|
|
// Without this arm, a tagged-template snippet containing only the
|
|
// DROP yields no table-level asset and the TS/Python SDK parsers
|
|
// fall back to a db-level `datatable://<db>` reference, putting a
|
|
// stray database node on the pipeline canvas.
|
|
sqlparser::ast::Statement::Drop { object_type, names, .. } => {
|
|
if matches!(
|
|
object_type,
|
|
sqlparser::ast::ObjectType::Table
|
|
| sqlparser::ast::ObjectType::View
|
|
| sqlparser::ast::ObjectType::MaterializedView
|
|
) {
|
|
for name in names {
|
|
// DROP is a write to the named object; resolve it as an
|
|
// asset if it is one (not a temp-creation context).
|
|
self.track_table_definition(name, false);
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::Copy { target: CopyTarget::File { filename }, .. } => {
|
|
self.current_access_type_stack.push(AccessCtx::CopyWrite);
|
|
self.handle_string_literal(filename);
|
|
self.current_access_type_stack.pop();
|
|
}
|
|
|
|
sqlparser::ast::Statement::AttachDuckDBDatabase {
|
|
database_path,
|
|
database_alias,
|
|
..
|
|
} => {
|
|
if let Some((kind, path)) = parse_asset_syntax(&database_path.value, true) {
|
|
if kind == AssetKind::Ducklake
|
|
|| kind == AssetKind::DataTable
|
|
|| kind == AssetKind::Resource
|
|
{
|
|
if let Some(database_alias) = database_alias {
|
|
self.var_identifiers
|
|
.insert(database_alias.value.clone(), (kind, path.to_string()));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::DetachDuckDBDatabase { database_alias, .. } => {
|
|
let asset = self.var_identifiers.remove(&database_alias.value);
|
|
if self.currently_used_asset == asset {
|
|
self.currently_used_asset = None;
|
|
}
|
|
}
|
|
|
|
sqlparser::ast::Statement::Use(sqlparser::ast::Use::Object(obj_name)) => {
|
|
if let Some((kind, path)) = self.var_identifiers.get(&obj_name.to_string()) {
|
|
self.currently_used_asset = Some((*kind, path.clone()));
|
|
} else {
|
|
self.currently_used_asset = None;
|
|
}
|
|
}
|
|
|
|
_ => {}
|
|
}
|
|
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn post_visit_statement(
|
|
&mut self,
|
|
statement: &sqlparser::ast::Statement,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
// Balance the push done by handle_query_reads (called from the Query,
|
|
// CreateView, and CTAS arms).
|
|
let pushed = match statement {
|
|
sqlparser::ast::Statement::Query(_) | sqlparser::ast::Statement::CreateView { .. } => {
|
|
true
|
|
}
|
|
sqlparser::ast::Statement::CreateTable(ct) => ct.query.is_some(),
|
|
_ => false,
|
|
};
|
|
if pushed {
|
|
self.cte_name_stack.pop();
|
|
}
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn pre_visit_query(
|
|
&mut self,
|
|
query: &sqlparser::ast::Query,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
self.current_access_type_stack.push(AccessCtx::QueryRead);
|
|
self.cte_name_stack.push(collect_cte_names(query));
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
fn post_visit_query(
|
|
&mut self,
|
|
_query: &sqlparser::ast::Query,
|
|
) -> std::ops::ControlFlow<Self::Break> {
|
|
self.current_access_type_stack.pop();
|
|
self.cte_name_stack.pop();
|
|
std::ops::ControlFlow::Continue(())
|
|
}
|
|
|
|
// We do not use pre_visit_relation because we cannot know if an ObjectName is a table or a function
|
|
}
|
|
|
|
fn collect_cte_names(query: &sqlparser::ast::Query) -> HashSet<String> {
|
|
query.with.as_ref().map_or_else(HashSet::new, |with| {
|
|
with.cte_tables
|
|
.iter()
|
|
.map(|cte| cte.alias.name.value.to_lowercase())
|
|
.collect()
|
|
})
|
|
}
|
|
|
|
fn is_read_fn(fname: &str) -> bool {
|
|
fname.eq_ignore_ascii_case("read_parquet")
|
|
|| fname.eq_ignore_ascii_case("read_csv")
|
|
|| fname.eq_ignore_ascii_case("read_json")
|
|
}
|
|
|
|
fn get_trivial_obj_name(name: &sqlparser::ast::ObjectName) -> Option<&str> {
|
|
if name.0.len() != 1 {
|
|
return None;
|
|
}
|
|
Some(name.0.first()?.as_ident()?.value.as_str())
|
|
}
|
|
|
|
fn get_str_lit_from_obj_name(name: &ObjectName) -> Option<&str> {
|
|
if name.0.len() != 1 {
|
|
return None;
|
|
}
|
|
let ident = name.0.first()?.as_ident()?;
|
|
if ident.quote_style != Some('\'') {
|
|
return None;
|
|
}
|
|
Some(ident.value.as_str())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_literals() {
|
|
let input = r#"
|
|
SELECT * FROM read_parquet('s3:///a.parquet');
|
|
COPY (SELECT * FROM 's3://snd/b.parquet') TO 's3:///c.parquet';
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![
|
|
ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/a.parquet".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},
|
|
ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/c.parquet".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},
|
|
ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "snd/b.parquet".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_duckdb_read_key_matches_sdk_write_key() {
|
|
// Cross-language lineage: a TS `writeS3File({ s3: "exports/x" })` or
|
|
// Python `write_s3_file(S3Object(s3="exports/x"))` records the asset
|
|
// path `/exports/x` (default storage, leading slash). A DuckDB reader
|
|
// of the same object uses the triple-slash default-storage URI and
|
|
// must resolve to the identical path so the graph connects producer
|
|
// and consumer. The bare `s3://exports/x` form names storage
|
|
// `exports` instead — a different object, a different path.
|
|
let input = "SELECT * FROM read_csv('s3:///exports/x');";
|
|
let assets = parse_assets(input).expect("parse").assets;
|
|
assert_eq!(
|
|
assets,
|
|
vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/exports/x".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
}],
|
|
);
|
|
|
|
let input = "SELECT * FROM read_csv('s3://exports/x');";
|
|
let assets = parse_assets(input).expect("parse").assets;
|
|
assert_eq!(
|
|
assets,
|
|
vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "exports/x".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
}],
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_copy_target_echoed_in_select_stays_write_only() {
|
|
// The trailing summary SELECT merely mentions the COPY target — it
|
|
// must not add a read (rw would draw an asset⇄script cycle).
|
|
let input = r#"
|
|
COPY (SELECT 1 AS x) TO 's3:///out.csv';
|
|
SELECT 's3:///out.csv' AS target, 42 AS rows_written;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/out.csv".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_bare_string_mention_without_other_usage_is_a_read() {
|
|
let input = r#"
|
|
SELECT 's3:///referenced.csv' AS path;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/referenced.csv".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_self_refresh_read_fn_plus_copy_stays_rw() {
|
|
// A definitive read (read_csv) of the same file the script rewrites
|
|
// is a real rw — only *bare-literal* mentions are demoted.
|
|
let input = r#"
|
|
CREATE TABLE tmp AS SELECT * FROM read_csv('s3:///data.csv');
|
|
COPY (SELECT * FROM tmp) TO 's3:///data.csv';
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/data.csv".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_read_fn_list_arg_plus_copy_stays_rw() {
|
|
// read_parquet's list form is as definitive as the direct literal —
|
|
// it must not be demoted to a weak mention when the file is rewritten.
|
|
let input = r#"
|
|
CREATE TABLE tmp AS SELECT * FROM read_parquet(['s3:///data.parquet']);
|
|
COPY (SELECT * FROM tmp) TO 's3:///data.parquet';
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/data.parquet".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_read_fn_list_arg_multiple_files_are_reads() {
|
|
let input = r#"
|
|
SELECT * FROM read_parquet(['s3:///a.parquet', 's3:///b.parquet']);
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![
|
|
ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/a.parquet".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},
|
|
ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/b.parquet".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
}
|
|
])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_from_string_literal_of_written_file_stays_rw() {
|
|
// FROM-position string literal is likewise a definitive read.
|
|
let input = r#"
|
|
CREATE TABLE tmp AS SELECT * FROM 's3:///data.parquet';
|
|
COPY (SELECT * FROM tmp) TO 's3:///data.parquet';
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::S3Object,
|
|
path: "/data.parquet".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
}])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_attach_no_usage_is_registered_as_unknown() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT 2;
|
|
USE dl;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl".to_string(),
|
|
access_type: None,
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_attach_dot_notation_read() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT * FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/table1".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_attach_dot_notation_write() {
|
|
let input = r#"
|
|
ATTACH 'datatable://my_dt' AS dt;
|
|
SELECT dt.read_bait FROM unrelated_table; -- dt. doesn't access the asset
|
|
INSERT INTO dt.table1 VALUES ('test');
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "my_dt/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_detach() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
DETACH dl;
|
|
SELECT * FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(s.map_err(|e| e.to_string()), Ok(vec![]));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_implicit_use_asset() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
INSERT INTO table1 VALUES ('test');
|
|
USE memory;
|
|
SELECT * FROM table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_default_main() {
|
|
let input = r#"
|
|
ATTACH 'datatable' AS dl;
|
|
INSERT INTO dl.table1 VALUES ('test');
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "main/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_create_table() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl; USE dl;
|
|
CREATE TABLE friends (
|
|
name text,
|
|
age int
|
|
);
|
|
INSERT INTO friends VALUES ($name, $age);
|
|
SELECT * FROM friends;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/friends".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_drop_table_is_write() {
|
|
// A lone DROP (e.g. one SDK tagged-template snippet of an
|
|
// idempotent-refresh script) must resolve to a table-level write,
|
|
// not fall through to nothing.
|
|
let input = r#"
|
|
ATTACH 'datatable://main' AS dt; USE dt;
|
|
DROP TABLE IF EXISTS orders_raw;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "main/orders_raw".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_drop_then_create_qualified() {
|
|
// Canonical refresh pattern over an attached catalog: DROP + CREATE
|
|
// AS on the output, SELECT on the input.
|
|
let input = r#"
|
|
ATTACH 'datatable://main' AS pg;
|
|
DROP TABLE IF EXISTS pg.daily_revenue;
|
|
CREATE TABLE pg.daily_revenue AS SELECT * FROM pg.orders_clean;
|
|
"#;
|
|
let s = parse_assets(input).map(|mut s| {
|
|
s.assets.sort_by(|a, b| a.path.cmp(&b.path));
|
|
s.assets
|
|
});
|
|
// The DROP+CTAS combo yields a clean write of the output plus the
|
|
// read of the CTAS source (`SELECT * FROM pg.orders_clean`).
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![
|
|
ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "main/daily_revenue".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},
|
|
ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "main/orders_clean".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},
|
|
])
|
|
);
|
|
}
|
|
|
|
// Make sure a_function is not detected as main/a_function
|
|
#[test]
|
|
fn test_sql_asset_parser_function_table() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl; USE dl;
|
|
SELECT * FROM a_function('');
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main".to_string(),
|
|
access_type: None,
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_delete() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl;
|
|
USE dl;
|
|
DELETE FROM table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_update() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl;
|
|
USE dl;
|
|
UPDATE table1 SET id = NULL;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_resource() {
|
|
let input = r#"
|
|
ATTACH 'res://u/user/pg_resource' AS db (TYPE postgres);
|
|
USE db;
|
|
SELECT * FROM table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Resource,
|
|
path: "u/user/pg_resource?table=table1".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_update_with_dot_notation() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl;
|
|
UPDATE dl.table1 SET id = NULL;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/table1".to_string(),
|
|
access_type: Some(W),
|
|
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_resource_vs_ducklake_syntax() {
|
|
// Test that Resource uses ?table= while Ducklake uses /table
|
|
let input_resource = r#"
|
|
ATTACH 'res://u/user/pg_resource' AS db (TYPE postgres);
|
|
SELECT * FROM db.users;
|
|
"#;
|
|
let s = parse_assets(input_resource).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Resource,
|
|
path: "u/user/pg_resource?table=users".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
|
|
let input_ducklake = r#"
|
|
ATTACH 'ducklake://my_lake' AS dl;
|
|
SELECT * FROM dl.users;
|
|
"#;
|
|
let s = parse_assets(input_ducklake).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_lake/users".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
|
|
let input_datatable = r#"
|
|
ATTACH 'datatable://dt1' AS dt;
|
|
SELECT * FROM dt.users;
|
|
"#;
|
|
let s = parse_assets(input_datatable).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::DataTable,
|
|
path: "dt1/users".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_resource_with_long_path() {
|
|
// Test that Resource works with paths longer than 3 components
|
|
let input = r#"
|
|
ATTACH 'res://u/diego/a/b/c/my_postgres_resource' AS db (TYPE postgres);
|
|
USE db;
|
|
SELECT * FROM my_table;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Resource,
|
|
path: "u/diego/a/b/c/my_postgres_resource?table=my_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_table_with_schema() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl;
|
|
UPDATE dl.sch.table1 SET id = NULL;
|
|
SELECT * FROM dl.sch.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/sch.table1".to_string(),
|
|
access_type: Some(RW),
|
|
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_table_with_schema_implicit() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl;
|
|
USE dl;
|
|
UPDATE sch.table1 SET id = NULL;
|
|
SELECT * FROM sch.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/sch.table1".to_string(),
|
|
access_type: Some(RW),
|
|
columns: Some(BTreeMap::from([("id".to_string(), W)])),
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_single_table_column_detection() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT a, b FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should have one asset with merged columns
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, "my_dl/table1");
|
|
assert_eq!(result[0].access_type, Some(R));
|
|
|
|
// Check that both columns are present in the merged asset
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 2);
|
|
assert_eq!(columns.get("a"), Some(&R));
|
|
assert_eq!(columns.get("b"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_explicit_table_prefix_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT dl.table1.a, dl.table1.b FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
// Should detect columns with explicit table prefix
|
|
let result = s.unwrap();
|
|
// Check we have the table asset
|
|
|
|
// Check we have column assets
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("a"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("b"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_multi_table_no_simple_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT a, b FROM dl.table1, dl.table2;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
// Simple columns (a, b) should NOT be detected with multiple tables
|
|
// Only table-level assets should be present
|
|
let result = s.unwrap();
|
|
|
|
// Should have 2 table assets
|
|
assert_eq!(result.iter().filter(|a| a.columns.is_none()).count(), 2);
|
|
|
|
// Should have NO column assets (ambiguous which table they belong to)
|
|
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_multi_table_with_qualified_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl1' AS dl1;
|
|
ATTACH 'ducklake://my_dl2' AS dl2;
|
|
SELECT table1.a, table2.b FROM dl1.table1, dl2.table2;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
// Qualified columns should be detected even with multiple tables
|
|
let result = s.unwrap();
|
|
|
|
// Check we have column assets for both tables
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl1/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("a"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl2/table2"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("b"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_use_with_simple_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
SELECT a, b, c FROM table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should detect columns since it's a single table
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("a"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("b"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("c"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_wildcard_no_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT * FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Wildcard should NOT create column assets, only table asset
|
|
assert_eq!(result.len(), 1);
|
|
assert!(result[0].columns.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_columns_with_alias() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT a AS column_a, b AS column_b FROM dl.table1;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should detect columns even when aliased
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("a"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("b"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_columns_with_table_alias() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT t.a, t.b FROM dl.table1 AS t;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should detect columns using the table alias
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("a"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "my_dl/table1"
|
|
&& a.columns
|
|
.as_ref()
|
|
.map_or(false, |cols| cols.contains_key("b"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_insert_with_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
INSERT INTO dl.table1 (name, age, email) VALUES ('John', 30, 'john@example.com');
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should have one asset with merged columns
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, "my_dl/table1");
|
|
assert_eq!(result[0].access_type, Some(W));
|
|
|
|
// Check that all columns are present in the merged asset
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 3);
|
|
assert_eq!(columns.get("name"), Some(&W));
|
|
assert_eq!(columns.get("age"), Some(&W));
|
|
assert_eq!(columns.get("email"), Some(&W));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_insert_without_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
INSERT INTO dl.table1 VALUES ('John', 30);
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should have one asset without column information
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, "my_dl/table1");
|
|
assert_eq!(result[0].access_type, Some(W));
|
|
assert!(result[0].columns.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_update_multiple_columns() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
UPDATE dl.table1 SET name = 'Jane', age = 25, active = true;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should have one asset with merged columns
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, "my_dl/table1");
|
|
assert_eq!(result[0].access_type, Some(W));
|
|
|
|
// Check that all columns are present
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 3);
|
|
assert_eq!(columns.get("name"), Some(&W));
|
|
assert_eq!(columns.get("age"), Some(&W));
|
|
assert_eq!(columns.get("active"), Some(&W));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_update_returning() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
UPDATE dl.table1 SET name = 'Jane', age = 26 RETURNING id, name;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
|
|
let result = s.unwrap();
|
|
|
|
// Should have RW access type when RETURNING is used
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].path, "my_dl/table1");
|
|
assert_eq!(result[0].access_type, Some(RW));
|
|
|
|
// Check that columns are present with correct access types
|
|
// name and age are written (W), id and name are read (R)
|
|
// name should be RW (both written and read)
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 3);
|
|
assert_eq!(columns.get("name"), Some(&RW)); // Written in SET, read in RETURNING
|
|
assert_eq!(columns.get("age"), Some(&W)); // Only written
|
|
assert_eq!(columns.get("id"), Some(&R)); // Only read
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_single_table_column_detection() {
|
|
let input = r#"
|
|
SELECT col1, col2 FROM read_parquet('s3:///example_file.parquet');
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "/example_file.parquet");
|
|
assert_eq!(result[0].access_type, Some(R));
|
|
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 2);
|
|
assert_eq!(columns.get("col1"), Some(&R));
|
|
assert_eq!(columns.get("col2"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_single_table_column_with_alias() {
|
|
let input = r#"
|
|
SELECT col1 AS c1, col2 AS c2 FROM read_parquet('s3:///example_file.parquet');
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.get("col1"), Some(&R));
|
|
assert_eq!(columns.get("col2"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_wildcard_no_columns() {
|
|
let input = r#"
|
|
SELECT * FROM read_parquet('s3:///example_file.parquet');
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "/example_file.parquet");
|
|
assert!(result[0].columns.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_table_alias_qualified_columns() {
|
|
let input = r#"
|
|
SELECT t.col1, t.col2 FROM read_parquet('s3:///example_file.parquet') AS t;
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "/example_file.parquet");
|
|
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.get("col1"), Some(&R));
|
|
assert_eq!(columns.get("col2"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_multi_table_aliased_columns() {
|
|
let input = r#"
|
|
SELECT t1.col1, t2.col2
|
|
FROM read_parquet('s3:///file1.parquet') AS t1,
|
|
read_csv('s3://bucket/file2.csv') AS t2;
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 2);
|
|
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "/file1.parquet"
|
|
&& a.columns.as_ref().map_or(false, |c| c.contains_key("col1"))
|
|
}));
|
|
assert!(result.iter().any(|a| {
|
|
a.path == "bucket/file2.csv"
|
|
&& a.columns.as_ref().map_or(false, |c| c.contains_key("col2"))
|
|
}));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_multi_table_no_alias_no_columns() {
|
|
// Without aliases, unqualified columns in a multi-table query are ambiguous
|
|
let input = r#"
|
|
SELECT col1, col2
|
|
FROM read_parquet('s3:///file1.parquet'),
|
|
read_parquet('s3:///file2.parquet');
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
// Table-level assets should still be detected, but no columns
|
|
assert_eq!(result.iter().filter(|a| a.columns.is_some()).count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_str_literal_table_column_detection() {
|
|
// FROM 's3:///file.parquet' (string literal as table, no read_parquet wrapper)
|
|
let input = r#"
|
|
SELECT a,b,c FROM 's3:///test.parquet';
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "/test.parquet");
|
|
assert_eq!(result[0].access_type, Some(R));
|
|
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.len(), 3);
|
|
assert_eq!(columns.get("a"), Some(&R));
|
|
assert_eq!(columns.get("b"), Some(&R));
|
|
assert_eq!(columns.get("c"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_str_literal_table_with_alias_columns() {
|
|
let input = r#"
|
|
SELECT t.col1, t.col2 FROM 's3://bucket/file.parquet' AS t;
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "bucket/file.parquet");
|
|
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.get("col1"), Some(&R));
|
|
assert_eq!(columns.get("col2"), Some(&R));
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_str_literal_wildcard_no_columns() {
|
|
let input = r#"
|
|
SELECT * FROM 's3:///test.parquet';
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert!(result[0].columns.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_cte_not_treated_as_asset() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
WITH tmp AS (SELECT 1 AS x)
|
|
SELECT * FROM tmp;
|
|
SELECT * FROM real_table;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/real_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_cte_scope_does_not_leak() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
WITH tmp AS (SELECT 1) SELECT * FROM tmp;
|
|
SELECT * FROM tmp;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/tmp".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_multiple_ctes() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
WITH cte1 AS (SELECT 1), cte2 AS (SELECT 2)
|
|
SELECT * FROM cte1 JOIN cte2 ON true;
|
|
SELECT * FROM real_table;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/real_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_local_create_table_overrides_asset() {
|
|
let input = r#"
|
|
CREATE TABLE local_tbl (id INT);
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
SELECT * FROM local_tbl;
|
|
SELECT * FROM asset_table;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/asset_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_create_table_with_use_is_still_asset() {
|
|
let input = r#"
|
|
ATTACH 'ducklake' AS dl; USE dl;
|
|
CREATE TABLE friends (
|
|
name text,
|
|
age int
|
|
);
|
|
INSERT INTO friends VALUES ($name, $age);
|
|
SELECT * FROM friends;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "main/friends".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_local_create_view_overrides_asset() {
|
|
let input = r#"
|
|
CREATE VIEW my_view AS SELECT 1;
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
SELECT * FROM my_view;
|
|
SELECT * FROM asset_table;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/asset_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_create_view_with_use_is_still_asset() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
CREATE VIEW my_view AS SELECT 1;
|
|
SELECT * FROM my_view;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/my_view".to_string(),
|
|
access_type: Some(RW),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_cte_mixed_with_asset_tables() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
WITH tmp AS (SELECT 1 AS x)
|
|
SELECT * FROM tmp JOIN real_table ON true;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/real_table".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_local_table_insert_and_select() {
|
|
let input = r#"
|
|
CREATE TABLE staging (id INT, val TEXT);
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
INSERT INTO staging VALUES (1, 'a');
|
|
SELECT * FROM staging;
|
|
INSERT INTO real_table VALUES (2, 'b');
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/real_table".to_string(),
|
|
access_type: Some(W),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_qualified_ref_bypasses_local() {
|
|
// Even if 'tbl' is local, 'dl.tbl' is an explicit asset reference
|
|
let input = r#"
|
|
CREATE TABLE tbl (id INT);
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
SELECT * FROM dl.tbl;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl/tbl".to_string(),
|
|
access_type: Some(R),
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_cte_case_insensitive() {
|
|
let input = r#"
|
|
ATTACH 'ducklake://my_dl' AS dl;
|
|
USE dl;
|
|
WITH MyTable AS (SELECT 1)
|
|
SELECT * FROM mytable;
|
|
"#;
|
|
let s = parse_assets(input).map(|s| s.assets);
|
|
assert_eq!(
|
|
s.map_err(|e| e.to_string()),
|
|
Ok(vec![ParseAssetsResult {
|
|
kind: AssetKind::Ducklake,
|
|
path: "my_dl".to_string(),
|
|
access_type: None,
|
|
columns: None
|
|
},])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sql_asset_parser_s3_read_csv_columns() {
|
|
let input = r#"
|
|
SELECT name, age FROM read_csv('s3://my-bucket/data.csv');
|
|
"#;
|
|
let result = parse_assets(input).unwrap().assets;
|
|
|
|
assert_eq!(result.len(), 1);
|
|
assert_eq!(result[0].kind, AssetKind::S3Object);
|
|
assert_eq!(result[0].path, "my-bucket/data.csv");
|
|
|
|
let columns = result[0].columns.as_ref().expect("Should have columns");
|
|
assert_eq!(columns.get("name"), Some(&R));
|
|
assert_eq!(columns.get("age"), Some(&R));
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod ctas_read_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_ctas_collects_upstream_read() {
|
|
let input = r#"
|
|
ATTACH 'datatable://main' AS pg;
|
|
CREATE TABLE IF NOT EXISTS pg.exciting_809 AS
|
|
SELECT * FROM pg.fx_rates;
|
|
"#;
|
|
let assets = parse_assets(input).unwrap().assets;
|
|
assert!(
|
|
assets.iter().any(|a| a.path == "main/fx_rates"
|
|
&& a.kind == AssetKind::DataTable
|
|
&& a.access_type == Some(R)),
|
|
"expected read of main/fx_rates, got {:?}",
|
|
assets
|
|
);
|
|
assert!(
|
|
assets
|
|
.iter()
|
|
.any(|a| a.path == "main/exciting_809" && a.access_type == Some(W)),
|
|
"expected write of main/exciting_809, got {:?}",
|
|
assets
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_create_view_collects_upstream_read() {
|
|
let input = r#"
|
|
ATTACH 'datatable://main' AS pg;
|
|
CREATE VIEW pg.v AS SELECT * FROM pg.fx_rates;
|
|
"#;
|
|
let assets = parse_assets(input).unwrap().assets;
|
|
assert!(
|
|
assets
|
|
.iter()
|
|
.any(|a| a.path == "main/fx_rates" && a.access_type == Some(R)),
|
|
"expected read of main/fx_rates, got {:?}",
|
|
assets
|
|
);
|
|
}
|
|
|
|
fn lineage(input: &str) -> Vec<ColumnLineage> {
|
|
parse_assets(input).unwrap().column_lineage
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_computed_and_passthrough() {
|
|
// CTAS with a computed column (amount + tax) and a passthrough (id).
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS
|
|
SELECT dl.orders.id, dl.orders.amount + dl.orders.tax AS order_total
|
|
FROM dl.orders;
|
|
"#;
|
|
let got = lineage(input);
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
ColumnLineage {
|
|
column: "id".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "id".to_string(),
|
|
}],
|
|
},
|
|
ColumnLineage {
|
|
column: "order_total".to_string(),
|
|
inputs: vec![
|
|
ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "amount".to_string(),
|
|
},
|
|
ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "tax".to_string(),
|
|
},
|
|
],
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_bare_column_single_table() {
|
|
// Managed-materialize form: a plain top-level SELECT, bare columns
|
|
// attributed to the single FROM table.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
USE dl;
|
|
SELECT amount AS revenue FROM orders;
|
|
"#;
|
|
let got = lineage(input);
|
|
assert_eq!(
|
|
got,
|
|
vec![ColumnLineage {
|
|
column: "revenue".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "amount".to_string(),
|
|
}],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_annotation_overrides() {
|
|
// The `// column` annotation for `order_total` wins; `id` stays inferred.
|
|
let input = r#"
|
|
-- column order_total <- datatable://prod/manual.grand_total
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS
|
|
SELECT dl.orders.id, dl.orders.amount + dl.orders.tax AS order_total
|
|
FROM dl.orders;
|
|
"#;
|
|
let got = lineage(input);
|
|
// Annotation entry is authoritative and listed first.
|
|
assert_eq!(got[0].column, "order_total");
|
|
assert_eq!(got[0].inputs[0].from_path, "prod/manual");
|
|
assert_eq!(got[0].inputs[0].from_column, "grand_total");
|
|
// Inferred `id` survives; inferred `order_total` dropped (no dup).
|
|
assert!(got.iter().any(|c| c.column == "id"));
|
|
assert_eq!(got.iter().filter(|c| c.column == "order_total").count(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_skips_local_staging_ctas() {
|
|
// A CTAS into a TEMP/local table is NOT the materialized output, so its
|
|
// columns must not be reported (they'd be anchored to the script's
|
|
// `// materialize` target as if they were the final asset's columns).
|
|
// The final SELECT reads the local staging table → unresolved → empty.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TEMP TABLE tmp AS SELECT dl.orders.amount AS amt FROM dl.orders;
|
|
SELECT amt AS total FROM tmp;
|
|
"#;
|
|
assert!(
|
|
lineage(input).is_empty(),
|
|
"staging columns must not be reported as final output; got {:?}",
|
|
lineage(input)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_ctas_into_asset_still_inferred() {
|
|
// A CTAS whose target IS an asset is the output, so it's still inferred.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS SELECT dl.orders.amount AS amt FROM dl.orders;
|
|
"#;
|
|
assert_eq!(
|
|
lineage(input),
|
|
vec![ColumnLineage {
|
|
column: "amt".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "amount".to_string(),
|
|
}],
|
|
}]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_temp_table_under_use_is_local() {
|
|
// A one-part TEMP table name under an active `USE dl` must NOT resolve to
|
|
// an asset (`warehouse/tmp`); it's local, so the final SELECT reading it
|
|
// can't invent `warehouse/tmp.amt` as a column source for the output.
|
|
let input = r#"
|
|
-- materialize ducklake://warehouse/final
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
USE dl;
|
|
CREATE TEMP TABLE tmp AS SELECT amount AS amt FROM orders;
|
|
SELECT amt AS total FROM tmp;
|
|
"#;
|
|
let got = lineage(input);
|
|
assert!(
|
|
got.is_empty(),
|
|
"temp staging under USE must not leak warehouse/tmp as a source; got {:?}",
|
|
got
|
|
);
|
|
// And no phantom `warehouse/tmp` asset is recorded.
|
|
let assets = parse_assets(input).unwrap().assets;
|
|
assert!(
|
|
!assets.iter().any(|a| a.path == "warehouse/tmp"),
|
|
"temp table must not be recorded as an asset; got {:?}",
|
|
assets
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_scopes_to_materialize_target() {
|
|
// A script with a `// materialize` target plus an AUXILIARY CTAS into a
|
|
// different asset: only the materialized target's columns are reported;
|
|
// the auxiliary output's columns must not be attributed to it.
|
|
let input = r#"
|
|
-- materialize ducklake://warehouse/final
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.audit AS SELECT dl.orders.id AS aid FROM dl.orders;
|
|
SELECT dl.orders.amount AS total FROM dl.orders;
|
|
"#;
|
|
assert_eq!(
|
|
lineage(input),
|
|
vec![ColumnLineage {
|
|
column: "total".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "amount".to_string(),
|
|
}],
|
|
}],
|
|
"auxiliary `audit` columns must not appear on the materialized target"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_drops_ambiguous_multi_output() {
|
|
// No `// materialize` target and two real CTAS outputs: which asset the
|
|
// flat lineage describes is ambiguous, so inference is dropped rather
|
|
// than attributed to an arbitrary one.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.a AS SELECT dl.orders.id AS x FROM dl.orders;
|
|
CREATE TABLE dl.b AS SELECT dl.orders.amount AS y FROM dl.orders;
|
|
"#;
|
|
assert!(
|
|
lineage(input).is_empty(),
|
|
"ambiguous multi-output must drop inference"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_wildcard_yields_nothing() {
|
|
// `SELECT *` has no enumerable output columns → no inferred lineage.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS SELECT * FROM dl.orders;
|
|
"#;
|
|
assert!(lineage(input).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_resolves_joined_inputs() {
|
|
// Columns from BOTH sides of an explicit JOIN must resolve, incl. a
|
|
// computed column mixing the two. A bare column is dropped (ambiguous
|
|
// across the join) rather than misattributed to the first table.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS
|
|
SELECT o.id, c.region AS cust_region, o.amount + c.discount AS net
|
|
FROM dl.orders o
|
|
JOIN dl.customers c ON c.id = o.customer_id;
|
|
"#;
|
|
let got = lineage(input);
|
|
assert_eq!(
|
|
got,
|
|
vec![
|
|
ColumnLineage {
|
|
column: "id".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "id".to_string(),
|
|
}],
|
|
},
|
|
ColumnLineage {
|
|
column: "cust_region".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/customers".to_string(),
|
|
from_column: "region".to_string(),
|
|
}],
|
|
},
|
|
ColumnLineage {
|
|
column: "net".to_string(),
|
|
inputs: vec![
|
|
ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "amount".to_string(),
|
|
},
|
|
ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/customers".to_string(),
|
|
from_column: "discount".to_string(),
|
|
},
|
|
],
|
|
},
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_infer_lineage_does_not_descend_into_subqueries() {
|
|
// A scalar subquery's bare column (`amount`) belongs to the subquery's
|
|
// own FROM, NOT the outer `dl.orders` — it must not be attributed to the
|
|
// outer table. The subquery column is left to annotations; the
|
|
// passthrough `id` still resolves.
|
|
let input = r#"
|
|
ATTACH 'ducklake://warehouse' AS dl;
|
|
CREATE TABLE dl.orders_daily AS
|
|
SELECT dl.orders.id, (SELECT amount FROM dl.other LIMIT 1) AS c
|
|
FROM dl.orders;
|
|
"#;
|
|
let got = lineage(input);
|
|
assert_eq!(
|
|
got,
|
|
vec![ColumnLineage {
|
|
column: "id".to_string(),
|
|
inputs: vec![ColumnRef {
|
|
from_kind: AssetKind::Ducklake,
|
|
from_path: "warehouse/orders".to_string(),
|
|
from_column: "id".to_string(),
|
|
}],
|
|
}],
|
|
"subquery column `c` must be dropped, not misattributed to orders"
|
|
);
|
|
}
|
|
}
|