diff --git a/CHANGELOG.md b/CHANGELOG.md index 5456b0dd..8e2e556c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Kept `@pondpilot/flowscope-react` as a private monorepo workspace and removed it from the npm release pipeline +### Fixed + +#### Core Engine (flowscope-core) + +- **MSSQL `GO` batch separators** - treat standalone SQL Server batch separators as statement boundaries during analysis and statement splitting, including trailing separators and CRLF input. + ## [0.9.0] - 2026-08-12 ### Added diff --git a/crates/flowscope-cli/tests/mssql_go_cli.rs b/crates/flowscope-cli/tests/mssql_go_cli.rs new file mode 100644 index 00000000..0b8cb09c --- /dev/null +++ b/crates/flowscope-cli/tests/mssql_go_cli.rs @@ -0,0 +1,44 @@ +use std::process::Command; + +use tempfile::tempdir; + +#[test] +fn analyzes_multiple_mssql_files_with_trailing_go() { + let dir = tempdir().expect("temp dir"); + let first = dir.path().join("first.sql"); + let second = dir.path().join("second.sql"); + let sql = "SELECT 1;\nGO\nSELECT 2;\nGO\n"; + std::fs::write(&first, sql).expect("write first SQL file"); + std::fs::write(&second, sql).expect("write second SQL file"); + + let output = Command::new(env!("CARGO_BIN_EXE_flowscope")) + .args(["-d", "mssql", "-f", "json"]) + .arg(&first) + .arg(&second) + .output() + .expect("run CLI"); + assert!( + output.status.success(), + "CLI failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let result: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("valid CLI JSON output"); + let statements = result["statements"].as_array().expect("statements array"); + assert_eq!(statements.len(), 4); + for (index, statement) in statements.iter().enumerate() { + assert_eq!(statement["statementIndex"], index); + let source = statement["sourceName"].as_str().expect("source name"); + let expected = if index < 2 { "first.sql" } else { "second.sql" }; + assert!(source.ends_with(expected), "unexpected source: {source}"); + let offset = if index % 2 == 0 { 0 } else { 13 }; + assert_eq!(statement["span"]["start"], offset); + assert_eq!(statement["span"]["end"], offset + 8); + } + assert!(!result["issues"] + .as_array() + .expect("issues array") + .iter() + .any(|issue| issue["code"] == "PARSE_ERROR")); +} diff --git a/crates/flowscope-core/src/analyzer/input.rs b/crates/flowscope-core/src/analyzer/input.rs index e9d43c49..dc612dc6 100644 --- a/crates/flowscope-core/src/analyzer/input.rs +++ b/crates/flowscope-core/src/analyzer/input.rs @@ -653,13 +653,14 @@ fn split_ranges_on_mssql_go_separators(sql: &str, ranges: Vec>) -> for range in ranges { let mut cursor = range.start; for go_range in &go_line_ranges { - if go_range.start < range.start || go_range.end > range.end || go_range.start < cursor { + if go_range.end <= cursor || go_range.start >= range.end { continue; } - if let Some(chunk) = trim_statement_range(sql, cursor, go_range.start) { + let separator_start = go_range.start.max(cursor); + if let Some(chunk) = trim_statement_range(sql, cursor, separator_start) { out.push(chunk); } - cursor = go_range.end; + cursor = go_range.end.min(range.end); } if let Some(chunk) = trim_statement_range(sql, cursor, range.end) { @@ -1212,6 +1213,32 @@ mod tests { assert_eq!(&sql[ranges[1].clone()], "CREATE TABLE test (id INT)"); } + #[test] + fn mssql_statement_ranges_split_trailing_go_batch_separators() { + for sql in [ + "SELECT 1;\nGO\nSELECT 2;\nGO\n", + "SELECT 1;\r\n go \r\nSELECT 2;\r\nGO\r\n", + "SELECT 1\nGO\nGO\nSELECT 2\nGO\n", + "GO\nSELECT 1;\nGO\nSELECT 2;\nGO\n", + ] { + let ranges = compute_statement_ranges_for_dialect(sql, Dialect::Mssql); + assert_eq!(ranges.len(), 2, "unexpected ranges for {sql:?}"); + assert_eq!(&sql[ranges[0].clone()], "SELECT 1"); + assert_eq!(&sql[ranges[1].clone()], "SELECT 2"); + } + } + + #[test] + fn mssql_statement_ranges_ignore_go_inside_strings_comments_and_identifiers() { + let sql = "SELECT 'GO' AS literal;\nSELECT [GO] FROM [source];\n-- GO\n/* GO */\nGO\nSELECT 'inside\nGO\nstring' AS literal;"; + let ranges = compute_statement_ranges_for_dialect(sql, Dialect::Mssql); + + assert_eq!(ranges.len(), 3); + assert_eq!(&sql[ranges[0].clone()], "SELECT 'GO' AS literal"); + assert_eq!(&sql[ranges[1].clone()], "SELECT [GO] FROM [source]"); + assert!(sql[ranges[2].clone()].contains("inside\nGO\nstring")); + } + #[test] fn collect_statements_mssql_go_batch_without_final_semicolon_parses_statements() { let mut request = base_request(); @@ -1226,6 +1253,36 @@ mod tests { assert_eq!(statements.len(), 2); } + #[test] + fn collect_statements_mssql_go_batch_with_trailing_separator_has_no_parse_error() { + let mut request = base_request(); + request.dialect = Dialect::Mssql; + request.sql = "SELECT 1;\nGO\nSELECT 2;\nGO\n".to_string(); + + let (statements, issues) = collect_statements(&request); + + assert_eq!(statements.len(), 2); + assert!( + issues.is_empty(), + "MSSQL trailing GO should not produce parse errors: {issues:?}" + ); + } + + #[test] + fn best_effort_mssql_go_batches_only_report_invalid_statement() { + let mut request = base_request(); + request.dialect = Dialect::Mssql; + request.sql = "SELECT 1;\nGO\nSELECT FROM;\nGO\nSELECT 2;\nGO\n".to_string(); + + let (statements, issues) = collect_statements(&request); + + assert_eq!(statements.len(), 2); + assert_eq!(issues.len(), 1, "only invalid SQL should fail: {issues:?}"); + assert_eq!(issues[0].code, issue_codes::PARSE_ERROR); + let span = issues[0].span.expect("invalid statement span"); + assert_eq!(&request.sql[span.start..span.end], "SELECT FROM"); + } + #[test] fn parses_procedure_with_inner_semicolons() { let mut request = base_request(); diff --git a/crates/flowscope-wasm/tests/analysis.rs b/crates/flowscope-wasm/tests/analysis.rs new file mode 100644 index 00000000..6877fa1b --- /dev/null +++ b/crates/flowscope-wasm/tests/analysis.rs @@ -0,0 +1,48 @@ +use flowscope_wasm::{analyze_sql_json, split_statements_json}; +use serde_json::Value; + +#[test] +fn analyze_sql_json_handles_mssql_go_batch_separators() { + let request = serde_json::json!({ + "sql": "SELECT 1;\nGO\nSELECT 2;\nGO\n", + "dialect": "mssql" + }); + + let result: Value = serde_json::from_str(&analyze_sql_json(&request.to_string())) + .expect("analysis result should be valid JSON"); + let statements = result + .get("statements") + .and_then(Value::as_array) + .expect("analysis result should contain statements"); + let issues = result + .get("issues") + .and_then(Value::as_array) + .expect("analysis result should contain issues"); + + assert_eq!(statements.len(), 2); + assert!(!issues + .iter() + .any(|issue| { issue.get("code") == Some(&Value::String("PARSE_ERROR".to_string())) })); +} + +#[test] +fn split_statements_json_handles_mssql_go_batch_separators() { + let sql = "SELECT 1;\nGO\nSELECT 2;\nGO\n"; + let request = serde_json::json!({ + "sql": sql, + "dialect": "mssql" + }); + + let result: Value = serde_json::from_str(&split_statements_json(&request.to_string())) + .expect("statement split result should be valid JSON"); + let statements = result + .get("statements") + .and_then(Value::as_array) + .expect("statement split result should contain statements"); + + assert_eq!(statements.len(), 2); + assert_eq!(statements[0]["start"], 0); + assert_eq!(statements[0]["end"], 8); + assert_eq!(statements[1]["start"], 13); + assert_eq!(statements[1]["end"], 21); +} diff --git a/packages/core/wasm/flowscope_wasm_bg.wasm b/packages/core/wasm/flowscope_wasm_bg.wasm index 37dd9065..05da6b7d 100644 Binary files a/packages/core/wasm/flowscope_wasm_bg.wasm and b/packages/core/wasm/flowscope_wasm_bg.wasm differ diff --git a/scripts/test_wasm_browser.mjs b/scripts/test_wasm_browser.mjs index 80cd1d1c..ddc7e0fb 100644 --- a/scripts/test_wasm_browser.mjs +++ b/scripts/test_wasm_browser.mjs @@ -43,6 +43,15 @@ const harness = ` .map((node) => node.label); const errors = result.issues.filter((issue) => issue.severity === 'error'); + const mssqlRequest = { + sql: 'SELECT 1;\\nGO\\nSELECT 2;\\nGO\\n', + dialect: 'mssql', + }; + const mssqlResult = JSON.parse(analyze_sql_json(JSON.stringify(mssqlRequest))); + const mssqlParseErrors = mssqlResult.issues.filter( + (issue) => issue.code === 'PARSE_ERROR' + ); + if (result.statements.length !== 1 || result.summary.statementCount !== 1) { throw new Error('Expected one analyzed statement'); } @@ -52,12 +61,18 @@ const harness = ` if (errors.length > 0) { throw new Error('Analysis returned errors: ' + JSON.stringify(errors)); } + if (mssqlResult.statements.length !== 2 || mssqlParseErrors.length > 0) { + throw new Error( + 'MSSQL GO batch analysis failed: ' + JSON.stringify(mssqlResult.issues) + ); + } body.dataset.status = 'passed'; body.textContent = JSON.stringify({ version: get_version(), statementCount: result.summary.statementCount, - tableLabels + tableLabels, + mssqlStatementCount: mssqlResult.summary.statementCount, }); } catch (error) { body.dataset.status = 'failed';