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-core/src/analyzer/input.rs b/crates/flowscope-core/src/analyzer/input.rs index e9d43c49..b7a2c581 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,31 @@ 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", + ] { + 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 +1252,21 @@ 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 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/scripts/test_wasm_browser.mjs b/scripts/test_wasm_browser.mjs index 80cd1d1c..55b235ed 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';