Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions crates/flowscope-cli/tests/mssql_go_cli.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
63 changes: 60 additions & 3 deletions crates/flowscope-core/src/analyzer/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -653,13 +653,14 @@ fn split_ranges_on_mssql_go_separators(sql: &str, ranges: Vec<Range<usize>>) ->
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) {
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down
48 changes: 48 additions & 0 deletions crates/flowscope-wasm/tests/analysis.rs
Original file line number Diff line number Diff line change
@@ -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);
}
Binary file modified packages/core/wasm/flowscope_wasm_bg.wasm
Binary file not shown.
17 changes: 16 additions & 1 deletion scripts/test_wasm_browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,15 @@ const harness = `<!doctype html>
.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');
}
Expand All @@ -52,12 +61,18 @@ const harness = `<!doctype html>
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';
Expand Down
Loading