Skip to main content

uqa_sql/schema/
truncate.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! TRUNCATE target expansion, partition rules and foreign-key dependency ordering.
8
9use crate::SQLError;
10use std::collections::BTreeSet;
11
12/// Catalog metadata required to bind a TRUNCATE target set.
13pub trait TruncateCatalog {
14    fn try_resolve_visible_relation_kind(
15        &self,
16        name: &str,
17    ) -> Result<Option<(String, &'static str)>, SQLError>;
18    fn is_partitioned(&self, table: &str) -> Result<bool, String>;
19    fn hierarchy_scan_tables(
20        &self,
21        table: &str,
22        descendants: bool,
23    ) -> Result<Vec<String>, SQLError>;
24    fn referrers_to(&self, table: &str) -> Result<Vec<String>, String>;
25    /// The relation followed by each partitioned table it is a partition of, nearest first.
26    fn partition_ancestor_tables(&self, table: &str) -> Result<Vec<String>, SQLError>;
27}
28
29/// The tables whose foreign keys reference `table`'s rows: those that reference it and those that reference a partitioned table it is a partition of, as `PostgreSQL` derives a constraint on each referenced partition.
30fn referencing_tables(catalog: &dyn TruncateCatalog, table: &str) -> Result<Vec<String>, SQLError> {
31    let mut referencing = Vec::new();
32    for ancestor in catalog.partition_ancestor_tables(table)? {
33        for referrer in catalog
34            .referrers_to(&ancestor)
35            .map_err(|err| SQLError::Internal(format!("read foreign keys: {err}")))?
36        {
37            if !referencing.contains(&referrer) {
38                referencing.push(referrer);
39            }
40        }
41    }
42    Ok(referencing)
43}
44
45#[cfg(test)]
46mod tests;
47
48#[derive(Debug)]
49pub struct TruncateTargets {
50    pub all: BTreeSet<String>,
51    pub trigger_order: Vec<String>,
52    pub privilege_targets: BTreeSet<String>,
53    /// The tables `CASCADE` added because their foreign keys reference a target, in the order they were added.
54    pub cascaded: Vec<String>,
55}
56
57pub fn resolve_truncate_targets(
58    catalog: &dyn TruncateCatalog,
59    tables: &[crate::ast::TruncateTarget],
60    cascade: bool,
61) -> Result<TruncateTargets, SQLError> {
62    let mut targets = BTreeSet::new();
63    let mut trigger_targets = Vec::new();
64    let mut privilege_targets = BTreeSet::new();
65    for requested in tables {
66        let table = match catalog.try_resolve_visible_relation_kind(&requested.table)? {
67            Some((table, "table")) => table,
68            Some((_, "foreign table")) => {
69                return Err(SQLError::Unsupported(format!(
70                    "TRUNCATE TABLE: foreign table `{}` cannot be truncated",
71                    requested.table
72                )))
73            }
74            // `truncate_check_rel` accepts only tables.
75            Some((relation, _)) => {
76                let local = uqa_core::RelationIdentity::from_legacy_name(&relation)
77                    .map_or(relation, |relation| relation.name);
78                return Err(SQLError::Routine {
79                    sqlstate: "42809".into(),
80                    message: format!("\"{local}\" is not a table"),
81                });
82            }
83            None => return Err(SQLError::UnknownTable(requested.table.clone())),
84        };
85        privilege_targets.insert(table.clone());
86        let partitioned = catalog
87            .is_partitioned(&table)
88            .map_err(|err| SQLError::Internal(format!("read table hierarchy: {err}")))?;
89        if !requested.include_descendants && partitioned {
90            return Err(SQLError::Routine {
91                sqlstate: "42809".into(),
92                message: "cannot truncate only a partitioned table".into(),
93            });
94        }
95        for target in catalog.hierarchy_scan_tables(&table, requested.include_descendants)? {
96            if targets.insert(target.clone()) {
97                trigger_targets.push(target);
98            }
99        }
100    }
101    let mut cascaded = Vec::new();
102    if cascade {
103        let mut cursor = 0;
104        while let Some(table) = trigger_targets.get(cursor).cloned() {
105            cursor += 1;
106            for referrer in referencing_tables(catalog, &table)? {
107                if targets.insert(referrer.clone()) {
108                    privilege_targets.insert(referrer.clone());
109                    cascaded.push(referrer.clone());
110                    trigger_targets.push(referrer);
111                }
112            }
113        }
114    }
115    Ok(TruncateTargets {
116        all: targets,
117        trigger_order: trigger_targets,
118        privilege_targets,
119        cascaded,
120    })
121}
122
123/// Reject truncating a table that a table outside the targets references, naming the first such pair in target order, as `PostgreSQL`'s `heap_truncate_check_FKs` does.
124pub fn validate_truncate_references(
125    catalog: &dyn TruncateCatalog,
126    targets: &TruncateTargets,
127) -> Result<(), SQLError> {
128    for table in &targets.trigger_order {
129        if let Some(referrer) = referencing_tables(catalog, table)?
130            .into_iter()
131            .find(|referrer| !targets.all.contains(referrer))
132        {
133            let referrer = crate::semantics::foreign_keys::foreign_key_relation_name(&referrer);
134            return Err(SQLError::Diagnostic {
135                sqlstate: "0A000".into(),
136                message: "cannot truncate a table referenced in a foreign key constraint".into(),
137                detail: Some(format!(
138                    "Table \"{referrer}\" references \"{}\".",
139                    crate::semantics::foreign_keys::foreign_key_relation_name(table)
140                )),
141                hint: Some(format!(
142                    "Truncate table \"{referrer}\" at the same time, or use TRUNCATE ... CASCADE."
143                )),
144            });
145        }
146    }
147    Ok(())
148}
149
150/// Referencing relations precede their targets, with cycles visited once.
151pub fn truncate_dependency_order(
152    catalog: &dyn TruncateCatalog,
153    targets: &TruncateTargets,
154) -> Result<Vec<String>, SQLError> {
155    let mut ordered = Vec::with_capacity(targets.all.len());
156    let mut visiting = BTreeSet::new();
157    let mut visited = BTreeSet::new();
158    for table in &targets.trigger_order {
159        visit_truncate_target(
160            catalog,
161            table,
162            &targets.all,
163            &mut visiting,
164            &mut visited,
165            &mut ordered,
166        )?;
167    }
168    Ok(ordered)
169}
170
171fn visit_truncate_target(
172    catalog: &dyn TruncateCatalog,
173    table: &str,
174    targets: &BTreeSet<String>,
175    visiting: &mut BTreeSet<String>,
176    visited: &mut BTreeSet<String>,
177    ordered: &mut Vec<String>,
178) -> Result<(), SQLError> {
179    if visited.contains(table) || !visiting.insert(table.to_string()) {
180        return Ok(());
181    }
182    for referrer in referencing_tables(catalog, table)? {
183        if targets.contains(&referrer) {
184            visit_truncate_target(catalog, &referrer, targets, visiting, visited, ordered)?;
185        }
186    }
187    visiting.remove(table);
188    if visited.insert(table.to_string()) {
189        ordered.push(table.to_string());
190    }
191    Ok(())
192}