mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(sqlserver): diagnose DECLARE without data type in routine bodies
This commit is contained in:
@@ -136,6 +136,7 @@ import { createHoverSearch, type HoverSearchController } from "@/lib/editor/sqlH
|
||||
import { lineColumnToOffset, sqlErrorDecorationRange as resolveSqlErrorDecorationRange, sqlErrorSqlMatchesEditor } from "@/lib/sql/sqlDiagnostics";
|
||||
import { analyzeMysqlRoutineSyntax, supportsMysqlRoutineSyntaxDiagnostics } from "@/lib/sql/mysqlRoutineSyntaxDiagnostics";
|
||||
import { buildOracleSyntaxDiagnostics } from "@/lib/sql/oracleSyntaxDiagnostics";
|
||||
import { buildSqlServerRoutineSyntaxDiagnostics } from "@/lib/sql/sqlServerRoutineSyntaxDiagnostics";
|
||||
import {
|
||||
DBX_TABLE_REFERENCE_MIME,
|
||||
DBX_TABLE_REFERENCE_DROP_EVENT,
|
||||
@@ -217,6 +218,7 @@ import {
|
||||
isSqlVirtualTableReference,
|
||||
shouldRunSqlSemanticDiagnostics,
|
||||
sqlSemanticDiagnosticRangesForViewport,
|
||||
sqlServerRoutineDefinitionRangesForViewport,
|
||||
tableReferenceKey,
|
||||
type SqlSemanticDiagnostic,
|
||||
} from "@/lib/sql/semantic/diagnostics";
|
||||
@@ -3940,7 +3942,11 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, currentView.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
}
|
||||
const diagnosticRanges = sqlSemanticDiagnosticRangesForViewport(sql, visibleRanges, props.databaseType, props.databaseType === "sqlserver" ? undefined : executableStatementRangeCache?.ranges, sqlStatementParameterOptions());
|
||||
if (diagnosticRanges.length === 0) {
|
||||
// SQL Server routine batches are excluded from `diagnosticRanges` (see
|
||||
// `sqlServerRoutineDefinitionRangesForViewport`), so they are recomputed here
|
||||
// and stay part of the replaced range set below.
|
||||
const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];
|
||||
if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {
|
||||
if (!options.preserveOutsideRanges) setSemanticDiagnostics([]);
|
||||
return;
|
||||
}
|
||||
@@ -3953,6 +3959,12 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
return !!diagnosticRange && diagnosticRanges.some((range) => rangesOverlap(diagnosticRange, range));
|
||||
}),
|
||||
);
|
||||
// The analyzer never sees routine batches (the MsSql grammar cannot parse their
|
||||
// parameter list), so run the token-based routine syntax rules instead of leaving
|
||||
// a stored procedure without any check at all (dbx#9315).
|
||||
for (const range of sqlServerRoutineRanges) {
|
||||
nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));
|
||||
}
|
||||
const mysqlRoutineAnalysis = props.databaseType === "mysql" && supportsMysqlRoutineSyntaxDiagnostics(sqlDriverProfile.value) ? analyzeMysqlRoutineSyntax(sql) : null;
|
||||
if (mysqlRoutineAnalysis) {
|
||||
nextDiagnostics.push(
|
||||
@@ -4018,7 +4030,7 @@ async function refreshSemanticDiagnostics(options: { preserveOutsideRanges?: boo
|
||||
}
|
||||
}
|
||||
if (options.preserveOutsideRanges) {
|
||||
replaceSemanticDiagnosticsInRanges(nextDiagnostics, diagnosticRanges, sql);
|
||||
replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);
|
||||
} else {
|
||||
setSemanticDiagnostics(nextDiagnostics.sort(compareSqlSemanticDiagnostics));
|
||||
}
|
||||
|
||||
Vendored
+37
@@ -0,0 +1,37 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* SQL Server 例程批次的语法诊断接线(#9315)写在 `<script setup>` 内部,
|
||||
* 无法直接 import,因此沿用仓库既有做法(QueryEditor.cteNavigationWiring.spec.ts)
|
||||
* 对源码做结构断言。规则本身由 sqlServerRoutineSyntaxDiagnostics.test.ts 覆盖,
|
||||
* 批次/视口裁剪由 sqlSemanticDiagnostics.test.ts 覆盖,这里只锁定接线契约。
|
||||
*/
|
||||
const source = readFileSync(new URL("../QueryEditor.vue", import.meta.url), "utf8");
|
||||
|
||||
function normalizeCode(text: string): string {
|
||||
return text.replace(/\s+/g, "").replace(/,(?=[)\]}])/g, "");
|
||||
}
|
||||
|
||||
const normalizedSource = normalizeCode(source);
|
||||
|
||||
function expectSourceToContain(fragment: string): void {
|
||||
expect(normalizedSource).toContain(normalizeCode(fragment));
|
||||
}
|
||||
|
||||
describe("QueryEditor 的 SQL Server 例程语法诊断接线", () => {
|
||||
it("例程批次被跳过整个语义范围后仍单独取回", () => {
|
||||
// sqlSemanticDiagnosticRangesForViewport 对例程批次返回空,所以必须再取一次。
|
||||
expectSourceToContain('const sqlServerRoutineRanges = props.databaseType === "sqlserver" ? sqlServerRoutineDefinitionRangesForViewport(sql, visibleRanges) : [];');
|
||||
// 例程批次不能因为普通范围为空就提前 return,否则过程体永远不检查。
|
||||
expectSourceToContain("if (diagnosticRanges.length === 0 && sqlServerRoutineRanges.length === 0) {");
|
||||
});
|
||||
|
||||
it("例程批次只跑 token 规则,不做引用分析", () => {
|
||||
expectSourceToContain("nextDiagnostics.push(...offsetSqlSemanticDiagnostics(buildSqlServerRoutineSyntaxDiagnostics(range.sql, props.databaseType), range, sql));");
|
||||
});
|
||||
|
||||
it("增量刷新时把例程批次一并纳入替换范围,避免旧诊断残留", () => {
|
||||
expectSourceToContain("replaceSemanticDiagnosticsInRanges(nextDiagnostics, [...diagnosticRanges, ...sqlServerRoutineRanges], sql);");
|
||||
});
|
||||
});
|
||||
@@ -41,6 +41,28 @@ export function sqlSemanticDiagnosticRangesForViewport(sql: string, visibleRange
|
||||
return selected;
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL Server routine definition batches (`CREATE/ALTER PROCEDURE | FUNCTION`) are
|
||||
* deliberately excluded from reference diagnostics: the MsSql grammar in the
|
||||
* analyzer cannot parse the parameter list, and metadata checks inside a routine
|
||||
* body (temp tables, table variables) would report noise (see
|
||||
* `isSqlServerRoutineDefinitionBatch`). That suppression also dropped the
|
||||
* *syntax* errors of the body, so a routine that does not even compile looked
|
||||
* clean (issue #9315). Callers use these ranges to run the routine-only syntax
|
||||
* rules that survive the suppression, while reference diagnostics stay off.
|
||||
*/
|
||||
export function sqlServerRoutineDefinitionRangesForViewport(sql: string, visibleRanges: readonly SqlSemanticDiagnosticVisibleRange[]): SqlTextRange[] {
|
||||
if (visibleRanges.length === 0) return [];
|
||||
|
||||
const selected: SqlTextRange[] = [];
|
||||
for (const batch of sqlServerBatchRanges(sql)) {
|
||||
if (!isSqlServerRoutineDefinitionBatch(batch.sql)) continue;
|
||||
if (!visibleRanges.some((visibleRange) => rangesIntersect(batch, visibleRange))) continue;
|
||||
selected.push(batch);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
function sqlServerSemanticDiagnosticRanges(sql: string): SqlTextRange[] {
|
||||
const ranges: SqlTextRange[] = [];
|
||||
for (const batch of sqlServerBatchRanges(sql)) {
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import type { SqlSemanticDiagnostic } from "@/lib/sql/semantic/diagnostics";
|
||||
import type { DatabaseType, SqlTextSpan } from "@/types/database";
|
||||
|
||||
export const SQLSERVER_DECLARE_MISSING_DATA_TYPE_MESSAGE = "T-SQL DECLARE requires a data type before the default value";
|
||||
|
||||
type SqlServerTokenKind = "word" | "variable" | "symbol";
|
||||
|
||||
interface SqlServerToken {
|
||||
kind: SqlServerTokenKind;
|
||||
value: string;
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export function supportsSqlServerRoutineSyntaxDiagnostics(databaseType?: DatabaseType): boolean {
|
||||
return databaseType === "sqlserver";
|
||||
}
|
||||
|
||||
/**
|
||||
* Syntax rules for T-SQL routine bodies (`CREATE/ALTER PROCEDURE | FUNCTION`).
|
||||
*
|
||||
* Routine batches are skipped by the semantic diagnostic pipeline (the analyzer's
|
||||
* MsSql grammar cannot parse the parameter list), so nothing inside a stored
|
||||
* procedure was ever checked while editing it. Re-parsing the body with that same
|
||||
* grammar is not an option either: valid T-SQL such as `WHILE ... BEGIN ... END`,
|
||||
* `IF/ELSE`, `TRY/CATCH` and `GOTO` make it report phantom errors. The rules here
|
||||
* are token based, so only constructs the server itself rejects are flagged
|
||||
* (issue #9315).
|
||||
*/
|
||||
export function buildSqlServerRoutineSyntaxDiagnostics(source: string, databaseType?: DatabaseType): SqlSemanticDiagnostic[] {
|
||||
if (!supportsSqlServerRoutineSyntaxDiagnostics(databaseType)) return [];
|
||||
|
||||
const diagnostics: SqlSemanticDiagnostic[] = [];
|
||||
for (const token of missingDeclareDataTypeTokens(source)) diagnostics.push(diagnosticAtToken(source, token));
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function diagnosticAtToken(source: string, token: SqlServerToken): SqlSemanticDiagnostic {
|
||||
return {
|
||||
span: spanForOffsets(source, token.from, token.to),
|
||||
message: SQLSERVER_DECLARE_MISSING_DATA_TYPE_MESSAGE,
|
||||
severity: "error",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `DECLARE @name = value` is not valid T-SQL: the declaration needs a data type
|
||||
* (`DECLARE @name INT = value`, an optional `AS` is allowed in between). The
|
||||
* server rejects the statement with `Incorrect syntax near '='`, so the same
|
||||
* position is reported here.
|
||||
*
|
||||
* Only positions that can actually start a declaration item are inspected — the
|
||||
* declaration keyword itself, or a top level comma continuing its list — so
|
||||
* assignment statements such as `SELECT @name = value` or `SET @name = value`
|
||||
* are never flagged.
|
||||
*/
|
||||
function missingDeclareDataTypeTokens(source: string): SqlServerToken[] {
|
||||
const tokens = tokenizeSqlServerSyntax(source);
|
||||
const violations: SqlServerToken[] = [];
|
||||
|
||||
let index = 0;
|
||||
let expectsDeclaration = false;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index];
|
||||
if (!expectsDeclaration) {
|
||||
expectsDeclaration = token.kind === "word" && token.value === "DECLARE";
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (token.kind !== "variable") {
|
||||
// `DECLARE name CURSOR FOR ...` names a cursor without an `@`; anything
|
||||
// else ends the declaration list we were tracking.
|
||||
expectsDeclaration = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
let cursor = index + 1;
|
||||
if (tokens[cursor]?.kind === "word" && tokens[cursor].value === "AS") cursor += 1;
|
||||
|
||||
if (tokens[cursor]?.kind === "symbol" && tokens[cursor].value === "=") {
|
||||
violations.push(tokens[cursor]);
|
||||
// The declaration is broken, but the list may continue after the value;
|
||||
// keep tracking it so every untyped entry is reported.
|
||||
cursor = skipInitializerValue(tokens, cursor + 1);
|
||||
} else {
|
||||
// The item has a data type (or `TABLE`/`CURSOR`); skip the type, and the
|
||||
// optional `= value` initializer, to find whether the list continues.
|
||||
cursor = skipDeclarationItem(tokens, cursor);
|
||||
}
|
||||
if (tokens[cursor]?.kind === "symbol" && tokens[cursor].value === ",") {
|
||||
index = cursor + 1;
|
||||
continue;
|
||||
}
|
||||
index = cursor;
|
||||
expectsDeclaration = false;
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** Advances past a declared data type plus an optional `= value` initializer. */
|
||||
function skipDeclarationItem(tokens: readonly SqlServerToken[], start: number): number {
|
||||
let index = start;
|
||||
if (tokens[index]?.kind === "word") index += 1;
|
||||
else if (tokens[index]?.kind === "variable") index += 1;
|
||||
|
||||
if (tokens[index]?.kind === "symbol" && tokens[index].value === "(") index = skipBalancedParentheses(tokens, index);
|
||||
|
||||
if (tokens[index]?.kind === "symbol" && tokens[index].value === "=") {
|
||||
index = skipInitializerValue(tokens, index + 1);
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function skipInitializerValue(tokens: readonly SqlServerToken[], start: number): number {
|
||||
let index = start;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index];
|
||||
if (token.kind === "symbol" && token.value === ",") return index;
|
||||
if (token.kind === "symbol" && token.value === "(") {
|
||||
index = skipBalancedParentheses(tokens, index);
|
||||
continue;
|
||||
}
|
||||
if (token.kind === "symbol" && token.value === ";") return index;
|
||||
if (token.kind === "word" && STATEMENT_BOUNDARY_KEYWORDS.has(token.value)) return index;
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function skipBalancedParentheses(tokens: readonly SqlServerToken[], openingIndex: number): number {
|
||||
let depth = 0;
|
||||
let index = openingIndex;
|
||||
while (index < tokens.length) {
|
||||
const token = tokens[index];
|
||||
if (token.kind === "symbol" && token.value === "(") depth += 1;
|
||||
else if (token.kind === "symbol" && token.value === ")") {
|
||||
depth -= 1;
|
||||
if (depth === 0) return index + 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
// Statement keywords that end an un-terminated declaration list (`DECLARE @a INT`
|
||||
// followed by the next statement without a semicolon).
|
||||
const STATEMENT_BOUNDARY_KEYWORDS = new Set(["SELECT", "INSERT", "UPDATE", "DELETE", "MERGE", "SET", "EXEC", "EXECUTE", "IF", "WHILE", "RETURN", "PRINT", "RAISERROR", "THROW", "WITH", "BEGIN", "GO"]);
|
||||
|
||||
function tokenizeSqlServerSyntax(source: string): SqlServerToken[] {
|
||||
const tokens: SqlServerToken[] = [];
|
||||
let index = 0;
|
||||
while (index < source.length) {
|
||||
const char = source[index];
|
||||
const next = source[index + 1];
|
||||
|
||||
if (char === "-" && next === "-") {
|
||||
index = skipLineComment(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "/" && next === "*") {
|
||||
index = skipBlockComment(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "'" || char === '"') {
|
||||
index = skipQuotedLiteral(source, index, char);
|
||||
continue;
|
||||
}
|
||||
if (char === "[") {
|
||||
index = skipBracketedIdentifier(source, index);
|
||||
continue;
|
||||
}
|
||||
if (char === "@") {
|
||||
const end = readWordEnd(source, index + 1);
|
||||
if (end > index + 1) {
|
||||
tokens.push({ kind: "variable", value: source.slice(index + 1, end).toUpperCase(), from: index, to: end });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: "symbol", value: char, from: index, to: index + 1 });
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/[A-Za-z_]/.test(char)) {
|
||||
const end = readWordEnd(source, index);
|
||||
tokens.push({ kind: "word", value: source.slice(index, end).toUpperCase(), from: index, to: end });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (/[0-9]/.test(char)) {
|
||||
const end = readNumberEnd(source, index);
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (char === "#") {
|
||||
const end = readWordEnd(source, index + 1);
|
||||
tokens.push({ kind: "word", value: source.slice(index, end).toUpperCase(), from: index, to: end });
|
||||
index = end;
|
||||
continue;
|
||||
}
|
||||
if (/[ \t\r\n]/.test(char)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
tokens.push({ kind: "symbol", value: char, from: index, to: index + 1 });
|
||||
index += 1;
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function readWordEnd(source: string, start: number): number {
|
||||
let index = start;
|
||||
while (index < source.length && /[A-Za-z0-9_$#@]/.test(source[index])) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function readNumberEnd(source: string, start: number): number {
|
||||
let index = start;
|
||||
while (index < source.length && /[0-9A-Za-z_.]/.test(source[index])) index += 1;
|
||||
return index;
|
||||
}
|
||||
|
||||
function skipLineComment(source: string, start: number): number {
|
||||
const newline = source.indexOf("\n", start);
|
||||
return newline < 0 ? source.length : newline + 1;
|
||||
}
|
||||
|
||||
// T-SQL nests block comments, so the closing marker has to be matched by depth.
|
||||
function skipBlockComment(source: string, start: number): number {
|
||||
let depth = 1;
|
||||
let index = start + 2;
|
||||
while (index < source.length) {
|
||||
if (source.startsWith("/*", index)) {
|
||||
depth += 1;
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (source.startsWith("*/", index)) {
|
||||
depth -= 1;
|
||||
index += 2;
|
||||
if (depth === 0) return index;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function skipQuotedLiteral(source: string, start: number, quote: string): number {
|
||||
let index = start + 1;
|
||||
while (index < source.length) {
|
||||
if (source[index] !== quote) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (source[index + 1] === quote) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function skipBracketedIdentifier(source: string, start: number): number {
|
||||
let index = start + 1;
|
||||
while (index < source.length) {
|
||||
if (source[index] !== "]") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (source[index + 1] === "]") {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
return index + 1;
|
||||
}
|
||||
return source.length;
|
||||
}
|
||||
|
||||
function spanForOffsets(source: string, startOffset: number, endOffset: number): SqlTextSpan {
|
||||
const start = lineColumnAtOffset(source, startOffset);
|
||||
const end = lineColumnAtOffset(source, endOffset);
|
||||
return {
|
||||
start_line: start.line,
|
||||
start_column: start.column,
|
||||
end_line: end.line,
|
||||
end_column: Math.max(end.column - 1, start.column),
|
||||
};
|
||||
}
|
||||
|
||||
function lineColumnAtOffset(source: string, offset: number): { line: number; column: number } {
|
||||
const clamped = Math.max(0, Math.min(offset, source.length));
|
||||
let line = 1;
|
||||
let column = 1;
|
||||
for (let index = 0; index < clamped; index += 1) {
|
||||
if (source[index] === "\n") {
|
||||
line += 1;
|
||||
column = 1;
|
||||
continue;
|
||||
}
|
||||
column += 1;
|
||||
}
|
||||
return { line, column };
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
+71
-1
@@ -1,6 +1,6 @@
|
||||
import { strict as assert } from "node:assert";
|
||||
import { test } from "vitest";
|
||||
import { buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, areSqlSemanticDiagnosticsEqual, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport } from "../../apps/desktop/src/lib/sql/semantic/diagnostics.ts";
|
||||
import { buildSqlParserErrorDiagnostic, buildSqlSemanticDiagnostics, areSqlSemanticDiagnosticsEqual, isSqlSemanticDiagnosticInputContext, shouldRunSqlSemanticDiagnostics, sqlSemanticDiagnosticRangesForViewport, sqlServerRoutineDefinitionRangesForViewport } from "../../apps/desktop/src/lib/sql/semantic/diagnostics.ts";
|
||||
import { sqlReferenceAnalysisDialectFor } from "../../apps/desktop/src/lib/sql/semantic/dialect.ts";
|
||||
import type { SqlReferenceAnalysis } from "../../apps/desktop/src/types/database.ts";
|
||||
|
||||
@@ -620,3 +620,73 @@ test("uses executable soft statement ranges for viewport diagnostics", () => {
|
||||
["SELECT missing_field FROM second"],
|
||||
);
|
||||
});
|
||||
|
||||
const PROCEDURE_SQL = `ALTER PROCEDURE [COMMON].[TEST]
|
||||
@BeginTime varchar(100)=null
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
DECLARE @temp_nums = 2;
|
||||
END`;
|
||||
|
||||
test("recovers SQL Server routine batches for routine-only syntax rules", () => {
|
||||
const ranges = sqlServerRoutineDefinitionRangesForViewport(PROCEDURE_SQL, [{ from: 0, to: PROCEDURE_SQL.length }]);
|
||||
|
||||
assert.deepEqual(
|
||||
ranges.map((range) => ({ from: range.from, to: range.to })),
|
||||
[{ from: 0, to: PROCEDURE_SQL.length }],
|
||||
);
|
||||
assert.equal(ranges[0]?.sql, PROCEDURE_SQL);
|
||||
assert.deepEqual(sqlSemanticDiagnosticRangesForViewport(PROCEDURE_SQL, [{ from: 0, to: PROCEDURE_SQL.length }], "sqlserver"), []);
|
||||
});
|
||||
|
||||
test("keeps SQL Server routine batches separated by GO, but skips leading query batches", () => {
|
||||
const sql = `SELECT 1;
|
||||
GO
|
||||
CREATE OR ALTER PROCEDURE dbo.refresh_users AS
|
||||
BEGIN
|
||||
DECLARE @temp_nums = 2;
|
||||
END
|
||||
GO
|
||||
ALTER FUNCTION [COMMON].[uf_answer]()
|
||||
RETURNS int
|
||||
AS
|
||||
BEGIN
|
||||
RETURN 42;
|
||||
END`;
|
||||
|
||||
const ranges = sqlServerRoutineDefinitionRangesForViewport(sql, [{ from: 0, to: sql.length }]);
|
||||
|
||||
assert.deepEqual(
|
||||
ranges.map((range) => range.sql),
|
||||
[
|
||||
"CREATE OR ALTER PROCEDURE dbo.refresh_users AS\nBEGIN\n DECLARE @temp_nums = 2;\nEND\n",
|
||||
"ALTER FUNCTION [COMMON].[uf_answer]()\nRETURNS int\nAS\nBEGIN\n RETURN 42;\nEND",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("only reports SQL Server routine batches that intersect the viewport", () => {
|
||||
const sql = `CREATE PROCEDURE dbo.p_test AS
|
||||
BEGIN
|
||||
DECLARE @temp_nums = 2;
|
||||
END
|
||||
GO
|
||||
SELECT missing_field FROM dbo.users;`;
|
||||
|
||||
const visibleFrom = sql.indexOf("SELECT missing_field");
|
||||
const ranges = sqlServerRoutineDefinitionRangesForViewport(sql, [{ from: visibleFrom, to: sql.length }]);
|
||||
assert.deepEqual(ranges, []);
|
||||
|
||||
const procedureFrom = sql.indexOf("CREATE PROCEDURE");
|
||||
const visibleProcedure = sqlServerRoutineDefinitionRangesForViewport(sql, [{ from: procedureFrom, to: procedureFrom + 10 }]);
|
||||
assert.equal(visibleProcedure.length, 1);
|
||||
assert.equal(visibleProcedure[0]?.from, procedureFrom);
|
||||
});
|
||||
|
||||
test("ignores SQL Server non-routine batches and empty viewports", () => {
|
||||
const sql = "SELECT missing_field FROM dbo.users;\nGO\nUPDATE dbo.users SET name = 'x';";
|
||||
|
||||
assert.deepEqual(sqlServerRoutineDefinitionRangesForViewport(sql, [{ from: 0, to: sql.length }]), []);
|
||||
assert.deepEqual(sqlServerRoutineDefinitionRangesForViewport(PROCEDURE_SQL, []), []);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { buildSqlServerRoutineSyntaxDiagnostics, supportsSqlServerRoutineSyntaxDiagnostics, SQLSERVER_DECLARE_MISSING_DATA_TYPE_MESSAGE } from "../../apps/desktop/src/lib/sql/sqlServerRoutineSyntaxDiagnostics.ts";
|
||||
|
||||
function messages(sql: string, databaseType: Parameters<typeof buildSqlServerRoutineSyntaxDiagnostics>[1] = "sqlserver") {
|
||||
return buildSqlServerRoutineSyntaxDiagnostics(sql, databaseType).map((diagnostic) => diagnostic.message);
|
||||
}
|
||||
|
||||
function spanAt(sql: string, token: string, occurrence = 0) {
|
||||
const upperSql = sql.toUpperCase();
|
||||
const upperToken = token.toUpperCase();
|
||||
let offset = -1;
|
||||
let from = 0;
|
||||
for (let index = 0; index <= occurrence; index += 1) {
|
||||
offset = upperSql.indexOf(upperToken, from);
|
||||
from = offset + token.length;
|
||||
}
|
||||
expect(offset).not.toBe(-1);
|
||||
const lineStart = sql.lastIndexOf("\n", offset - 1) + 1;
|
||||
const line = sql.slice(0, offset).split(/\r?\n/).length;
|
||||
return { start_line: line, start_column: offset - lineStart + 1, end_line: line, end_column: offset - lineStart + token.length };
|
||||
}
|
||||
|
||||
test("reports a DECLARE default value that has no data type", () => {
|
||||
const sql = "ALTER PROCEDURE dbo.p_test @x INT AS\nBEGIN\n DECLARE @temp_nums = 2;\n SELECT @temp_nums AS v;\nEND";
|
||||
const diagnostics = buildSqlServerRoutineSyntaxDiagnostics(sql, "sqlserver");
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.message)).toEqual([SQLSERVER_DECLARE_MISSING_DATA_TYPE_MESSAGE]);
|
||||
expect(diagnostics[0]?.span).toEqual(spanAt(sql, "=", 0));
|
||||
expect(diagnostics[0]?.severity).toBe("error");
|
||||
});
|
||||
|
||||
test("reports every untyped declaration in a comma separated list", () => {
|
||||
const sql = "DECLARE @a INT = 1, @b = 2, @c VARCHAR(5) = 'x', @d = 4;";
|
||||
const diagnostics = buildSqlServerRoutineSyntaxDiagnostics(sql, "sqlserver");
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.span)).toEqual([spanAt(sql, "=", 1), spanAt(sql, "=", 3)]);
|
||||
});
|
||||
|
||||
test("tracks CRLF and mixed-case declarations", () => {
|
||||
const sql = "declare\r\n @first = 1,\r\n @second as = 2;";
|
||||
const diagnostics = buildSqlServerRoutineSyntaxDiagnostics(sql, "sqlserver");
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.span)).toEqual([spanAt(sql, "=", 0), spanAt(sql, "=", 1)]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
"DECLARE @a INT = 1;",
|
||||
"DECLARE @a AS INT = 1;",
|
||||
"DECLARE @a DECIMAL(10,2) = 1.5, @b VARCHAR(20) = 'x,y';",
|
||||
"DECLARE @a INT, @b VARCHAR(5) = 'z';",
|
||||
"DECLARE @a INT;",
|
||||
"DECLARE @a dbo.MyType = 1;",
|
||||
"DECLARE @t TABLE (id INT, name VARCHAR(10) DEFAULT 'a,b');",
|
||||
"DECLARE @t TABLE (id INT, CONSTRAINT pk PRIMARY KEY (id));",
|
||||
"DECLARE @c CURSOR;",
|
||||
"DECLARE cur CURSOR FOR SELECT id, name FROM dbo.t;",
|
||||
"DECLARE @c CURSOR FOR SELECT a, b FROM dbo.t;",
|
||||
"SELECT @a = 1; SET @b = 2; UPDATE dbo.t SET @c = 3 WHERE id = 1;",
|
||||
"EXEC dbo.p @a = 1, @b = 2;",
|
||||
"SELECT * FROM dbo.t WHERE a = 1, b = 2;",
|
||||
"-- DECLARE @a = 1\n/* DECLARE @b = 2 */",
|
||||
"PRINT 'DECLARE @a = 1';",
|
||||
"EXEC sp_executesql N'DECLARE @a = 1';",
|
||||
"CREATE PROCEDURE dbo.p @a INT = 1 AS BEGIN SELECT @a; END",
|
||||
"CREATE FUNCTION dbo.f(@a INT = 1) RETURNS INT AS BEGIN RETURN @a; END",
|
||||
"DECLARE @a INT = (SELECT TOP 1 id FROM dbo.t);",
|
||||
])("accepts the valid T-SQL declaration %s", (sql) => {
|
||||
expect(messages(sql, "sqlserver")).toEqual([]);
|
||||
});
|
||||
|
||||
test("stops at the statement that follows an unterminated declaration list", () => {
|
||||
const sql = "DECLARE @a INT\nSELECT @b = 1\nDECLARE @c = 2";
|
||||
const diagnostics = buildSqlServerRoutineSyntaxDiagnostics(sql, "sqlserver");
|
||||
|
||||
expect(diagnostics.map((diagnostic) => diagnostic.span)).toEqual([spanAt(sql, "=", 1)]);
|
||||
});
|
||||
|
||||
test("stays silent for other dialects", () => {
|
||||
const sql = "DECLARE @a = 1;";
|
||||
expect(messages(sql, "mysql")).toEqual([]);
|
||||
expect(messages(sql, "oracle")).toEqual([]);
|
||||
expect(buildSqlServerRoutineSyntaxDiagnostics(sql, undefined)).toEqual([]);
|
||||
});
|
||||
|
||||
test("only supports SQL Server", () => {
|
||||
expect(supportsSqlServerRoutineSyntaxDiagnostics("sqlserver")).toBe(true);
|
||||
expect(supportsSqlServerRoutineSyntaxDiagnostics("mysql")).toBe(false);
|
||||
expect(supportsSqlServerRoutineSyntaxDiagnostics(undefined)).toBe(false);
|
||||
});
|
||||
Reference in New Issue
Block a user