fix(editor): bound SQL completion to active statement
This commit is contained in:
1 parent
8271ba2a71
commit
41ffc969a1
12 files changed
+917
-210
No files matched your search
@@ -0,0 +1,75 @@
|
||||
import * as langSql from "@codemirror/lang-sql";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { isSqlCompletionSuppressedContext, isSqlLikeCompletionStatement } from "@/lib/sql/sqlCompletion";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
|
||||
// Forces CM6's background parse to completion so tests are deterministic. Production code never
|
||||
// does this -- see sqlSyntaxTreeWindow.ts's doc comment.
|
||||
function stateFor(doc: string): EditorState {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, "postgres", "postgres") })],
|
||||
});
|
||||
ensureSyntaxTree(state, doc.length, 5_000);
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("isSqlCompletionSuppressedContext with a live EditorState", () => {
|
||||
it("suppresses completion inside the maintainer's round-4 counterexample (unterminated string with 120,000 repeated ';') where the pure-string fallback gets the boundary wrong", () => {
|
||||
const sql = `SELECT 'unterminated ${";".repeat(120_000)}`;
|
||||
const cursor = sql.length - 5;
|
||||
const state = stateFor(sql);
|
||||
|
||||
// Pure-string call (no live EditorState): exercises the documented bounded fallback, which
|
||||
// is the path affected by the still-open, disclosed heuristic limitation.
|
||||
const withoutTree = isSqlCompletionSuppressedContext(sql, cursor, { databaseType: "postgres" });
|
||||
// With the live EditorState: resolved via the syntax tree, which correctly sees this whole
|
||||
// 50,000-character run as one unterminated string literal.
|
||||
const withTree = isSqlCompletionSuppressedContext(sql, cursor, { databaseType: "postgres", editorState: state });
|
||||
|
||||
expect(withTree).toBe(true);
|
||||
// Documents the contrast, not a requirement: the pure-string fallback (no live EditorState)
|
||||
// still exhibits the maintainer's exact round-4 counterexample, because it's unchanged --
|
||||
// see insertValueHints.ts's doc comment and the reframed test in insertValueHints.test.ts. A
|
||||
// real editor session always has a live EditorState, so `withTree` (asserted above) is what
|
||||
// actually reaches a user typing in the editor.
|
||||
expect(withoutTree).toBe(false);
|
||||
});
|
||||
|
||||
it("does not suppress completion on real code immediately before the unterminated string opens", () => {
|
||||
const sql = `SELECT id, ${";".repeat(50)}'unterminated forever`;
|
||||
const cursor = sql.indexOf("id") + 1;
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(isSqlCompletionSuppressedContext(sql, cursor, { databaseType: "postgres", editorState: state })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSqlLikeCompletionStatement with a live EditorState", () => {
|
||||
it("resolves the active statement via the syntax tree for a cursor deep inside a large document", () => {
|
||||
const filler = Array.from({ length: 2_000 }, (_, index) => `-- comment line ${index}\n`).join("");
|
||||
const sql = `${filler}SELECT id FROM users WHERE `;
|
||||
const cursor = sql.length;
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(isSqlLikeCompletionStatement(sql, cursor, { databaseType: "postgres", editorState: state })).toBe(true);
|
||||
});
|
||||
|
||||
it("builds the completion semantic model from only the active statement", () => {
|
||||
const filler = Array.from({ length: 4_000 }, (_, index) => `SELECT ${index};\n`).join("");
|
||||
const activeStatement = "SELECT u. FROM users u";
|
||||
const sql = filler + activeStatement;
|
||||
const cursor = sql.indexOf("u. FROM", filler.length) + 2;
|
||||
const state = stateFor(sql);
|
||||
|
||||
const model = buildSqlSemanticModel(sql, cursor, { databaseType: "postgres", editorState: state });
|
||||
|
||||
expect(model.statement.text).toBe(activeStatement);
|
||||
expect(model.rowSources).toEqual([expect.objectContaining({ name: "users", alias: "u" })]);
|
||||
expect(model.tokens.length).toBeLessThan(10);
|
||||
expect(model.tokens.every((token) => token.span.start >= filler.length)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import * as langSql from "@codemirror/lang-sql";
|
||||
import { ensureSyntaxTree } from "@codemirror/language";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
|
||||
import { resolveLexicalLeafFromSyntaxTree, resolveStatementWindowFromSyntaxTree } from "@/lib/sql/sqlSyntaxTreeWindow";
|
||||
|
||||
// Forces CM6's background parse to completion so tests are deterministic instead of racing the
|
||||
// idle-time parse scheduler. Production code never does this (see sqlSyntaxTreeWindow.ts's doc
|
||||
// comment on why it only ever consults syntaxTree/syntaxTreeAvailable, never ensureSyntaxTree).
|
||||
function stateFor(doc: string, dialectName: "mysql" | "postgres" | "sqlserver" = "postgres", databaseType?: string): EditorState {
|
||||
const state = EditorState.create({
|
||||
doc,
|
||||
extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName, databaseType as never) })],
|
||||
});
|
||||
ensureSyntaxTree(state, doc.length, 5_000);
|
||||
return state;
|
||||
}
|
||||
|
||||
describe("resolveStatementWindowFromSyntaxTree", () => {
|
||||
it("finds the true statement start (0) for an unterminated string with 120,000 repeated ';' -- the maintainer's exact round-4 counterexample, at a scale that reproduces it", () => {
|
||||
const sql = `SELECT 'unterminated ${";".repeat(120_000)}`;
|
||||
const cursor = sql.length - 5;
|
||||
const state = stateFor(sql);
|
||||
|
||||
const window = resolveStatementWindowFromSyntaxTree(state, cursor);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(window?.from).toBe(0);
|
||||
expect(window?.to).toBe(sql.length);
|
||||
});
|
||||
|
||||
it("resolves a normal multi-statement document and trims the trailing ';'", () => {
|
||||
const sql = "select id from users; select id from orders;";
|
||||
const cursor = sql.indexOf("orders");
|
||||
const state = stateFor(sql);
|
||||
|
||||
const window = resolveStatementWindowFromSyntaxTree(state, cursor);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(sql.slice(window!.from, window!.to)).toBe("select id from orders");
|
||||
});
|
||||
|
||||
it("resolves the statement when the cursor sits in trailing whitespace after the last token", () => {
|
||||
const sql = "select id from users; \n ";
|
||||
const cursor = sql.length - 2;
|
||||
const state = stateFor(sql);
|
||||
|
||||
const window = resolveStatementWindowFromSyntaxTree(state, cursor);
|
||||
|
||||
expect(window).not.toBeNull();
|
||||
expect(sql.slice(window!.from, window!.to)).toBe("select id from users");
|
||||
});
|
||||
|
||||
it("returns null (safe fallback) when a nearby dollar-quote tag makes the tree's boundary untrustworthy", () => {
|
||||
// This app disables doubleDollarQuotedStrings (issue #788), so the tree parses each ';'
|
||||
// inside the $$ body as a real statement boundary instead of treating the body as one unit.
|
||||
const body = Array.from({ length: 400 }, (_, index) => `SELECT ${index};`).join(" ");
|
||||
const sql = `CREATE FUNCTION f() RETURNS void AS $$ ${body} $$ LANGUAGE sql;`;
|
||||
const cursor = sql.indexOf(body) + 2000;
|
||||
const state = stateFor(sql);
|
||||
|
||||
const window = resolveStatementWindowFromSyntaxTree(state, cursor);
|
||||
|
||||
expect(window).toBeNull();
|
||||
});
|
||||
|
||||
it("requires the exact tagged delimiter before trusting a statement boundary", () => {
|
||||
const sql = `CREATE FUNCTION f() RETURNS void AS $outer$ SELECT '$inner$'; SELECT 2; $outer$ LANGUAGE sql;`;
|
||||
const cursor = sql.indexOf("SELECT 2") + 3;
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveStatementWindowFromSyntaxTree(state, cursor)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveLexicalLeafFromSyntaxTree", () => {
|
||||
it("classifies a position inside a single-quoted string as a string literal", () => {
|
||||
const sql = "select * from t where name = 'hello world'";
|
||||
const cursor = sql.indexOf("hello");
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveLexicalLeafFromSyntaxTree(state, cursor)).toEqual({
|
||||
inLineComment: false,
|
||||
inBlockComment: false,
|
||||
inStringLiteral: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a position inside an unterminated string spanning the rest of the document as a string literal", () => {
|
||||
const sql = `select 'unterminated ${";".repeat(40_000)}`;
|
||||
const cursor = sql.length - 3;
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveLexicalLeafFromSyntaxTree(state, cursor)?.inStringLiteral).toBe(true);
|
||||
});
|
||||
|
||||
it("classifies a position inside an unterminated block comment as a comment", () => {
|
||||
const sql = `select 1;\n/* unterminated ${"x".repeat(40_000)}`;
|
||||
const cursor = sql.length - 3;
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveLexicalLeafFromSyntaxTree(state, cursor)).toEqual({
|
||||
inLineComment: false,
|
||||
inBlockComment: true,
|
||||
inStringLiteral: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("classifies a position inside a line comment as a comment", () => {
|
||||
const sql = "select 1; -- trailing comment\nselect 2;";
|
||||
const cursor = sql.indexOf("trailing");
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveLexicalLeafFromSyntaxTree(state, cursor)).toEqual({
|
||||
inLineComment: true,
|
||||
inBlockComment: false,
|
||||
inStringLiteral: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat a double-quoted or backtick-quoted identifier as a string literal", () => {
|
||||
const doubleQuoted = 'select "user_name" from users';
|
||||
const doubleQuotedState = stateFor(doubleQuoted);
|
||||
expect(resolveLexicalLeafFromSyntaxTree(doubleQuotedState, doubleQuoted.indexOf("user_name"))?.inStringLiteral).toBe(false);
|
||||
|
||||
const backtickQuoted = "select `user_name` from users";
|
||||
const backtickState = stateFor(backtickQuoted, "mysql", "mysql");
|
||||
expect(resolveLexicalLeafFromSyntaxTree(backtickState, backtickQuoted.indexOf("user_name"))?.inStringLiteral).toBe(false);
|
||||
});
|
||||
|
||||
it("classifies plain code as neither a comment nor a string literal", () => {
|
||||
const sql = "select id from users";
|
||||
const cursor = sql.indexOf("id");
|
||||
const state = stateFor(sql);
|
||||
|
||||
expect(resolveLexicalLeafFromSyntaxTree(state, cursor)).toEqual({
|
||||
inLineComment: false,
|
||||
inBlockComment: false,
|
||||
inStringLiteral: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user