fix(editor): improve ASE SQL semantic highlighting

This commit is contained in:
zipg authored and GitHub committed 2026-07-16 17:00:55 +08:00
1 parent df1d31e74f
commit 3d23a58e36
14 files changed
+360 -22

No files matched your search

+75 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import { sqlSemanticCompletionScope, sqlSemanticLocalColumnsByTable, sqlSemanticProjectionAliasColumns } from "@/lib/sql/semantic/completion";
import { SQL_SEMANTIC_BASELINE_FIXTURES, sqlFixtureCursor } from "@/lib/sql/semantic/fixtures";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
import { buildSqlSemanticModel, sqlSemanticTableNameSpans } from "@/lib/sql/semantic/model";
describe("sqlSemanticModel baseline fixtures", () => {
for (const fixture of SQL_SEMANTIC_BASELINE_FIXTURES) {
@@ -255,4 +255,78 @@ describe("sqlSemanticModel baseline fixtures", () => {
expect(sqlSemanticCompletionScope(buildSqlSemanticModel(sqlite.sql, sqlite.cursor, { databaseType: "sqlite" })).useRemoteMetadata).toBe(true);
expect(buildSqlSemanticModel(duckdb.sql, duckdb.cursor, { databaseType: "duckdb" }).rowSources[0]).toEqual(expect.objectContaining({ kind: "table_function", name: "csv", alias: "csv" }));
});
it("returns only concrete table-name spans for semantic highlighting", () => {
const sql = "SELECT customer_id FROM dbo.wfAdmin AS wa WHERE wa.customer_id > 0";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["wfAdmin"]);
});
it("finds table names across statements, subqueries, and comma table lists", () => {
const sql = "SELECT * FROM users u, orders o; SELECT * FROM (SELECT * FROM audit_log) a JOIN dbo.events e ON e.id = a.id";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users", "orders", "audit_log", "events"]);
});
it("highlights mutation targets but ignores strings, comments, and table functions", () => {
const sql = "UPDATE users SET name = 'FROM fake'; INSERT INTO audit_log(id) VALUES (1); -- FROM ignored\nSELECT * FROM read_csv('events.csv') e";
const spans = sqlSemanticTableNameSpans(sql);
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users", "audit_log"]);
});
it("skips ASE maintenance keywords before update table targets", () => {
const statements = [
{ sql: "UPDATE STATISTICS wfAdmin", table: "wfAdmin" },
{ sql: "UPDATE INDEX STATISTICS wfAdmin ix_name", table: "wfAdmin" },
{ sql: "UPDATE TABLE STATISTICS dbo.wfAdmin", table: "wfAdmin" },
{ sql: "UPDATE ALL STATISTICS [dbo].[wfAdmin]", table: "wfAdmin" },
];
for (const { sql, table } of statements) {
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
const model = buildSqlSemanticModel(sql, sql.length, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual([sql.includes("[wfAdmin]") ? "[wfAdmin]" : table]);
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: table, kind: "mutation_target" })]));
expect(model.rowSources.some((source) => ["ALL", "INDEX", "TABLE", "STATISTICS"].includes(source.name.toUpperCase()))).toBe(false);
}
});
it("does not treat MERGE branch UPDATE as a table introducer", () => {
const sql = "MERGE INTO target_table t USING source_table s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name;";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["target_table", "source_table"]);
expect(model.rowSources.some((source) => source.name.toLowerCase() === "set")).toBe(false);
});
it("does not treat MySQL upsert UPDATE as a table introducer", () => {
const sql = "INSERT INTO users (id, name) VALUES (1, 'A') ON DUPLICATE KEY UPDATE name = VALUES(name);";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "mysql" });
const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "mysql" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["users"]);
expect(model.rowSources.some((source) => source.name === "name")).toBe(false);
});
it("keeps CTE UPDATE mutation targets", () => {
const sql = "WITH candidates AS (SELECT id FROM staging) UPDATE users SET active = 1 WHERE id IN (SELECT id FROM candidates);";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
const model = buildSqlSemanticModel(sql, sql.length - 1, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["staging", "users", "candidates"]);
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "users", kind: "mutation_target" })]));
});
it("recognizes SQL Server local, global, and tempdb-qualified temporary tables", () => {
const sql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp";
const spans = sqlSemanticTableNameSpans(sql, { dialect: "sqlserver" });
const model = buildSqlSemanticModel(sql, sql.length, { dialect: "sqlserver" });
expect(spans.map((span) => sql.slice(span.start, span.end))).toEqual(["#temp", "##global_temp", "#temp"]);
expect(model.rowSources).toEqual(expect.arrayContaining([expect.objectContaining({ name: "#temp", kind: "table" })]));
});
});
@@ -21,6 +21,15 @@ describe("sqlSemanticTokens", () => {
expect(isSuppressedSqlSemanticContext(tokens, "select * from users".length)).toBe(false);
});
it("treats hash prefixes as identifiers for SQL Server but comments for MySQL", () => {
const sqlServerSql = "SELECT * FROM #temp; SELECT * FROM ##global_temp; SELECT * FROM tempdb..#temp";
const sqlServerTokens = tokenizeSqlSemantic(sqlServerSql, "sqlserver");
expect(sqlServerTokens.filter((token) => token.kind === "word" && token.text.startsWith("#")).map((token) => token.text)).toEqual(["#temp", "##global_temp", "#temp"]);
expect(sqlServerTokens.some((token) => token.kind === "comment")).toBe(false);
expect(tokenizeSqlSemantic("SELECT 1 # comment", "mysql").some((token) => token.kind === "comment" && token.text === "# comment")).toBe(true);
});
it("finds active statement spans across semicolon-separated scripts", () => {
const sql = "select * from users;\nselect * from orders where id = 1;";
const cursor = sql.indexOf("orders");