LCOV - code coverage report
Current view: top level - dashql-core-session/src - catalog_changes.rs Coverage Total Hit
Test: DashQL 6e74948 Lines: 99.3 % 150 149
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : //! Catalog changes implied by successful statements.
       2              : //!
       3              : //! A full catalog refresh can be expensive, so the catalog follows the DDL statements that the user
       4              : //! runs: a successful `CREATE TABLE`, `CREATE TABLE AS`, `CREATE VIEW`, `SELECT INTO` or `DROP`
       5              : //! becomes a [`CatalogChange`] that is applied to a descriptor entry
       6              : //! (`dashql_core::catalog::Catalog::apply_catalog_changes`). The changes of a connection are kept as
       7              : //! SQL, a script of `CREATE TABLE` and `DROP TABLE` statements that is rendered and parsed with the
       8              : //! same derivation.
       9              : 
      10              : use dashql_buffers::parser::{AttributeKey, Node, NodeType, StatementType, TempType};
      11              : use dashql_core::analyzer::AnalyzedScript;
      12              : use dashql_core::catalog::changes::{CatalogChange, CatalogChangeKind};
      13              : use dashql_core::catalog::Catalog;
      14              : use dashql_core::error::{Error, Result};
      15              : use dashql_core::pack::pack_parsed_statement;
      16              : use dashql_core::script::Script;
      17              : use dashql_parser::parser::ParsedScript;
      18              : 
      19              : /// The header of rendered changes SQL
      20              : const CHANGES_SQL_HEADER: &str = "-- Catalog changes of executed statements";
      21              : 
      22              : /// How statements are turned into catalog changes
      23              : #[derive(Clone, Debug, Default)]
      24              : pub struct CatalogChangeOptions {
      25              :     /// Follow temporary relations? They only live as long as the database session.
      26              :     pub include_temporary: bool,
      27              : }
      28              : 
      29              : /// The names that qualify the relations of unqualified statements, they depend on the connection
      30              : #[derive(Clone, Debug, Default)]
      31              : pub struct CatalogNameDefaults {
      32              :     /// The database of relations without a database name
      33              :     pub database_name: String,
      34              :     /// The schema of relations without a schema name
      35              :     pub schema_name: String,
      36              : }
      37              : 
      38              : /// The child of a node with an attribute key
      39         1717 : fn attribute<'a>(parsed: &'a ParsedScript, node: &Node, key: AttributeKey) -> Option<&'a Node> {
      40         3420 :     parsed.children(node).iter().find(|c| c.attribute_key() == key)
      41         1717 : }
      42              : 
      43              : /// Is an attribute a `true` bool node?
      44          839 : fn is_true(node: Option<&Node>) -> bool {
      45          839 :     node.is_some_and(|n| n.node_type() == NodeType::BOOL && n.children_begin_or_value() != 0)
      46          839 : }
      47              : 
      48              : /// Does an attribute declare a temporary relation? `UNLOGGED` relations are persistent.
      49          867 : fn is_temporary(node: Option<&Node>) -> bool {
      50          867 :     node.is_some_and(|n| {
      51           19 :         n.node_type() == NodeType::ENUM_SQL_TEMP_TYPE
      52           19 :             && matches!(TempType(n.children_begin_or_value() as u8), TempType::DEFAULT | TempType::LOCAL | TempType::GLOBAL)
      53           19 :     })
      54          867 : }
      55              : 
      56              : /// The change of a statement, before the relation is named
      57              : struct StatementChange {
      58              :     /// What the statement does
      59              :     kind: CatalogChangeKind,
      60              :     /// Is the relation only created if it does not exist yet?
      61              :     if_not_exists: bool,
      62              : }
      63              : 
      64              : /// Classify a statement
      65         2179 : fn classify_statement(parsed: &ParsedScript, statement_id: usize, include_temporary: bool) -> Option<StatementChange> {
      66         2179 :     let statement = &parsed.statements[statement_id];
      67         2179 :     let root = &parsed.nodes[statement.root as usize];
      68         2179 :     let create = |temp: AttributeKey, if_not_exists: Option<AttributeKey>| {
      69          870 :         if !include_temporary && is_temporary(attribute(parsed, root, temp)) {
      70            8 :             return None;
      71          862 :         }
      72              :         Some(StatementChange {
      73          862 :             kind: CatalogChangeKind::CreateRelation,
      74          862 :             if_not_exists: if_not_exists.is_some_and(|key| is_true(attribute(parsed, root, key))),
      75              :         })
      76          870 :     };
      77         2179 :     match statement.statement_type {
      78          810 :         StatementType::CREATE_TABLE => create(AttributeKey::SQL_CREATE_TABLE_TEMP, Some(AttributeKey::SQL_CREATE_TABLE_IF_NOT_EXISTS)),
      79           35 :         StatementType::CREATE_TABLE_AS => create(AttributeKey::SQL_CREATE_AS_TEMP, Some(AttributeKey::SQL_CREATE_AS_IF_NOT_EXISTS)),
      80           25 :         StatementType::CREATE_VIEW => create(AttributeKey::SQL_VIEW_TEMP, None),
      81              :         StatementType::SELECT_INTO => {
      82           11 :             let into = attribute(parsed, root, AttributeKey::SQL_SELECT_INTO)?;
      83           11 :             if !include_temporary && is_temporary(attribute(parsed, into, AttributeKey::SQL_TEMP_TYPE)) {
      84            2 :                 return None;
      85            9 :             }
      86            9 :             Some(StatementChange {
      87            9 :                 kind: CatalogChangeKind::CreateRelation,
      88            9 :                 if_not_exists: false,
      89            9 :             })
      90              :         }
      91           56 :         StatementType::DROP_TABLE | StatementType::DROP_VIEW => Some(StatementChange {
      92           56 :             kind: CatalogChangeKind::DropRelation,
      93           56 :             if_not_exists: false,
      94           56 :         }),
      95         1242 :         _ => None,
      96              :     }
      97         2179 : }
      98              : 
      99              : /// Derive the catalog change of a statement of an analyzed script.
     100              : ///
     101              : /// The relation is named as written, see [`qualify_catalog_changes`]. Returns None for statements that
     102              : /// leave the catalog as it is, for excluded temporary relations, and for statements whose relation the
     103              : /// analysis could not name.
     104         2179 : pub fn derive_catalog_change(analyzed: &AnalyzedScript, statement_id: usize, options: &CatalogChangeOptions) -> Option<CatalogChange> {
     105         2179 :     let parsed = &analyzed.parsed;
     106         2179 :     if statement_id >= parsed.statements.len() {
     107            0 :         return None;
     108         2179 :     }
     109         2179 :     let classified = classify_statement(parsed, statement_id, options.include_temporary)?;
     110          927 :     let target = pack_parsed_statement(parsed, statement_id).target?;
     111          927 :     let relation_name = target.relation_name.filter(|n| !n.is_empty())?;
     112          927 :     let mut change = CatalogChange {
     113          927 :         kind: classified.kind,
     114          927 :         database_name: target.database_name.unwrap_or_default(),
     115          927 :         schema_name: target.schema_name.unwrap_or_default(),
     116          927 :         relation_name,
     117          927 :         if_not_exists: classified.if_not_exists,
     118          927 :         column_names: Vec::new(),
     119          927 :     };
     120          927 :     if classified.kind == CatalogChangeKind::CreateRelation {
     121          871 :         let table = analyzed
     122          871 :             .entry
     123          871 :             .table_declarations
     124          871 :             .iter()
     125         2059 :             .find(|t| t.ast_statement_id == Some(statement_id as u32))?;
     126              :         // The analyzer sorts the columns of CREATE TABLE by name, the AST nodes restore the declaration order
     127          871 :         let mut columns: Vec<_> = table.table_columns.iter().collect();
     128         9843 :         columns.sort_by_key(|c| c.ast_node_id.unwrap_or(u32::MAX));
     129         2682 :         change.column_names = columns.iter().map(|c| c.column_name.text.to_string()).collect();
     130           56 :     }
     131          927 :     Some(change)
     132         2179 : }
     133              : 
     134              : /// Fill the missing database and schema names of changes with the defaults
     135           25 : pub fn qualify_catalog_changes(changes: &mut [CatalogChange], defaults: &CatalogNameDefaults) {
     136           26 :     for change in changes {
     137           52 :         for (name, default) in [
     138           26 :             (&mut change.database_name, &defaults.database_name),
     139           26 :             (&mut change.schema_name, &defaults.schema_name),
     140           26 :         ] {
     141           52 :             if name.is_empty() {
     142           40 :                 name.clone_from(default);
     143           40 :             }
     144              :         }
     145              :     }
     146           25 : }
     147              : 
     148              : /// Compact changes to the last change of every relation, in the order of the last changes.
     149              : ///
     150              : /// `IF NOT EXISTS` is resolved against the earlier changes of the same relation: after a creation it
     151              : /// changes nothing, after a drop it creates the relation unconditionally.
     152            3 : pub fn compact_catalog_changes(changes: Vec<CatalogChange>) -> Vec<CatalogChange> {
     153            3 :     let mut compacted: Vec<CatalogChange> = Vec::new();
     154           12 :     for mut change in changes {
     155           14 :         let previous = compacted.iter().position(|c| c.key() == change.key());
     156           12 :         if let Some(previous) = previous {
     157            4 :             if change.if_not_exists {
     158            2 :                 if compacted[previous].kind == CatalogChangeKind::CreateRelation {
     159            1 :                     continue;
     160            1 :                 }
     161            1 :                 change.if_not_exists = false;
     162            2 :             }
     163            3 :             compacted.remove(previous);
     164            8 :         }
     165           11 :         compacted.push(change);
     166              :     }
     167            3 :     compacted
     168            3 : }
     169              : 
     170              : /// Quote an identifier
     171           31 : fn quote_identifier(identifier: &str, out: &mut String) {
     172           31 :     out.push('"');
     173           31 :     out.push_str(&identifier.replace('"', "\"\""));
     174           31 :     out.push('"');
     175           31 : }
     176              : 
     177              : /// Write the qualified name of a change
     178            8 : fn write_qualified_name(change: &CatalogChange, out: &mut String) {
     179            8 :     let (database, schema, relation) = change.key();
     180            8 :     if !database.is_empty() {
     181            8 :         quote_identifier(database, out);
     182            8 :         out.push('.');
     183            8 :     }
     184            8 :     if !schema.is_empty() || !database.is_empty() {
     185            8 :         quote_identifier(schema, out);
     186            8 :         out.push('.');
     187            8 :     }
     188            8 :     quote_identifier(relation, out);
     189            8 : }
     190              : 
     191              : /// Render compacted changes as SQL
     192            3 : pub fn render_catalog_changes_sql(changes: &[CatalogChange]) -> String {
     193            3 :     let mut out = String::from(CHANGES_SQL_HEADER);
     194            8 :     for change in changes {
     195            8 :         match change.kind {
     196              :             CatalogChangeKind::CreateRelation => {
     197            6 :                 out.push_str(if change.if_not_exists { "\n\nCREATE TABLE IF NOT EXISTS " } else { "\n\nCREATE TABLE " });
     198            6 :                 write_qualified_name(change, &mut out);
     199            6 :                 out.push_str(" (");
     200            7 :                 for (i, column) in change.column_names.iter().enumerate() {
     201            7 :                     out.push_str(if i == 0 { "\n    " } else { ",\n    " });
     202            7 :                     quote_identifier(column, &mut out);
     203            7 :                     out.push_str(" VARCHAR");
     204              :                 }
     205            6 :                 out.push_str(if change.column_names.is_empty() { ");" } else { "\n);" });
     206              :             }
     207            2 :             CatalogChangeKind::DropRelation => {
     208            2 :                 out.push_str("\n\nDROP TABLE ");
     209            2 :                 write_qualified_name(change, &mut out);
     210            2 :                 out.push(';');
     211            2 :             }
     212              :         }
     213              :     }
     214            3 :     out.push('\n');
     215            3 :     out
     216            3 : }
     217              : 
     218              : /// Parse changes SQL into its changes. Its statements are fully qualified.
     219            5 : pub fn parse_catalog_changes_sql(text: &str) -> Result<Vec<CatalogChange>> {
     220            5 :     let catalog = Catalog::new();
     221            5 :     let mut script = Script::new(&catalog);
     222            5 :     script.replace_text(text);
     223            5 :     script.analyze(&catalog, true)?;
     224            5 :     let analyzed = script.analyzed.clone().ok_or_else(|| Error::invalid_argument("catalog changes SQL is not analyzed"))?;
     225            5 :     if !analyzed.parsed.errors.is_empty() || !analyzed.parsed.scanned.errors.is_empty() {
     226            1 :         return Err(Error::invalid_argument("invalid catalog changes SQL"));
     227            4 :     }
     228            4 :     let options = CatalogChangeOptions::default();
     229            4 :     Ok((0..analyzed.parsed.statements.len())
     230            8 :         .filter_map(|id| derive_catalog_change(&analyzed, id, &options))
     231            4 :         .collect())
     232            5 : }
     233              : 
     234              : /// Append changes to changes SQL and compact it
     235            2 : pub fn append_catalog_changes_sql(changes_sql: &str, changes: &[CatalogChange]) -> Result<String> {
     236            2 :     let mut all = parse_catalog_changes_sql(changes_sql)?;
     237            2 :     all.extend(changes.iter().cloned());
     238            2 :     Ok(render_catalog_changes_sql(&compact_catalog_changes(all)))
     239            2 : }
        

Generated by: LCOV version 2.0-1