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

            Line data    Source code
       1              : //! The script compiler: the terminal statement of a script, its SQL and the compiled visualization of a
       2              : //! terminal query with `@vis` tags. Runs execute the plan of the planner, the compilation describes the
       3              : //! terminal statement for the editor.
       4              : 
       5              : use dashql_core::analyzer::{AnalyzedScript, VisualizationSpec};
       6              : use dashql_core::catalog::{Catalog, PROTO_NULL_U32};
       7              : use dashql_core::formatter::Formatter;
       8              : use dashql_core::script::Script;
       9              : use crate::visualize;
      10              : use dashql_buffers::execution::{
      11              :     CompiledVisualizationT, ScriptCompilationErrorCode as ErrorCode, ScriptCompilationErrorT, ScriptCompilationResultT,
      12              :     ScriptCompilationStatementKind as StatementKind,
      13              : };
      14              : use dashql_buffers::formatting::FormattingConfigT;
      15              : use dashql_buffers::parser::{AttributeKey, NodeType, StatementType, TextSpanT};
      16              : use dashql_buffers::visualization::{DashboardSpecT, MarkdownSpec, TableSpecT};
      17              : use dashql_parser::attrs;
      18              : use dashql_parser::parser::context::resolve_text_span;
      19              : use dashql_parser::parser::{ParsedScript, Statement};
      20              : use dashql_parser::scanner::ScannedScript;
      21              : use dashql_parser::span::TextSpan;
      22              : 
      23              : /// Options of the compilation
      24              : #[derive(Clone, Copy, Debug)]
      25              : pub struct CompilationOptions {
      26              :     /// Allow the DashQL extensions (the visualizations of `@vis` tags)
      27              :     pub allow_extensions: bool,
      28              :     /// Parse the script if the text changed
      29              :     pub parse_if_outdated: bool,
      30              : }
      31              : 
      32              : impl Default for CompilationOptions {
      33           17 :     fn default() -> Self {
      34           17 :         Self {
      35           17 :             allow_extensions: true,
      36           17 :             parse_if_outdated: true,
      37           17 :         }
      38           17 :     }
      39              : }
      40              : 
      41            5 : fn error(code: ErrorCode, message: &str, statement_id: u32, ast_node_id: u32, text_span: Option<TextSpan>) -> ScriptCompilationErrorT {
      42            5 :     let mut e = ScriptCompilationErrorT::default();
      43            5 :     e.code = code;
      44            5 :     e.statement_id = statement_id;
      45            5 :     e.ast_node_id = ast_node_id;
      46            5 :     e.text_span = text_span.map(|s| {
      47            2 :         let mut t = TextSpanT::default();
      48            2 :         t.offset = s.offset;
      49            2 :         t.length = s.length;
      50            2 :         t
      51            2 :     });
      52            5 :     e.message = Some(message.to_string());
      53            5 :     e
      54            5 : }
      55              : 
      56              : /// The text of a span with the obfuscated literals replaced, so that it can be executed
      57          849 : pub fn read_executable_text(scanned: &ScannedScript, span: TextSpan) -> String {
      58          849 :     read_executable_text_with(scanned, span, &[])
      59          849 : }
      60              : 
      61              : /// The text of a span with the obfuscated literals replaced and with edits: ascending, disjoint text spans and
      62              : /// their replacements, an empty span inserts its text. Edits within obfuscated literals are dropped.
      63         1233 : pub fn read_executable_text_with(scanned: &ScannedScript, span: TextSpan, edits: &[(TextSpan, String)]) -> String {
      64         1233 :     let text = scanned.text.as_bytes();
      65         3488 :     let clamp = |offset: usize| offset.min(text.len());
      66         1233 :     let begin = clamp(span.offset as usize);
      67         1233 :     let end = clamp((span.offset + span.length) as usize);
      68              :     // The replacements of the obfuscated literals and the edits, ordered by offset with insertions first
      69         1233 :     let first = scanned.obfuscated_literals.partition_point(|l| (l.location.offset as usize) < begin);
      70         1233 :     let mut replacements: Vec<(usize, usize, &str)> = scanned.obfuscated_literals[first..]
      71         1233 :         .iter()
      72         1233 :         .take_while(|l| (l.location.offset + l.location.length) as usize <= end)
      73         1233 :         .map(|l| (l.location.offset as usize, (l.location.offset + l.location.length) as usize, l.replacement.as_str()))
      74         1233 :         .collect();
      75         1233 :     replacements.extend(
      76         1233 :         edits
      77         1233 :             .iter()
      78         1233 :             .filter(|(s, _)| s.offset as usize >= begin && s.end() as usize <= end)
      79         1233 :             .map(|(s, t)| (s.offset as usize, s.end() as usize, t.as_str())),
      80              :     );
      81         1233 :     replacements.sort_by_key(|&(b, e, _)| (b, e));
      82         1233 :     let mut out = Vec::with_capacity(end - begin);
      83         1233 :     let mut at = begin;
      84         1233 :     for (b, e, replacement) in replacements {
      85          512 :         if b < at {
      86            1 :             continue;
      87          511 :         }
      88          511 :         out.extend_from_slice(&text[at..clamp(b)]);
      89          511 :         out.extend_from_slice(replacement.as_bytes());
      90          511 :         at = clamp(e);
      91              :     }
      92         1233 :     out.extend_from_slice(&text[at.min(end)..end]);
      93         1233 :     String::from_utf8_lossy(&out).into_owned()
      94         1233 : }
      95              : 
      96          109 : fn has_returning(parsed: &ParsedScript, statement: &Statement) -> bool {
      97          109 :     let root = &parsed.nodes[statement.root as usize];
      98          109 :     let begin = root.children_begin_or_value() as usize;
      99          109 :     let children = &parsed.nodes[begin..begin + root.children_count() as usize];
     100          109 :     let [insert_returning, delete_returning] = attrs!(children, [AttributeKey::SQL_INSERT_RETURNING, AttributeKey::SQL_DELETE_RETURNING]);
     101          109 :     let returning = if statement.statement_type == StatementType::DELETE { delete_returning } else { insert_returning };
     102          109 :     returning.is_some_and(|r| r.node_type() != NodeType::NONE)
     103          109 : }
     104              : 
     105              : /// Does a statement produce output?
     106         1064 : pub fn produces_output(parsed: &ParsedScript, statement: &Statement) -> bool {
     107         1064 :     match statement.statement_type {
     108              :         // SELECT INTO creates a table and returns no rows
     109          752 :         StatementType::SELECT | StatementType::EXPLAIN => true,
     110          109 :         StatementType::INSERT | StatementType::DELETE => has_returning(parsed, statement),
     111          203 :         _ => false,
     112              :     }
     113         1064 : }
     114              : 
     115              : /// Compile the visualization metadata
     116           39 : pub fn compile_visualization(spec: &VisualizationSpec, script: &AnalyzedScript) -> Result<CompiledVisualizationT, String> {
     117           39 :     let renderer = spec.renderer.as_ref().ok_or("could not resolve visualization renderer")?.to_ascii_lowercase();
     118           38 :     let mut out = CompiledVisualizationT::default();
     119              :     // The compiled visualization always writes its strings
     120           38 :     out.vegalite_spec = Some(String::new());
     121           38 :     let mut dashboard = DashboardSpecT::default();
     122           38 :     dashboard.row = spec.dashboard.row;
     123           38 :     dashboard.column = spec.dashboard.column;
     124           38 :     dashboard.width = spec.dashboard.width;
     125           38 :     dashboard.height = spec.dashboard.height;
     126           38 :     out.dashboard = Some(Box::new(dashboard));
     127           38 :     match renderer.as_str() {
     128           38 :         "vegalite" => out.vegalite_spec = Some(visualize::generate_vegalite_spec(spec, script)),
     129           34 :         "markdown" => {
     130            6 :             let doc = visualize::compile_markdown_spec(spec).ok_or("could not compile Markdown visualization spec")?;
     131            5 :             let mut builder = flatbuffers::FlatBufferBuilder::new();
     132            5 :             let packed = visualize::pack_markdown_spec(&mut builder, &doc);
     133            5 :             builder.finish(packed, None);
     134            5 :             out.markdown_spec = Some(Box::new(flatbuffers::root::<MarkdownSpec>(builder.finished_data()).unwrap().unpack()));
     135              :         }
     136           28 :         "table" => out.table_spec = Some(Box::new(TableSpecT::default())),
     137            1 :         _ => return Err(format!("unsupported visualization renderer: {renderer}")),
     138              :     }
     139           36 :     out.renderer = Some(renderer);
     140           36 :     Ok(out)
     141           39 : }
     142              : 
     143              : /// Is a byte C `isspace`?
     144            2 : fn is_c_space(c: u8) -> bool {
     145            2 :     matches!(c, b' ' | b'\t' | b'\n' | b'\x0b' | b'\x0c' | b'\r')
     146            2 : }
     147              : 
     148              : /// Compile a script
     149           19 : pub fn compile(script: &mut Script, catalog: &Catalog, config: &FormattingConfigT, options: CompilationOptions) -> ScriptCompilationResultT {
     150           19 :     let mut result = ScriptCompilationResultT::default();
     151           19 :     result.kind = StatementKind::QUERY;
     152           19 :     result.terminal_statement_id = PROTO_NULL_U32;
     153              :     // The result always writes its strings
     154           19 :     result.sql = Some(String::new());
     155           19 :     let mut errors = Vec::new();
     156           19 :     let finish = |mut result: ScriptCompilationResultT, errors| {
     157           19 :         result.errors = Some(errors);
     158           19 :         result
     159           19 :     };
     160           19 :     if options.parse_if_outdated && script.parsed.as_ref().is_none_or(|p| p.scanned.text_version != script.text_version()) {
     161           17 :         script.parse();
     162           18 :     }
     163           19 :     let Some(parsed) = script.parsed.clone() else {
     164            1 :         errors.push(error(ErrorCode::EMPTY_SCRIPT, "script is not parsed", PROTO_NULL_U32, PROTO_NULL_U32, None));
     165            1 :         return finish(result, errors);
     166              :     };
     167           18 :     let scanned = &parsed.scanned;
     168           18 :     for e in &scanned.errors {
     169            1 :         errors.push(error(ErrorCode::SCANNER_ERROR, &e.message, PROTO_NULL_U32, PROTO_NULL_U32, Some(e.location)));
     170            1 :     }
     171           18 :     for e in &parsed.errors {
     172            1 :         let span = resolve_text_span(&scanned.text, &scanned.symbols, e.location);
     173            1 :         errors.push(error(ErrorCode::PARSER_ERROR, &e.message, PROTO_NULL_U32, PROTO_NULL_U32, Some(span)));
     174            1 :     }
     175           18 :     if !errors.is_empty() {
     176              :         // Without a reliable AST, the original text is the one statement
     177            2 :         let sql = read_executable_text(scanned, TextSpan::new(0, scanned.text.len() as u32));
     178            2 :         if sql.bytes().any(|c| !is_c_space(c)) {
     179            2 :             result.sql = Some(sql);
     180            2 :             result.terminal_statement_id = 0;
     181            2 :         }
     182            2 :         return finish(result, errors);
     183           16 :     }
     184           16 :     if parsed.statements.is_empty() {
     185            2 :         errors.push(error(ErrorCode::EMPTY_SCRIPT, "script has no executable statement", PROTO_NULL_U32, PROTO_NULL_U32, None));
     186            2 :         return finish(result, errors);
     187           14 :     }
     188           14 :     let terminal_statement_id = parsed.statements.len() - 1;
     189           14 :     result.terminal_statement_id = terminal_statement_id as u32;
     190              :     // A query with `@vis` tags is a visualization, the shell runs it as a plain query
     191           14 :     let vis_tags = options.allow_extensions
     192           13 :         && parsed.statements[terminal_statement_id].statement_type == StatementType::SELECT
     193           12 :         && dashql_core::analyzer::has_vis_tags(&parsed, terminal_statement_id);
     194           14 :     let descriptions = parsed.associate_descriptions();
     195           14 :     let terminal_sql = read_executable_text(scanned, descriptions[terminal_statement_id].statement_span);
     196           14 :     if !vis_tags {
     197            8 :         result.sql = Some(terminal_sql);
     198            8 :         return finish(result, errors);
     199            6 :     }
     200              : 
     201            6 :     result.kind = StatementKind::VISUALIZE;
     202            6 :     let terminal_root = parsed.statements[terminal_statement_id].root;
     203            6 :     let current = script.analyzed.as_ref().is_some_and(|a| std::sync::Arc::ptr_eq(&a.parsed, &parsed));
     204            6 :     if !current {
     205              :         // A failed analysis leaves the visualization without metadata
     206            6 :         if let Err(e) = script.analyze(catalog, false) {
     207            0 :             errors.push(error(ErrorCode::VISUALIZATION_METADATA_UNAVAILABLE, &e.message, terminal_statement_id as u32, terminal_root, None));
     208            0 :             return finish(result, errors);
     209            6 :         }
     210            0 :     }
     211              : 
     212            6 :     let mut formatter = Formatter::new(&parsed);
     213            6 :     formatter.replace_obfuscated_literals();
     214            6 :     let formatted = formatter.format_node_at(terminal_root as usize, config);
     215              :     // A placeholder of a node that the formatter does not know would change the query, the source text is used instead
     216            6 :     let sql = if formatter.is_fully_formatted() { formatted } else { terminal_sql };
     217            6 :     if sql.is_empty() {
     218            0 :         errors.push(error(
     219              :             ErrorCode::FORMAT_ERROR,
     220            0 :             "could not format the executable query",
     221            0 :             terminal_statement_id as u32,
     222            0 :             terminal_root,
     223            0 :             None,
     224              :         ));
     225            0 :         return finish(result, errors);
     226            6 :     }
     227            6 :     result.sql = Some(sql);
     228              : 
     229            6 :     let analyzed = script.analyzed.clone().unwrap();
     230            6 :     let visualization = analyzed
     231            6 :         .visualization_specs
     232            6 :         .iter()
     233            6 :         .find(|s| s.ast_statement_id == Some(terminal_statement_id as u32))
     234            6 :         .filter(|s| s.renderer.is_some());
     235              :     // Invalid `@vis` tags are analyzer warnings, the query still runs
     236            6 :     let Some(visualization) = visualization else {
     237            2 :         result.kind = StatementKind::QUERY;
     238            2 :         return finish(result, errors);
     239              :     };
     240            4 :     match compile_visualization(visualization, &analyzed) {
     241            4 :         Ok(compiled) => result.visualization = Some(Box::new(compiled)),
     242            0 :         Err(message) => {
     243            0 :             result.sql = Some(String::new());
     244            0 :             errors.push(error(
     245            0 :                 ErrorCode::VISUALIZATION_METADATA_UNAVAILABLE,
     246            0 :                 &message,
     247            0 :                 terminal_statement_id as u32,
     248            0 :                 terminal_root,
     249            0 :                 None,
     250            0 :             ));
     251            0 :         }
     252              :     }
     253            4 :     finish(result, errors)
     254           19 : }
        

Generated by: LCOV version 2.0-1