diff --git a/src-tauri/src/drivers/mysql/mod.rs b/src-tauri/src/drivers/mysql/mod.rs index 45ba6f44..5a5df43b 100644 --- a/src-tauri/src/drivers/mysql/mod.rs +++ b/src-tauri/src/drivers/mysql/mod.rs @@ -4,6 +4,7 @@ pub mod types; mod explain; mod helpers; +mod multi_result; mod stmt_classify; #[cfg(test)] @@ -1172,6 +1173,7 @@ async fn exec_on_mysql_conn( affected_rows: exec_result.rows_affected(), truncated: false, pagination: None, + additional_results: None, }); } @@ -1192,6 +1194,7 @@ async fn exec_on_mysql_conn( affected_rows: exec_result.rows_affected(), truncated: false, pagination: None, + additional_results: None, }); } @@ -1218,33 +1221,38 @@ async fn exec_on_mysql_conn( final_query = query.to_string(); } - let mut columns: Vec = Vec::new(); - let mut json_rows = Vec::new(); + // A single statement may stream back several result sets (e.g. a `CALL` + // whose procedure body holds multiple `SELECT`s), so `fetch_many` is used + // instead of `fetch`: it interleaves rows with one `Either::Left` + // terminator per result set, which the collector folds into discrete sets. + let mut collector = multi_result::ResultSetCollector::new(manual_limit); // Scope the stream so `conn` borrow is released before returning { use futures::stream::StreamExt; - let mut rows_stream = if text.enabled { - use sqlx::Executor; - (&mut *conn).fetch(sqlx::raw_sql(&final_query)) + use sqlx::Executor; + let mut event_stream = if text.enabled { + (&mut *conn).fetch_many(sqlx::raw_sql(&final_query)) } else { - sqlx::query(&final_query).fetch(&mut *conn) + (&mut *conn).fetch_many(sqlx::query(&final_query)) }; - while let Some(result) = rows_stream.next().await { + while let Some(result) = event_stream.next().await { match result { - Ok(row) => { - // Initialize columns from the first row - if columns.is_empty() { - columns = row.columns().iter().map(|c| c.name().to_string()).collect(); + Ok(sqlx::Either::Left(_)) => collector.end_result_set(), + Ok(sqlx::Either::Right(row)) => { + // Initialize columns from the first row of each result set + if collector.needs_columns() { + collector.set_columns( + row.columns().iter().map(|c| c.name().to_string()).collect(), + ); } - // Check limit (only if manual_limit is set) - if let Some(l) = manual_limit { - if json_rows.len() >= l as usize { - truncated = true; - break; - } + // Past the row cap the row is still consumed (the stream + // must drain to reach later result sets) but not decoded. + if collector.at_limit() { + collector.note_overflow_row(); + continue; } // Map row using type extraction function @@ -1253,12 +1261,41 @@ async fn exec_on_mysql_conn( let val = extract_value(&row, i, None); json_row.push(val); } - json_rows.push(json_row); + collector.push_row(json_row); } Err(e) => return Err(e.to_string()), } } - } // rows_stream dropped here — conn borrow released + } // event_stream dropped here — conn borrow released + + let mut result_sets = collector.finish(); + let primary = if result_sets.is_empty() { + multi_result::ResultSetData::default() + } else { + result_sets.remove(0) + }; + let columns = primary.columns; + let mut json_rows = primary.rows; + if primary.truncated { + truncated = true; + } + let additional_results = if result_sets.is_empty() { + None + } else { + Some( + result_sets + .into_iter() + .map(|set| QueryResult { + columns: set.columns, + rows: set.rows, + affected_rows: 0, + truncated: set.truncated, + pagination: None, + additional_results: None, + }) + .collect(), + ) + }; // Apply LIMIT +1 result: if we got page_size+1 rows, has_more=true if let Some(ref mut p) = pagination { @@ -1276,6 +1313,7 @@ async fn exec_on_mysql_conn( affected_rows: 0, truncated, pagination, + additional_results, }) } diff --git a/src-tauri/src/drivers/mysql/multi_result.rs b/src-tauri/src/drivers/mysql/multi_result.rs new file mode 100644 index 00000000..c3663651 --- /dev/null +++ b/src-tauri/src/drivers/mysql/multi_result.rs @@ -0,0 +1,86 @@ +//! Accumulates the result sets streamed back by a single MySQL statement. +//! +//! A statement is usually one result set, but a `CALL` to a stored procedure +//! may return several (one per `SELECT` in its body). `sqlx`'s `fetch_many` +//! surfaces them as a flat stream of rows interleaved with per-result-set +//! terminators; this collector folds that stream back into discrete sets. + +/// One materialized result set: column names plus JSON-encoded rows. +#[derive(Debug, Default)] +pub struct ResultSetData { + pub columns: Vec, + pub rows: Vec>, + pub truncated: bool, +} + +/// Folds a `fetch_many`-style event stream (rows + result-set terminators) +/// into a list of [`ResultSetData`], applying an optional per-set row cap. +/// +/// Result sets that produced no rows are dropped: without rows `sqlx` exposes +/// no column metadata, and the trailing `OK` packet of a `CALL` arrives as an +/// empty set too, so an empty set is indistinguishable from "no result set". +/// This mirrors the pre-existing single-set behaviour where a rowless query +/// yielded empty `columns` / `rows`. +pub struct ResultSetCollector { + limit: Option, + done: Vec, + current: ResultSetData, +} + +impl ResultSetCollector { + pub fn new(limit: Option) -> Self { + Self { + limit, + done: Vec::new(), + current: ResultSetData::default(), + } + } + + /// True until the first row of the current result set has provided + /// column metadata. + pub fn needs_columns(&self) -> bool { + self.current.columns.is_empty() + } + + pub fn set_columns(&mut self, columns: Vec) { + self.current.columns = columns; + } + + /// True when the current result set already holds `limit` rows. Callers + /// should still consume (and discard) the remaining rows of the set so + /// that any following result sets are reached. + pub fn at_limit(&self) -> bool { + matches!(self.limit, Some(l) if self.current.rows.len() >= l as usize) + } + + /// Appends a row to the current result set, or marks the set as + /// truncated when the row cap has been reached. + pub fn push_row(&mut self, row: Vec) { + if self.at_limit() { + self.current.truncated = true; + } else { + self.current.rows.push(row); + } + } + + /// Records that a row beyond the cap was consumed from the wire without + /// being decoded, marking the current result set as truncated. + pub fn note_overflow_row(&mut self) { + self.current.truncated = true; + } + + /// Closes the current result set (a `fetch_many` `Left` terminator). + pub fn end_result_set(&mut self) { + if !self.current.rows.is_empty() { + self.done.push(std::mem::take(&mut self.current)); + } else { + self.current = ResultSetData::default(); + } + } + + /// Returns all collected result sets, closing any still-open one. + pub fn finish(mut self) -> Vec { + self.end_result_set(); + self.done + } +} diff --git a/src-tauri/src/drivers/mysql/tests.rs b/src-tauri/src/drivers/mysql/tests.rs index 5a733f92..2e0d4467 100644 --- a/src-tauri/src/drivers/mysql/tests.rs +++ b/src-tauri/src/drivers/mysql/tests.rs @@ -813,3 +813,118 @@ mod build_mysql_pk_where_tests { assert!(build_mysql_pk_where(&pk_map).is_err()); } } + +mod multi_result_collector { + use super::super::multi_result::ResultSetCollector; + use serde_json::json; + + fn row(v: i64) -> Vec { + vec![json!(v)] + } + + #[test] + fn single_result_set_is_collected() { + let mut c = ResultSetCollector::new(None); + assert!(c.needs_columns()); + c.set_columns(vec!["id".to_string()]); + assert!(!c.needs_columns()); + c.push_row(row(1)); + c.push_row(row(2)); + c.end_result_set(); + + let sets = c.finish(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].columns, vec!["id".to_string()]); + assert_eq!(sets[0].rows.len(), 2); + assert!(!sets[0].truncated); + } + + #[test] + fn multiple_result_sets_are_split_at_terminators() { + let mut c = ResultSetCollector::new(None); + for set in 0..3 { + assert!(c.needs_columns()); + c.set_columns(vec![format!("col{set}")]); + c.push_row(row(set)); + c.end_result_set(); + } + + let sets = c.finish(); + assert_eq!(sets.len(), 3); + assert_eq!(sets[1].columns, vec!["col1".to_string()]); + assert_eq!(sets[2].rows, vec![row(2)]); + } + + #[test] + fn empty_result_sets_are_dropped() { + // A CALL emits a trailing OK packet that surfaces as an empty set; + // rowless SELECTs are indistinguishable from it and dropped too. + let mut c = ResultSetCollector::new(None); + c.set_columns(vec!["id".to_string()]); + c.push_row(row(1)); + c.end_result_set(); + c.end_result_set(); + c.end_result_set(); + + let sets = c.finish(); + assert_eq!(sets.len(), 1); + } + + #[test] + fn no_rows_at_all_yields_no_sets() { + let mut c = ResultSetCollector::new(None); + c.end_result_set(); + assert!(c.finish().is_empty()); + } + + #[test] + fn per_set_limit_truncates_each_set_independently() { + let mut c = ResultSetCollector::new(Some(2)); + c.set_columns(vec!["id".to_string()]); + c.push_row(row(1)); + assert!(!c.at_limit()); + c.push_row(row(2)); + assert!(c.at_limit()); + c.note_overflow_row(); + c.end_result_set(); + + // The cap applies per result set: the next set starts fresh. + c.set_columns(vec!["id".to_string()]); + c.push_row(row(3)); + assert!(!c.at_limit()); + c.end_result_set(); + + let sets = c.finish(); + assert_eq!(sets.len(), 2); + assert_eq!(sets[0].rows.len(), 2); + assert!(sets[0].truncated); + assert_eq!(sets[1].rows.len(), 1); + assert!(!sets[1].truncated); + } + + #[test] + fn push_row_beyond_limit_drops_row_and_marks_truncated() { + let mut c = ResultSetCollector::new(Some(1)); + c.set_columns(vec!["id".to_string()]); + c.push_row(row(1)); + c.push_row(row(2)); + c.end_result_set(); + + let sets = c.finish(); + assert_eq!(sets[0].rows, vec![row(1)]); + assert!(sets[0].truncated); + } + + #[test] + fn finish_flushes_an_unterminated_set() { + // Defensive: a stream that ends without a final terminator must not + // lose the in-flight rows. + let mut c = ResultSetCollector::new(None); + c.set_columns(vec!["id".to_string()]); + c.push_row(row(1)); + + let sets = c.finish(); + assert_eq!(sets.len(), 1); + assert_eq!(sets[0].rows.len(), 1); + } +} diff --git a/src-tauri/src/drivers/postgres/mod.rs b/src-tauri/src/drivers/postgres/mod.rs index 695c7754..14bb4215 100644 --- a/src-tauri/src/drivers/postgres/mod.rs +++ b/src-tauri/src/drivers/postgres/mod.rs @@ -844,6 +844,7 @@ async fn exec_on_pg_client( affected_rows: affected, truncated: false, pagination: None, + additional_results: None, }); } @@ -920,6 +921,7 @@ async fn exec_on_pg_client( affected_rows: 0, truncated, pagination, + additional_results: None, }) } diff --git a/src-tauri/src/drivers/sqlite/mod.rs b/src-tauri/src/drivers/sqlite/mod.rs index 3c7479ab..17a400ee 100644 --- a/src-tauri/src/drivers/sqlite/mod.rs +++ b/src-tauri/src/drivers/sqlite/mod.rs @@ -530,6 +530,7 @@ async fn exec_on_sqlite_conn( affected_rows: exec_result.rows_affected(), truncated: false, pagination: None, + additional_results: None, }); } @@ -605,6 +606,7 @@ async fn exec_on_sqlite_conn( affected_rows: 0, truncated, pagination, + additional_results: None, }) } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index f33e1551..c0aa0572 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -339,6 +339,12 @@ pub struct QueryResult { #[serde(default)] pub truncated: bool, pub pagination: Option, + /// Extra result sets produced by a single statement beyond the first one, + /// e.g. a MySQL `CALL` to a stored procedure containing multiple `SELECT`s. + /// The first result set stays in `columns` / `rows` so consumers unaware + /// of multi-result statements keep working unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additional_results: Option>, } /// One statement's outcome within an `execute_batch` call. Exactly one of diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index 51447326..8dfc11d7 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -718,7 +718,6 @@ "selectTypeFirst": "Zuerst Kontext/Namespace/Typ auswählen", "useK8s": "Kubernetes-Port-Forward verwenden", "useK8sConnection": "Gespeicherte Verbindung", - "advanced": "Erweitert", "startupScript": "Startskript", "startupScriptDescription": "SQL, das bei jeder neuen Verbindung zu dieser Datenquelle ausgeführt wird. Verwenden Sie es für Sitzungseinstellungen wie SET / set_config (z. B. zum Umgehen von RLS). Trennen Sie Anweisungen mit Semikolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1091,6 +1090,7 @@ "viewTabs": "Tab-Ansicht", "viewStacked": "Gestapelte Ansicht", "queryPrefix": "Abfrage", + "resultSetPrefix": "Ergebnis", "results": "Ergebnisse", "collapseAll": "Alle einklappen", "expandAll": "Alle ausklappen", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c4b7258a..d18ac987 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -752,7 +752,6 @@ "selectTypeFirst": "Select context/namespace/type first", "useK8s": "Use Kubernetes Port-Forward", "useK8sConnection": "Saved Connection", - "advanced": "Advanced", "startupScript": "Startup Script", "startupScriptDescription": "SQL run on every new connection to this data source. Use it for session settings such as SET / set_config (e.g. bypassing RLS). Separate statements with semicolons.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1127,6 +1126,7 @@ "viewTabs": "Tab view", "viewStacked": "Stacked view", "queryPrefix": "Query", + "resultSetPrefix": "Result", "results": "Results", "collapseAll": "Collapse all", "expandAll": "Expand all", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index c94ee32d..da4d7a6e 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -723,7 +723,6 @@ "selectTypeFirst": "Selecciona primero contexto/namespace/tipo", "useK8s": "Usar Port-Forward de Kubernetes", "useK8sConnection": "Conexión guardada", - "advanced": "Avanzado", "startupScript": "Script de inicio", "startupScriptDescription": "SQL que se ejecuta en cada nueva conexión a esta fuente de datos. Úsalo para ajustes de sesión como SET / set_config (p. ej., para omitir RLS). Separa las sentencias con punto y coma.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1086,6 +1085,7 @@ "viewTabs": "Vista en pestañas", "viewStacked": "Vista apilada", "queryPrefix": "Consulta", + "resultSetPrefix": "Resultado", "results": "Resultados", "collapseAll": "Contraer todo", "expandAll": "Expandir todo", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9f5f0c3f..2bb662df 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -718,7 +718,6 @@ "selectTypeFirst": "Sélectionnez d'abord contexte/namespace/type", "useK8s": "Utiliser le Port-Forward Kubernetes", "useK8sConnection": "Connexion enregistrée", - "advanced": "Avancé", "startupScript": "Script de démarrage", "startupScriptDescription": "SQL exécuté à chaque nouvelle connexion à cette source de données. Utilisez-le pour des paramètres de session tels que SET / set_config (par exemple, pour contourner la RLS). Séparez les instructions par des points-virgules.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1091,6 +1090,7 @@ "viewTabs": "Vue onglets", "viewStacked": "Vue empilée", "queryPrefix": "Requête", + "resultSetPrefix": "Résultat", "results": "Résultats", "collapseAll": "Tout réduire", "expandAll": "Tout développer", diff --git a/src/i18n/locales/it.json b/src/i18n/locales/it.json index afa62335..b2c46175 100644 --- a/src/i18n/locales/it.json +++ b/src/i18n/locales/it.json @@ -723,7 +723,6 @@ "selectTypeFirst": "Seleziona prima contesto/namespace/tipo", "useK8s": "Usa Port-Forward Kubernetes", "useK8sConnection": "Connessione salvata", - "advanced": "Avanzate", "startupScript": "Script di avvio", "startupScriptDescription": "SQL eseguito a ogni nuova connessione a questa origine dati. Usalo per le impostazioni di sessione come SET / set_config (ad es. per bypassare la RLS). Separa le istruzioni con punto e virgola.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1093,6 +1092,7 @@ "viewTabs": "Vista a tab", "viewStacked": "Vista impilata", "queryPrefix": "Query", + "resultSetPrefix": "Risultato", "results": "Risultati", "collapseAll": "Comprimi tutto", "expandAll": "Espandi tutto", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 38240998..7ead7385 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -732,7 +732,6 @@ "selectTypeFirst": "先にコンテキスト/ネームスペース/タイプを選択してください", "useK8s": "Kubernetes ポートフォワードを使用", "useK8sConnection": "保存された接続", - "advanced": "詳細設定", "startupScript": "起動スクリプト", "startupScriptDescription": "このデータソースへの新規接続ごとに実行される SQL です。SET / set_config(例: RLS のバイパス)などのセッション設定に使用します。複数のステートメントはセミコロンで区切ってください。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1103,6 +1102,7 @@ "viewTabs": "タブ表示", "viewStacked": "スタック表示", "queryPrefix": "クエリ", + "resultSetPrefix": "結果", "results": "結果", "collapseAll": "すべて折りたたむ", "expandAll": "すべて展開", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index d419fe8e..c1cee46a 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -711,7 +711,6 @@ "useK8s": "Использовать проброс портов Kubernetes", "useK8sConnection": "Сохранённое подключение", "appearance": "Внешний вид", - "advanced": "Дополнительно", "startupScript": "Сценарий запуска", "startupScriptDescription": "SQL, выполняемый при каждом новом подключении к этому источнику данных. Используйте его для настроек сеанса, таких как SET / set_config (например, для обхода RLS). Разделяйте операторы точкой с запятой.", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1097,6 +1096,7 @@ "viewTabs": "Вкладки", "viewStacked": "Список", "queryPrefix": "Запрос", + "resultSetPrefix": "Результат", "results": "Результаты", "collapseAll": "Свернуть все", "expandAll": "Развернуть все", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 0ee1f4c0..d642e56a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -686,7 +686,6 @@ "selectTypeFirst": "请先选择上下文/命名空间/类型", "useK8s": "使用 Kubernetes 端口转发", "useK8sConnection": "已保存的连接", - "advanced": "高级", "startupScript": "启动脚本", "startupScriptDescription": "每次新建到此数据源的连接时执行的 SQL。可用于会话设置,例如 SET / set_config(如绕过 RLS)。多条语句请用分号分隔。", "startupScriptPlaceholder": "SELECT set_config('app.bypass_rls', 'on', false);", @@ -1045,7 +1044,8 @@ "close": "关闭标签", "rename": "重命名", "aiGenerateName": "使用 AI 生成名称", - "generatingName": "正在生成名称..." + "generatingName": "正在生成名称...", + "resultSetPrefix": "结果" } }, "createTable": { diff --git a/src/pages/Editor.tsx b/src/pages/Editor.tsx index 96a971a1..4660760a 100644 --- a/src/pages/Editor.tsx +++ b/src/pages/Editor.tsx @@ -73,6 +73,7 @@ import { import { splitQueries, extractTableName, getExplainableQueries, statementLabel } from "../utils/sql"; import { createResultEntries, + createEntriesFromResultSets, updateResultEntry, removeResultEntry, removeOtherEntries, @@ -761,6 +762,41 @@ export const Editor = () => { }); const end = performance.now(); + // A single statement can return several result sets (e.g. a MySQL + // CALL to a procedure with multiple SELECTs): show them as separate + // result tabs, reusing the multi-statement results UI. Row editing + // metadata (activeTable / pkColumns) is skipped — procedure output + // is not row-editable. + if (res.additional_results && res.additional_results.length > 0) { + const entries = createEntriesFromResultSets( + targetTabId, + textToRun, + res, + end - start, + t("editor.multiResult.resultSetPrefix"), + ); + updateTab(targetTabId, { + results: entries, + activeResultId: entries[0].id, + result: null, + executionTime: end - start, + isLoading: false, + activeTable: null, + pkColumns: null, + }); + if (shouldRecordHistory) { + addHistoryEntry( + textToRun, + end - start, + "success", + null, + null, + historyDb, + ); + } + return; + } + // Fetch PK column if this is a table tab OR if the query references a table const currentTab = tabsRef.current.find((t) => t.id === targetTabId); let tableName = currentTab?.activeTable; diff --git a/src/types/editor.ts b/src/types/editor.ts index befa106c..17f9e7fe 100644 --- a/src/types/editor.ts +++ b/src/types/editor.ts @@ -37,6 +37,11 @@ export interface QueryResult { affected_rows: number; truncated?: boolean; pagination?: Pagination; + /// Extra result sets beyond the first one from a single statement, e.g. a + /// MySQL `CALL` whose procedure body holds multiple `SELECT`s. Mirrors + /// `src-tauri/src/models.rs::QueryResult`; the first set stays in + /// `columns` / `rows`. + additional_results?: QueryResult[]; } /// One statement's outcome inside an `execute_query_batch` invocation. diff --git a/src/utils/multiResult.ts b/src/utils/multiResult.ts index ad9d1041..8cd3f506 100644 --- a/src/utils/multiResult.ts +++ b/src/utils/multiResult.ts @@ -1,4 +1,4 @@ -import type { QueryResultEntry } from "../types/editor"; +import type { QueryResult, QueryResultEntry } from "../types/editor"; /** * Creates initial QueryResultEntry array from a list of queries. @@ -22,6 +22,41 @@ export function createResultEntries( })); } +/** + * Builds already-resolved QueryResultEntry items for a single statement that + * returned multiple result sets (e.g. a MySQL CALL with several SELECTs in + * the procedure body). The primary result (stripped of `additional_results`) + * and each additional result set become one entry each, labelled + * "{labelPrefix} 1..N". + * + * The execution time is attached to the first entry only: the statement ran + * once, so summing per-entry times across the tab bar must yield the real + * total instead of N copies of it. + */ +export function createEntriesFromResultSets( + tabId: string, + query: string, + result: QueryResult, + executionTime: number | null, + labelPrefix: string, +): QueryResultEntry[] { + const { additional_results, ...primary } = result; + const sets: QueryResult[] = [primary, ...(additional_results ?? [])]; + return sets.map((res, index) => ({ + id: `${tabId}-result-${index}`, + queryIndex: index, + query, + label: `${labelPrefix} ${index + 1}`, + result: res, + error: "", + executionTime: index === 0 ? executionTime : null, + isLoading: false, + page: 1, + activeTable: null, + pkColumns: null, + })); +} + /** * Updates a single entry within a results array by id. * Returns a new array with the matching entry replaced. diff --git a/tests/utils/multiResult.test.ts b/tests/utils/multiResult.test.ts index 99430316..0dbec22b 100644 --- a/tests/utils/multiResult.test.ts +++ b/tests/utils/multiResult.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; import { createResultEntries, + createEntriesFromResultSets, updateResultEntry, findActiveEntry, countSucceeded, @@ -13,7 +14,7 @@ import { getEntryDisplayLabel, getStackedGridHeight, } from "../../src/utils/multiResult"; -import type { QueryResultEntry } from "../../src/types/editor"; +import type { QueryResult, QueryResultEntry } from "../../src/types/editor"; function makeEntry(overrides: Partial = {}): QueryResultEntry { return { @@ -31,7 +32,93 @@ function makeEntry(overrides: Partial = {}): QueryResultEntry }; } +function makeResult(overrides: Partial = {}): QueryResult { + return { + columns: ["message"], + rows: [["First result"]], + affected_rows: 0, + ...overrides, + }; +} + describe("multiResult", () => { + describe("createEntriesFromResultSets", () => { + it("should create one entry per result set (primary + additional)", () => { + const result = makeResult({ + additional_results: [ + makeResult({ rows: [["Second result"]] }), + makeResult({ rows: [["Third result"]] }), + ], + }); + const entries = createEntriesFromResultSets( + "tab-1", + "CALL sp_test()", + result, + 42, + "Result", + ); + expect(entries).toHaveLength(3); + expect(entries[0].id).toBe("tab-1-result-0"); + expect(entries[0].result?.rows).toEqual([["First result"]]); + expect(entries[1].result?.rows).toEqual([["Second result"]]); + expect(entries[2].result?.rows).toEqual([["Third result"]]); + }); + + it("should strip additional_results from the primary entry's result", () => { + const result = makeResult({ + additional_results: [makeResult()], + }); + const entries = createEntriesFromResultSets("t", "CALL p()", result, 1, "Result"); + expect(entries[0].result?.additional_results).toBeUndefined(); + }); + + it("should label entries with the given prefix and 1-based index", () => { + const result = makeResult({ additional_results: [makeResult()] }); + const entries = createEntriesFromResultSets("t", "CALL p()", result, 1, "Result"); + expect(entries[0].label).toBe("Result 1"); + expect(entries[1].label).toBe("Result 2"); + }); + + it("should attach the execution time to the first entry only", () => { + const result = makeResult({ + additional_results: [makeResult(), makeResult()], + }); + const entries = createEntriesFromResultSets("t", "CALL p()", result, 123, "Result"); + expect(entries[0].executionTime).toBe(123); + expect(entries[1].executionTime).toBeNull(); + expect(entries[2].executionTime).toBeNull(); + }); + + it("should mark all entries as resolved and non-editable", () => { + const result = makeResult({ additional_results: [makeResult()] }); + const entries = createEntriesFromResultSets("t", "CALL p()", result, 1, "Result"); + for (const entry of entries) { + expect(entry.isLoading).toBe(false); + expect(entry.error).toBe(""); + expect(entry.page).toBe(1); + expect(entry.activeTable).toBeNull(); + expect(entry.pkColumns).toBeNull(); + expect(entry.query).toBe("CALL p()"); + } + }); + + it("should handle a result without additional sets as a single entry", () => { + const entries = createEntriesFromResultSets("t", "SELECT 1", makeResult(), 5, "Result"); + expect(entries).toHaveLength(1); + expect(entries[0].result?.columns).toEqual(["message"]); + }); + + it("should preserve per-set truncated and pagination fields", () => { + const result = makeResult({ + truncated: true, + additional_results: [makeResult({ truncated: false })], + }); + const entries = createEntriesFromResultSets("t", "CALL p()", result, 1, "Result"); + expect(entries[0].result?.truncated).toBe(true); + expect(entries[1].result?.truncated).toBe(false); + }); + }); + describe("createResultEntries", () => { it("should create entries from queries with correct ids", () => { const entries = createResultEntries("tab-1", [