Skip to main content

query_forge/
engine.rs

1use std::{collections::HashSet, sync::OnceLock};
2
3use anyhow::{Context, Result, anyhow, bail};
4use regex::Regex;
5use rusqlite::{
6    Connection, params_from_iter,
7    types::ValueRef,
8};
9
10use crate::input;
11use crate::value::to_sql_value;
12use crate::{
13    ExtractionOptions, InputNormalizationOptions, QueryParam, QueryResult, QueryValue,
14    TypeInferenceOptions, WorkbookInput,
15};
16
17const SQLITE_MAX_INSERT_VARIABLES: usize = 999;
18const SQLITE_MAX_INSERT_ROWS: usize = 250;
19
20pub fn run_query(
21    workbook_path: &std::path::Path,
22    sheet_name: Option<&str>,
23    query: &str,
24    has_headers: bool,
25) -> Result<QueryResult> {
26    run_query_with_params(workbook_path, sheet_name, query, &[], has_headers)
27}
28
29pub fn run_query_with_params(
30    workbook_path: &std::path::Path,
31    sheet_name: Option<&str>,
32    query: &str,
33    params: &[QueryParam],
34    has_headers: bool,
35) -> Result<QueryResult> {
36    run_query_with_params_multi(&[workbook_path], sheet_name, query, params, has_headers)
37}
38
39pub fn run_query_with_params_multi(
40    workbook_paths: &[&std::path::Path],
41    sheet_name: Option<&str>,
42    query: &str,
43    params: &[QueryParam],
44    has_headers: bool,
45) -> Result<QueryResult> {
46    let workbook_inputs = workbook_paths
47        .iter()
48        .map(|path| WorkbookInput {
49            path,
50            sheet_name,
51            table_name: None,
52            explicit_format: None,
53        })
54        .collect::<Vec<_>>();
55
56    run_query_with_params_multi_inputs(&workbook_inputs, query, params, has_headers)
57}
58
59pub fn run_query_with_params_multi_inputs(
60    workbook_inputs: &[WorkbookInput<'_>],
61    query: &str,
62    params: &[QueryParam],
63    has_headers: bool,
64) -> Result<QueryResult> {
65    run_query_with_params_multi_inputs_and_options(
66        workbook_inputs,
67        query,
68        params,
69        &TypeInferenceOptions::default(),
70        has_headers,
71    )
72}
73
74pub fn run_query_with_params_multi_inputs_and_options(
75    workbook_inputs: &[WorkbookInput<'_>],
76    query: &str,
77    params: &[QueryParam],
78    inference_options: &TypeInferenceOptions,
79    has_headers: bool,
80) -> Result<QueryResult> {
81    run_query_with_params_multi_inputs_and_options_and_normalization(
82        workbook_inputs,
83        query,
84        params,
85        inference_options,
86        &InputNormalizationOptions::default(),
87        &ExtractionOptions::default(),
88        has_headers,
89    )
90}
91
92pub fn run_query_with_params_multi_inputs_and_options_and_normalization(
93    workbook_inputs: &[WorkbookInput<'_>],
94    query: &str,
95    params: &[QueryParam],
96    inference_options: &TypeInferenceOptions,
97    normalization_options: &InputNormalizationOptions,
98    extraction_options: &ExtractionOptions,
99    has_headers: bool,
100) -> Result<QueryResult> {
101    if workbook_inputs.is_empty() {
102        bail!("at least one workbook input is required");
103    }
104
105    let connection =
106        Connection::open_in_memory().context("failed to create in-memory SQLite database")?;
107    let mut registered_sheet_views = HashSet::new();
108
109    for (index, workbook_input) in workbook_inputs.iter().enumerate() {
110        let mut sheet = input::load_input(
111            workbook_input.path,
112            workbook_input.sheet_name,
113            inference_options,
114            extraction_options,
115            has_headers,
116            workbook_input.explicit_format,
117        )?;
118        input::apply_input_normalization(&mut sheet, normalization_options);
119        let table_name = workbook_input
120            .table_name
121            .map(str::to_owned)
122            .unwrap_or_else(|| {
123                if index == 0 {
124                    "table".to_owned()
125                } else {
126                    format!("table{}", index + 1)
127                }
128            });
129
130        register_sheet(
131            &connection,
132            &sheet,
133            &table_name,
134            &mut registered_sheet_views,
135        )?;
136    }
137
138    execute_statements(&connection, query, params)
139}
140
141/// Splits a SQL string into individual statements delimited by `;`.
142/// Handles single-quoted string literals (including `''` escapes) and
143/// both `--` line comments and `/* */` block comments so that semicolons
144/// inside them are not treated as statement separators.
145fn split_sql_statements(sql: &str) -> Vec<String> {
146    let mut statements = Vec::new();
147    let mut current = String::new();
148    let mut chars = sql.chars().peekable();
149
150    while let Some(ch) = chars.next() {
151        match ch {
152            // Single-quoted string: consume until the closing quote,
153            // treating '' as an escaped quote.
154            '\'' => {
155                current.push(ch);
156                loop {
157                    match chars.next() {
158                        None => break,
159                        Some('\'') => {
160                            current.push('\'');
161                            if chars.peek() == Some(&'\'') {
162                                current.push(chars.next().unwrap());
163                            } else {
164                                break;
165                            }
166                        }
167                        Some(c) => current.push(c),
168                    }
169                }
170            }
171            // -- line comment: consume until newline.
172            '-' if chars.peek() == Some(&'-') => {
173                current.push(ch);
174                current.push(chars.next().unwrap()); // second '-'
175                for c in chars.by_ref() {
176                    current.push(c);
177                    if c == '\n' {
178                        break;
179                    }
180                }
181            }
182            // /* block comment: consume until */.
183            '/' if chars.peek() == Some(&'*') => {
184                current.push(ch);
185                current.push(chars.next().unwrap()); // '*'
186                let mut prev = '\0';
187                for c in chars.by_ref() {
188                    current.push(c);
189                    if prev == '*' && c == '/' {
190                        break;
191                    }
192                    prev = c;
193                }
194            }
195            ';' => {
196                let trimmed = current.trim().to_owned();
197                if !trimmed.is_empty() {
198                    statements.push(trimmed);
199                }
200                current.clear();
201            }
202            _ => current.push(ch),
203        }
204    }
205
206    let trimmed = current.trim().to_owned();
207    if !trimmed.is_empty() {
208        statements.push(trimmed);
209    }
210
211    statements
212}
213
214/// Executes one or more `;`-separated SQL statements against `connection`.
215/// All statements except the last are executed as DDL/DML via `execute_batch`.
216/// The last statement is executed as a SELECT and its result is returned.
217fn execute_statements(
218    connection: &Connection,
219    sql: &str,
220    params: &[QueryParam],
221) -> Result<QueryResult> {
222    let statements = split_sql_statements(sql);
223
224    match statements.as_slice() {
225        [] => bail!("no SQL statements provided"),
226        [single] => execute_query(connection, single, params),
227        _ => {
228            let last = statements.last().expect("non-empty slice always has a last element");
229            let init = &statements[..statements.len() - 1];
230            for stmt in init {
231                connection
232                    .execute_batch(stmt)
233                    .with_context(|| format!("failed to execute statement: {stmt}"))?;
234            }
235            execute_query(connection, last, params)
236        }
237    }
238}
239
240fn register_sheet(
241    connection: &Connection,
242    sheet: &input::SheetData,
243    table_name: &str,
244    registered_views: &mut HashSet<String>,
245) -> Result<()> {
246    let columns = sheet
247        .columns
248        .iter()
249        .map(|column| quote_identifier(column))
250        .collect::<Vec<_>>()
251        .join(", ");
252
253    connection
254        .execute(
255            &format!("CREATE TABLE {} ({columns})", quote_identifier(table_name)),
256            [],
257        )
258        .context("failed to create sheet table")?;
259
260    let table1_key = normalize_view_key("table1");
261    if table_name == "table" && !registered_views.contains(&table1_key) {
262        connection
263            .execute(
264                &format!(
265                    "CREATE VIEW {} AS SELECT * FROM {}",
266                    quote_identifier("table1"),
267                    quote_identifier("table")
268                ),
269                [],
270            )
271            .context("failed to register alias view table1 for first input")?;
272        registered_views.insert(table1_key);
273    }
274
275    let sanitized_sheet_name = sanitize_table_name(&sheet.original_name);
276    let sanitized_sheet_key = normalize_view_key(&sanitized_sheet_name);
277    if sanitized_sheet_name != table_name && !registered_views.contains(&sanitized_sheet_key) {
278        connection
279            .execute(
280                &format!(
281                    "CREATE VIEW {} AS SELECT * FROM {}",
282                    quote_identifier(&sanitized_sheet_name),
283                    quote_identifier(table_name)
284                ),
285                [],
286            )
287            .with_context(|| {
288                format!("failed to register view for sheet {}", sheet.original_name)
289            })?;
290        registered_views.insert(sanitized_sheet_key);
291    }
292
293    if sheet.rows.is_empty() {
294        return Ok(());
295    }
296
297    insert_sheet_rows(connection, table_name, sheet)
298}
299
300fn insert_sheet_rows(connection: &Connection, table_name: &str, sheet: &input::SheetData) -> Result<()> {
301    connection
302        .execute_batch("BEGIN IMMEDIATE TRANSACTION")
303        .context("failed to begin sheet insert transaction")?;
304
305    let insert_result = (|| {
306        let batch_size = calculate_insert_batch_size(sheet.columns.len());
307        for rows_chunk in sheet.rows.chunks(batch_size) {
308            let insert_sql =
309                build_batched_insert_sql(table_name, sheet.columns.len(), rows_chunk.len());
310            let mut values = Vec::with_capacity(rows_chunk.len() * sheet.columns.len());
311            for row in rows_chunk {
312                for index in 0..sheet.columns.len() {
313                    let value = row.get(index).unwrap_or(&QueryValue::Null);
314                    values.push(to_sql_value(value));
315                }
316            }
317
318            connection
319                .execute(&insert_sql, params_from_iter(values))
320                .context("failed to insert sheet rows")?;
321        }
322
323        Ok(())
324    })();
325
326    match insert_result {
327        Ok(()) => connection
328            .execute_batch("COMMIT")
329            .context("failed to commit sheet insert transaction"),
330        Err(error) => {
331            let _ = connection.execute_batch("ROLLBACK");
332            Err(error)
333        }
334    }
335}
336
337fn calculate_insert_batch_size(column_count: usize) -> usize {
338    let clamped_column_count = column_count.max(1);
339    (SQLITE_MAX_INSERT_VARIABLES / clamped_column_count)
340        .max(1)
341        .min(SQLITE_MAX_INSERT_ROWS)
342}
343
344fn build_batched_insert_sql(table_name: &str, column_count: usize, row_count: usize) -> String {
345    let row_placeholders = format!("({})", vec!["?"; column_count].join(", "));
346    let values = vec![row_placeholders; row_count].join(", ");
347    format!("INSERT INTO {} VALUES {values}", quote_identifier(table_name))
348}
349
350fn execute_query(
351    connection: &Connection,
352    query: &str,
353    params: &[QueryParam],
354) -> Result<QueryResult> {
355    let normalized_query = normalize_query_for_reserved_identifiers(query);
356    let mut statement = connection
357        .prepare(&normalized_query)
358        .map_err(|error| enrich_query_prepare_error(connection, query, error))?;
359
360    if statement.column_count() == 0 {
361        bail!("query must return rows");
362    }
363
364    let columns = statement
365        .column_names()
366        .into_iter()
367        .map(str::to_owned)
368        .collect::<Vec<_>>();
369
370    bind_query_params(&mut statement, params)?;
371
372    let column_count = statement.column_count();
373    let mut rows = statement.raw_query();
374    let mut result_rows = Vec::new();
375
376    while let Some(row) = rows.next().context("failed to fetch query row")? {
377        let mut values = Vec::with_capacity(column_count);
378
379        for index in 0..column_count {
380            values.push(match row.get_ref(index)? {
381                ValueRef::Null => QueryValue::Null,
382                ValueRef::Integer(value) => QueryValue::Integer(value),
383                ValueRef::Real(value) => QueryValue::Real(value),
384                ValueRef::Text(value) => {
385                    QueryValue::Text(String::from_utf8_lossy(value).into_owned())
386                }
387                ValueRef::Blob(value) => QueryValue::Text(format_blob(value)),
388            });
389        }
390
391        result_rows.push(values);
392    }
393
394    Ok(QueryResult {
395        columns,
396        rows: result_rows,
397    })
398}
399
400fn enrich_query_prepare_error(
401    connection: &Connection,
402    query: &str,
403    error: rusqlite::Error,
404) -> anyhow::Error {
405    let raw_error = error.to_string();
406
407    if let Some(table) = raw_error.strip_prefix("no such table: ").map(str::trim) {
408        let table = simplify_sqlite_missing_identifier(table);
409        let available_tables = list_loaded_table_names(connection);
410        let available_suffix = if available_tables.is_empty() {
411            String::new()
412        } else {
413            format!(" Available tables/views: {}.", available_tables.join(", "))
414        };
415
416        return anyhow!(
417            "query references unknown table '{table}'.{available_suffix} Check your table names or run `qf tables --input ...` to inspect available tables.\nQuery: {query}"
418        );
419    }
420
421    if let Some(column) = raw_error.strip_prefix("no such column: ").map(str::trim) {
422        let column = simplify_sqlite_missing_identifier(column);
423        return anyhow!(
424            "query references unknown column '{column}'. Check your column names or run `qf schema --input ...` to inspect available columns.\nQuery: {query}"
425        );
426    }
427
428    anyhow!(error).context(format!("failed to prepare query: {query}"))
429}
430
431fn simplify_sqlite_missing_identifier(identifier: &str) -> &str {
432    identifier
433        .split_once(" in ")
434        .map(|(name, _)| name)
435        .unwrap_or(identifier)
436        .trim()
437}
438
439fn list_loaded_table_names(connection: &Connection) -> Vec<String> {
440    let mut statement = match connection.prepare(
441        "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%' ORDER BY name",
442    ) {
443        Ok(statement) => statement,
444        Err(_) => return Vec::new(),
445    };
446
447    let names = match statement.query_map([], |row| row.get::<_, String>(0)) {
448        Ok(names) => names,
449        Err(_) => return Vec::new(),
450    };
451
452    names.filter_map(|name| name.ok()).collect()
453}
454
455fn normalize_query_for_reserved_identifiers(query: &str) -> String {
456    static RESERVED_TABLE_RE: OnceLock<Regex> = OnceLock::new();
457
458    let re = RESERVED_TABLE_RE.get_or_init(|| {
459        Regex::new(r"(?i)\b(from|join|update|into)\s+table\b")
460            .expect("reserved table regex should compile")
461    });
462
463    re.replace_all(query, |captures: &regex::Captures<'_>| {
464        format!("{} \"table\"", &captures[1])
465    })
466    .into_owned()
467}
468
469fn bind_query_params(statement: &mut rusqlite::Statement<'_>, params: &[QueryParam]) -> Result<()> {
470    for param in params {
471        let mut bound = false;
472
473        for prefix in [":", "@", "$"] {
474            let parameter_name = format!("{prefix}{}", param.name);
475            if let Some(index) = statement
476                .parameter_index(&parameter_name)
477                .with_context(|| format!("failed to inspect parameter {parameter_name}"))?
478            {
479                statement
480                    .raw_bind_parameter(index, to_sql_value(&param.value))
481                    .with_context(|| format!("failed to bind parameter {parameter_name}"))?;
482                bound = true;
483                break;
484            }
485        }
486
487        if !bound {
488            bail!("query does not contain parameter :{}", param.name);
489        }
490    }
491
492    Ok(())
493}
494
495fn format_blob(value: &[u8]) -> String {
496    use std::fmt::Write as _;
497
498    let mut formatted = String::from("0x");
499    for byte in value {
500        let _ = write!(&mut formatted, "{byte:02x}");
501    }
502
503    formatted
504}
505
506fn quote_identifier(identifier: &str) -> String {
507    format!("\"{}\"", identifier.replace('"', "\"\""))
508}
509
510fn sanitize_table_name(name: &str) -> String {
511    let sanitized = name
512        .chars()
513        .map(|character| {
514            if character.is_alphanumeric() || character == '_' {
515                character
516            } else {
517                '_'
518            }
519        })
520        .collect::<String>()
521        .trim_matches('_')
522        .to_string();
523
524    if sanitized.is_empty() {
525        "table_view".to_owned()
526    } else {
527        sanitized
528    }
529}
530
531fn normalize_view_key(name: &str) -> String {
532    name.to_ascii_lowercase()
533}
534
535#[cfg(test)]
536mod tests {
537    use super::*;
538
539    #[test]
540    fn calculates_safe_insert_batch_size() {
541        assert_eq!(calculate_insert_batch_size(1), SQLITE_MAX_INSERT_ROWS);
542        assert_eq!(calculate_insert_batch_size(3), SQLITE_MAX_INSERT_ROWS);
543        assert_eq!(calculate_insert_batch_size(400), 2);
544        assert_eq!(calculate_insert_batch_size(2_000), 1);
545    }
546
547    #[test]
548    fn builds_batched_insert_sql_for_multiple_rows() {
549        assert_eq!(
550            build_batched_insert_sql("table", 2, 3),
551            "INSERT INTO \"table\" VALUES (?, ?), (?, ?), (?, ?)"
552        );
553    }
554}