we have a passer
This commit is contained in:
@@ -202,6 +202,7 @@ impl DependencyAnalyzer {
|
||||
}
|
||||
|
||||
/// Check for cycles in the dependency graph using proper DFS
|
||||
/// Self-references are allowed and filtered out from cycle detection
|
||||
pub async fn check_for_cycles(
|
||||
&self,
|
||||
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
|
||||
@@ -210,6 +211,7 @@ impl DependencyAnalyzer {
|
||||
) -> Result<(), DependencyError> {
|
||||
// FIRST: Validate that structured table access respects link constraints
|
||||
self.validate_link_constraints(tx, table_id, new_dependencies).await?;
|
||||
|
||||
// Get current dependency graph for this schema
|
||||
let current_deps = sqlx::query!(
|
||||
r#"SELECT sd.source_table_id, sd.target_table_id, st.table_name as source_name, tt.table_name as target_name
|
||||
@@ -223,17 +225,20 @@ impl DependencyAnalyzer {
|
||||
.await
|
||||
.map_err(|e| DependencyError::DatabaseError { error: e.to_string() })?;
|
||||
|
||||
// Build adjacency list
|
||||
// Build adjacency list - EXCLUDE self-references since they're always allowed
|
||||
let mut graph: HashMap<i64, Vec<i64>> = HashMap::new();
|
||||
let mut table_names: HashMap<i64, String> = HashMap::new();
|
||||
|
||||
for dep in current_deps {
|
||||
graph.entry(dep.source_table_id).or_default().push(dep.target_table_id);
|
||||
// Skip self-references in cycle detection
|
||||
if dep.source_table_id != dep.target_table_id {
|
||||
graph.entry(dep.source_table_id).or_default().push(dep.target_table_id);
|
||||
}
|
||||
table_names.insert(dep.source_table_id, dep.source_name);
|
||||
table_names.insert(dep.target_table_id, dep.target_name);
|
||||
}
|
||||
|
||||
// Add new dependencies to test
|
||||
// Add new dependencies to test - EXCLUDE self-references
|
||||
for dep in new_dependencies {
|
||||
// Look up target table ID
|
||||
let target_id = sqlx::query_scalar!(
|
||||
@@ -249,7 +254,10 @@ impl DependencyAnalyzer {
|
||||
script_context: format!("table_id_{}", table_id),
|
||||
})?;
|
||||
|
||||
graph.entry(table_id).or_default().push(target_id);
|
||||
// Only add to cycle detection graph if it's NOT a self-reference
|
||||
if table_id != target_id {
|
||||
graph.entry(table_id).or_default().push(target_id);
|
||||
}
|
||||
|
||||
// Get table name for error reporting
|
||||
if !table_names.contains_key(&table_id) {
|
||||
@@ -265,12 +273,76 @@ impl DependencyAnalyzer {
|
||||
}
|
||||
}
|
||||
|
||||
// Detect cycles using proper DFS algorithm
|
||||
// Detect cycles using proper DFS algorithm (now without self-references)
|
||||
self.detect_cycles_dfs(&graph, &table_names, table_id)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dfs_visit(
|
||||
&self,
|
||||
node: i64,
|
||||
states: &mut HashMap<i64, NodeState>,
|
||||
graph: &HashMap<i64, Vec<i64>>,
|
||||
path: &mut Vec<i64>,
|
||||
table_names: &HashMap<i64, String>,
|
||||
starting_table: i64,
|
||||
) -> Result<(), DependencyError> {
|
||||
states.insert(node, NodeState::Visiting);
|
||||
path.push(node);
|
||||
|
||||
if let Some(neighbors) = graph.get(&node) {
|
||||
for &neighbor in neighbors {
|
||||
// Ensure neighbor is in states map
|
||||
if !states.contains_key(&neighbor) {
|
||||
states.insert(neighbor, NodeState::Unvisited);
|
||||
}
|
||||
|
||||
match states.get(&neighbor).copied().unwrap_or(NodeState::Unvisited) {
|
||||
NodeState::Visiting => {
|
||||
// Check if this is a self-reference (allowed) or a real cycle (not allowed)
|
||||
if neighbor == node {
|
||||
// Self-reference: A table referencing itself is allowed
|
||||
// Skip this - it's not a harmful cycle
|
||||
continue;
|
||||
}
|
||||
|
||||
// Found a real cycle! Build the cycle path
|
||||
let cycle_start_idx = path.iter().position(|&x| x == neighbor).unwrap_or(0);
|
||||
let cycle_path: Vec<String> = path[cycle_start_idx..]
|
||||
.iter()
|
||||
.chain(std::iter::once(&neighbor))
|
||||
.map(|&id| table_names.get(&id).cloned().unwrap_or_else(|| id.to_string()))
|
||||
.collect();
|
||||
|
||||
// Only report as error if the cycle involves more than one table
|
||||
if cycle_path.len() > 2 || (cycle_path.len() == 2 && cycle_path[0] != cycle_path[1]) {
|
||||
let involving_script = table_names.get(&starting_table)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| starting_table.to_string());
|
||||
|
||||
return Err(DependencyError::CircularDependency {
|
||||
cycle_path,
|
||||
involving_script,
|
||||
});
|
||||
}
|
||||
}
|
||||
NodeState::Unvisited => {
|
||||
// Recursively visit unvisited neighbor
|
||||
self.dfs_visit(neighbor, states, graph, path, table_names, starting_table)?;
|
||||
}
|
||||
NodeState::Visited => {
|
||||
// Already processed, no cycle through this path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
states.insert(node, NodeState::Visited);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates that structured table access (steel_get_column functions) respects link constraints
|
||||
/// Raw SQL access (steel_query_sql) is allowed to reference any table
|
||||
/// SELF-REFERENCES are always allowed (table can access its own columns)
|
||||
@@ -379,60 +451,6 @@ impl DependencyAnalyzer {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn dfs_visit(
|
||||
&self,
|
||||
node: i64,
|
||||
states: &mut HashMap<i64, NodeState>,
|
||||
graph: &HashMap<i64, Vec<i64>>,
|
||||
path: &mut Vec<i64>,
|
||||
table_names: &HashMap<i64, String>,
|
||||
starting_table: i64,
|
||||
) -> Result<(), DependencyError> {
|
||||
states.insert(node, NodeState::Visiting);
|
||||
path.push(node);
|
||||
|
||||
if let Some(neighbors) = graph.get(&node) {
|
||||
for &neighbor in neighbors {
|
||||
// Ensure neighbor is in states map
|
||||
if !states.contains_key(&neighbor) {
|
||||
states.insert(neighbor, NodeState::Unvisited);
|
||||
}
|
||||
|
||||
match states.get(&neighbor).copied().unwrap_or(NodeState::Unvisited) {
|
||||
NodeState::Visiting => {
|
||||
// Found a cycle! Build the cycle path
|
||||
let cycle_start_idx = path.iter().position(|&x| x == neighbor).unwrap_or(0);
|
||||
let cycle_path: Vec<String> = path[cycle_start_idx..]
|
||||
.iter()
|
||||
.chain(std::iter::once(&neighbor))
|
||||
.map(|&id| table_names.get(&id).cloned().unwrap_or_else(|| id.to_string()))
|
||||
.collect();
|
||||
|
||||
let involving_script = table_names.get(&starting_table)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| starting_table.to_string());
|
||||
|
||||
return Err(DependencyError::CircularDependency {
|
||||
cycle_path,
|
||||
involving_script,
|
||||
});
|
||||
}
|
||||
NodeState::Unvisited => {
|
||||
// Recursively visit unvisited neighbor
|
||||
self.dfs_visit(neighbor, states, graph, path, table_names, starting_table)?;
|
||||
}
|
||||
NodeState::Visited => {
|
||||
// Already processed, no cycle through this path
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.pop();
|
||||
states.insert(node, NodeState::Visited);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Save dependencies to database within an existing transaction
|
||||
pub async fn save_dependencies(
|
||||
&self,
|
||||
|
||||
Reference in New Issue
Block a user