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
146 changes: 134 additions & 12 deletions crates/sqlbuild-analysis/src/compiler/_helpers/sql_tests/helper_scope.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use polyglot_sql::Dialect;

use crate::compiler::_helpers::sql_tests::cte_rename::defined_cte_keys;
use crate::compiler::_helpers::sql_tests::cte_slices::SliceDialect;
use crate::compiler::_helpers::sql_tests::cte_sql::with_leading_ctes;
use crate::compiler::_helpers::sql_tests::cte_sql::{with_leading_ctes, with_unique_ctes};
use crate::compiler::_helpers::sql_tests::helper_names::{
helper_cte_name, rename_helper_references,
};
Expand All @@ -24,6 +24,8 @@ use crate::constants::{
pub(crate) struct HelperScope {
pub(crate) ctes: Vec<(String, String)>,
pub(crate) reached_mocks: HashSet<String>,
/// Mock and model CTEs resolved helpers read, which defer to the reader's own copy.
pub(crate) generated: HashSet<String>,
}

/// One mock CTE other test CTEs can read by its generated name.
Expand All @@ -41,6 +43,16 @@ struct ScopeHelper {
cte_name: String,
sql: String,
tokens: Vec<String>,
resolved: Option<ResolvedHelper>,
}

/// A helper whose relation references were resolved like an assertion's, after the model chain.
pub(crate) struct ResolvedHelper {
/// The helper body with every reference replaced by the CTE that stands in for it.
pub(crate) sql: String,
/// The mock and model CTEs the resolved body reads, dependencies first.
pub(crate) lifted_ctes: Vec<(String, String)>,
pub(crate) reached_mocks: HashSet<String>,
}

/// One test CTE another test CTE reads: a mock, or a helper when `mock_name` is `None`.
Expand Down Expand Up @@ -161,6 +173,7 @@ impl ScopeGraph {
cte_name: cte.name.clone(),
sql: cte.sql_body.clone(),
tokens: identifier_tokens(&cte.sql_body, patterns),
resolved: None,
});
}
let readers = test_readers(fixtures);
Expand Down Expand Up @@ -281,6 +294,76 @@ impl ScopeGraph {
order.ordered
}

/// Helpers the test's expected rows and assertions read, except those a mock it uses reads.
pub(crate) fn read_helpers(
&self,
readers: &[&str],
used_mocks: &HashSet<String>,
patterns: &SqlTestPatterns,
) -> HashSet<usize> {
let helpers = |nodes: Vec<ScopeNode>| -> HashSet<usize> {
nodes
.into_iter()
.filter_map(|node| match node {
ScopeNode::Helper(index) => Some(index),
ScopeNode::Mock(_) => None,
})
.collect()
};
let mut read: HashSet<usize> = HashSet::new();
for sql in readers {
read.extend(helpers(
self.closure(&identifier_tokens(sql, patterns), true),
));
}
for mock in &self.mocks {
if used_mocks.contains(&mock.mock_name) {
for index in helpers(self.closure(&mock.tokens, true)) {
read.remove(&index);
}
}
}
read
}

/// Read helpers whose SQL, as placed in the test query, calls a reference such as `__ref()`.
pub(crate) fn referencing_helpers(
&self,
read: &HashSet<usize>,
patterns: &SqlTestPatterns,
) -> Vec<(usize, String)> {
self.helpers
.iter()
.enumerate()
.filter(|(index, helper)| helper.resolved.is_none() && read.contains(index))
.filter_map(|(index, _)| {
let sql = self.placed_helper_sql(index);
(patterns.test_reference.is_match(sql) || patterns.udf.is_match(sql))
.then(|| (index, sql.to_string()))
})
.collect()
}

/// Replace a helper's references with the CTEs a reader must define ahead of it.
pub(crate) fn resolve_helper(&mut self, index: usize, resolved: ResolvedHelper) {
let placement = self.placement;
let Some(helper) = self.helpers.get_mut(index) else {
return;
};
match placement {
HelperPlacement::TopLevel => helper.sql.clone_from(&resolved.sql),
HelperPlacement::Inline => helper.original.clone_from(&resolved.sql),
}
helper.resolved = Some(resolved);
}

fn placed_helper_sql(&self, index: usize) -> &str {
match self.placement {
HelperPlacement::TopLevel => &self.helpers[index].sql,
HelperPlacement::Inline => &self.helpers[index].original,
}
}

/// Whether a CTE name is a test helper placed at the top level of the test query.
pub(crate) fn is_top_level_helper(&self, name: &str) -> bool {
self.placement == HelperPlacement::TopLevel
Expand Down Expand Up @@ -343,18 +426,39 @@ impl ScopeGraph {
let mut scope = HelperScope {
ctes: Vec::new(),
reached_mocks: HashSet::new(),
generated: HashSet::new(),
};
for node in nodes {
if let ScopeNode::Mock(index) = node {
scope
.reached_mocks
.insert(self.mocks[index].mock_name.clone());
}
if let ScopeNode::Helper(index) = node
&& let Some(resolved) = &self.helpers[index].resolved
{
scope
.reached_mocks
.extend(resolved.reached_mocks.iter().cloned());
scope.generated.extend(
resolved
.lifted_ctes
.iter()
.map(|(name, _)| name.to_ascii_lowercase()),
);
scope.ctes = with_unique_ctes(
std::mem::take(&mut scope.ctes),
resolved.lifted_ctes.iter().cloned(),
);
}
if let Some(dependency) = self.dependency(node, None) {
scope.ctes.push((
dependency.generated_name.to_string(),
dependency.sql.to_string(),
));
scope.ctes = with_unique_ctes(
std::mem::take(&mut scope.ctes),
[(
dependency.generated_name.to_string(),
dependency.sql.to_string(),
)],
);
}
}
scope
Expand Down Expand Up @@ -428,6 +532,7 @@ pub(crate) fn helper_scope_ctes(
return Ok(HelperScope {
ctes: Vec::new(),
reached_mocks: HashSet::new(),
generated: HashSet::new(),
});
}
let nodes = graph.closure(&identifier_tokens(sql, patterns), true);
Expand All @@ -444,14 +549,31 @@ pub(crate) fn helper_scope_ctes(
Ok(graph.collect_scope(nodes))
}

/// Append scoped CTEs after a step's generated CTEs, keeping one definition per name.
/// Merge scoped CTEs into a step's CTEs; relations resolved helpers read come first and win.
pub(crate) fn merged_scoped_ctes(
mut lifted: Vec<(String, String)>,
scoped: Vec<(String, String)>,
lifted: Vec<(String, String)>,
scoped: HelperScope,
file_label: &str,
) -> Result<Vec<(String, String)>, String> {
for (name, sql) in scoped {
match lifted
type Ctes = Vec<(String, String)>;
let (generated, authored): (Ctes, Ctes) = scoped
.ctes
.into_iter()
.partition(|(name, _)| scoped.generated.contains(&name.to_ascii_lowercase()));
let mut merged: Vec<(String, String)> = lifted;
if !generated.is_empty() {
let own = std::mem::replace(&mut merged, generated);
for (name, sql) in own {
if !merged
.iter()
.any(|(existing, _)| existing.eq_ignore_ascii_case(&name))
{
merged.push((name, sql));
}
}
}
for (name, sql) in authored {
match merged
.iter()
.find(|(existing, _)| existing.eq_ignore_ascii_case(&name))
{
Expand All @@ -461,10 +583,10 @@ pub(crate) fn merged_scoped_ctes(
"SQL test '{file_label}' defines CTE '{name}', which conflicts with the generated CTE"
)));
}
None => lifted.push((name, sql)),
None => merged.push((name, sql)),
}
}
Ok(lifted)
Ok(merged)
}

/// Names the comparison renderer generates, which a scoped helper CTE must not shadow.
Expand Down
Loading
Loading