Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
use crate::compiler::_helpers::sql_tests::cte_slices::{
SliceDialect, WithSlices, opaque_end, read_identifier,
};
use crate::constants::{WITH_KEYWORD, WITH_LIST_ENDING_CLAUSES};
use sqlbuild_sqltext::sql_scan::main::comment_end::comment_end;
use sqlbuild_sqltext::sql_scan::main::skip_whitespace::skip_whitespace;
use sqlbuild_sqltext::sql_scan::models::Unclosed;
Expand Down Expand Up @@ -82,6 +83,91 @@ pub(crate) fn rename_ctes(
preserves_tokens((sql, &tokens), (&renamed, &renamed_tokens), &replaced).then_some(renamed)
}

/// Case-folded names of every CTE a statement defines at any depth; `None` when unscannable.
pub(crate) fn defined_cte_keys(sql: &str, dialect: SliceDialect) -> Option<Vec<String>> {
let tokens = match tokens(sql, dialect) {
Ok(tokens) => tokens,
Err(_) => return None,
};
let code: Vec<&Token> = tokens.iter().filter(|token| !token.comment).collect();
let text = |index: usize| code.get(index).map(|token| &sql[token.start..token.end]);
let keyword = |index: usize, word: &str| {
code.get(index)
.and_then(|token| token.key.as_deref())
.is_some_and(|key| key == word)
};
let in_with_list = with_list_commas(sql, &code);
let mut keys: Vec<String> = Vec::new();
for (index, token) in code.iter().enumerate() {
let Some(key) = token.key.as_deref() else {
continue;
};
let opens_list = index > 0
&& (keyword(index - 1, "with")
|| keyword(index - 1, "recursive")
|| (text(index - 1) == Some(",") && in_with_list[index - 1]));
if !opens_list {
continue;
}
let mut next = index + 1;
if text(next) == Some("(") {
let mut depth = 0usize;
while let Some(part) = text(next) {
depth = match part {
"(" => depth + 1,
")" => depth.saturating_sub(1),
_ => depth,
};
next += 1;
if depth == 0 {
break;
}
}
}
if !keyword(next, "as") {
continue;
}
next += 1;
if keyword(next, "not") {
next += 1;
}
if keyword(next, "materialized") {
next += 1;
}
if text(next) == Some("(") {
keys.push(key.to_string());
}
}
Some(keys)
}

/// Whether each token sits directly in a WITH list, so a comma there separates CTEs.
fn with_list_commas(sql: &str, code: &[&Token]) -> Vec<bool> {
let mut lists: Vec<bool> = vec![false];
let mut in_list: Vec<bool> = Vec::with_capacity(code.len());
for token in code {
match &sql[token.start..token.end] {
"(" => lists.push(false),
")" => {
if lists.len() > 1 {
let _ = lists.pop();
}
}
_ => {
if let (Some(key), Some(current)) = (token.key.as_deref(), lists.last_mut()) {
if key == WITH_KEYWORD {
*current = true;
} else if WITH_LIST_ENDING_CLAUSES.contains(&key) {
*current = false;
}
}
}
}
in_list.push(lists.last().copied().unwrap_or(false));
}
in_list
}

/// How one occurrence of a renamed CTE's name is used.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Usage {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,15 @@ use sqlbuild_sqltext::sql_scan::main::skip_whitespace::skip_whitespace;

/// Render one `name AS (body)` definition, closing a trailing line comment first.
pub(crate) fn cte_definition_sql(name: &str, sql: &str) -> String {
format!("{name} AS {}", parenthesized_sql(sql))
}

/// Wrap a query in parentheses, closing a trailing line comment first.
pub(crate) fn parenthesized_sql(sql: &str) -> String {
let body = sql.trim_end();
let final_line = body.rsplit_once('\n').map_or(body, |(_, line)| line);
let terminator = if final_line.contains("--") { "\n" } else { "" };
format!("{name} AS ({body}{terminator})")
format!("({body}{terminator})")
}

/// Append CTEs whose names are not already present, keeping first definitions and order.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
//! Reserved top-level names for test-defined helper CTEs and the rewrite of their references.

use std::collections::{HashMap, HashSet};

use polyglot_sql::{Dialect, Expression, ExpressionWalk};

pub(crate) const HELPER_PREFIX: &str = "__helper__";

/// The reserved top-level CTE name of one test-defined helper.
pub(crate) fn helper_cte_name(name: &str) -> String {
format!("{HELPER_PREFIX}{name}")
}

/// Point unqualified relation references at reserved helper names by AST span, keeping their alias.
pub(crate) fn rename_helper_references(
sql: &str,
helpers: &HashMap<String, String>,
dialect: &Dialect,
) -> Option<String> {
let statements = match dialect.parse(sql) {
Ok(statements) => statements,
Err(_) => return None,
};
let [statement] = statements.as_slice() else {
return None;
};
let char_offsets: Vec<usize> = sql
.char_indices()
.map(|(offset, _)| offset)
.chain(std::iter::once(sql.len()))
.collect();
let mut aliased: HashSet<usize> = HashSet::new();
for node in statement.dfs() {
if let Expression::Alias(alias) = node
&& let Expression::Table(table) = &alias.this
&& let Some(span) = table.name.span
{
aliased.insert(span.start);
}
}
let mut edits: Vec<(usize, usize, String)> = Vec::new();
for node in statement.dfs() {
let Expression::Table(table) = node else {
continue;
};
if table.schema.is_some() || table.catalog.is_some() {
continue;
}
let Some(reserved) = helpers.get(&table.name.name.to_lowercase()) else {
continue;
};
let span = table.name.span?;
let start = *char_offsets.get(span.start)?;
let end = *char_offsets.get(span.end)?;
let authored = sql.get(start..end)?;
let replacement = if table.alias.is_some() || aliased.contains(&span.start) {
reserved.clone()
} else {
format!("{reserved} AS {authored}")
};
edits.push((start, end, replacement));
}
edits.sort_by_key(|(start, _, _)| *start);
let mut rewritten = String::with_capacity(sql.len() + edits.len() * HELPER_PREFIX.len());
let mut cursor = 0;
for (start, end, replacement) in edits {
if start < cursor {
return None;
}
rewritten.push_str(&sql[cursor..start]);
rewritten.push_str(&replacement);
cursor = end;
}
rewritten.push_str(&sql[cursor..]);
Some(rewritten)
}
Loading
Loading