fix(editor): fold Elasticsearch REST requests individually

Closes #8065
This commit is contained in:
t8y2
2026-09-04 00:44:10 +08:00
parent 81587313eb
commit fe955da729
2 changed files with 100 additions and 12 deletions
@@ -3,12 +3,12 @@ import { ensureSyntaxTree, foldable } from "@codemirror/language";
import { Compartment, EditorState } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import { createDbxCodeMirrorSqlDialect } from "@/lib/editor/codemirrorSqlDialect";
import { collectUnionBranchFoldRanges, sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
import { collectUnionBranchFoldRanges, createSqlBlockFoldService, sqlBlockFoldService } from "@/lib/editor/codemirrorSqlBlockFolding";
function stateFor(doc: string, dialectName: "mysql" | "postgres" | "sqlserver" = "mysql"): EditorState {
function stateFor(doc: string, dialectName: "mysql" | "postgres" | "sqlserver" = "mysql", folding = sqlBlockFoldService): EditorState {
const state = EditorState.create({
doc,
extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName) }), sqlBlockFoldService],
extensions: [langSql.sql({ dialect: createDbxCodeMirrorSqlDialect(langSql, dialectName) }), folding],
});
ensureSyntaxTree(state, doc.length, 5_000);
return state;
@@ -21,6 +21,65 @@ function foldedTextAtLine(state: EditorState, lineNumber: number): string | null
}
describe("sqlBlockFoldService", () => {
it.each(["elasticsearch", "easysearch", "meilisearch"] as const)("folds one %s REST request without a semicolon", (databaseType) => {
const sql = `POST /orders/_search
{
"query": { "match_all": {} }
}`;
const state = stateFor(sql, "mysql", createSqlBlockFoldService(databaseType));
expect(sql).not.toContain(";");
expect(foldedTextAtLine(state, 1)).toBe('\n{\n "query": { "match_all": {} }\n}');
});
it("folds multiple REST requests independently", () => {
const sql = `POST /orders/_search
{
"query": { "match_all": {} }
}
GET /orders/_count
{
"query": { "term": { "status": "paid" } }
}`;
const state = stateFor(sql);
expect(sql).not.toContain(";");
expect(foldedTextAtLine(state, 1)).toBe('\n{\n "query": { "match_all": {} }\n}');
expect(foldedTextAtLine(state, 6)).toBe('\n{\n "query": { "term": { "status": "paid" } }\n}');
});
it("keeps REST request comments and blank lines outside individual folds", () => {
const sql = `# first request
POST /orders/_search
{
"query": { "match_all": {} }
}
// second request
POST /orders/_search
{
"query": { "term": { "status": "paid" } }
}`;
const state = stateFor(sql);
expect(foldedTextAtLine(state, 2)).toBe('\n{\n "query": { "match_all": {} }\n}');
expect(foldedTextAtLine(state, 8)).toBe('\n{\n "query": { "term": { "status": "paid" } }\n}');
});
it("keeps ordinary SQL block and CASE folding unchanged", () => {
const sql = `BEGIN
SELECT CASE
WHEN status = 'paid' THEN 1
ELSE 0
END;
END`;
const state = stateFor(sql);
expect(foldedTextAtLine(state, 1)).toContain("SELECT CASE");
expect(foldedTextAtLine(state, 2)).toBe("\n WHEN status = 'paid' THEN 1\n ELSE 0\n ");
});
it("folds a BEGIN...END block, from the end of the BEGIN line to the start of END", () => {
const sql = "CREATE PROCEDURE p()\nBEGIN\n SELECT 1;\nEND";
const state = stateFor(sql);
@@ -1,5 +1,7 @@
import { foldService, syntaxTree } from "@codemirror/language";
import type { EditorState } from "@codemirror/state";
import { elasticsearchRestRequestRanges } from "@/lib/sql/sqlStatementRanges";
import type { DatabaseType } from "@/types/database";
// `@lezer/common` is only a transitive dependency here (see sqlSyntaxTreeWindow.ts's comment on
// the same pattern), so derive the node types structurally instead of importing them.
@@ -62,7 +64,7 @@ function isSqlServerTransactionBegin(state: EditorState, tokens: SyntaxNode[], i
// watcher), and when Lezer's incremental parser finishes covering more of a large document in the
// background -- both produce a new `Tree` without a new `Text`, so keying on `Text` alone would
// keep serving ranges computed from a stale or partial parse.
const rangeCache = new WeakMap<Tree, Map<number, FoldRange>>();
const rangeCache = new WeakMap<Tree, Map<string, Map<number, FoldRange>>>();
function addMultilineFoldRange(state: EditorState, ranges: Map<number, FoldRange>, openerPosition: number, endPosition: number, overwrite = false) {
const openerLine = state.doc.lineAt(openerPosition);
@@ -154,12 +156,32 @@ function addQueryStructureFoldRanges(state: EditorState, tree: Tree, ranges: Map
}
}
function computeBlockFoldRanges(state: EditorState): Map<number, FoldRange> {
function addRestRequestFoldRanges(state: EditorState, ranges: Map<number, FoldRange>, databaseType?: DatabaseType): boolean {
const requests = elasticsearchRestRequestRanges(state.doc.toString(), databaseType ?? "elasticsearch");
if (requests.length === 0) return false;
for (const request of requests) {
addMultilineFoldRange(state, ranges, request.from, request.to);
}
return true;
}
function computeBlockFoldRanges(state: EditorState, databaseType?: DatabaseType): Map<number, FoldRange> {
const tree = syntaxTree(state);
const cached = rangeCache.get(tree);
const cacheByDatabaseType = rangeCache.get(tree);
const cacheKey = databaseType ?? "auto-detect";
const cached = cacheByDatabaseType?.get(cacheKey);
if (cached) return cached;
const byOpenerLine = new Map<number, FoldRange>();
const isRestDocument = addRestRequestFoldRanges(state, byOpenerLine, databaseType);
if (isRestDocument) {
const nextCacheByDatabaseType = cacheByDatabaseType ?? new Map<string, Map<number, FoldRange>>();
nextCacheByDatabaseType.set(cacheKey, byOpenerLine);
rangeCache.set(tree, nextCacheByDatabaseType);
return byOpenerLine;
}
const tokens: SyntaxNode[] = [];
tree.iterate({
enter(node) {
@@ -197,12 +219,19 @@ function computeBlockFoldRanges(state: EditorState): Map<number, FoldRange> {
addQueryStructureFoldRanges(state, tree, byOpenerLine);
rangeCache.set(tree, byOpenerLine);
const nextCacheByDatabaseType = cacheByDatabaseType ?? new Map<string, Map<number, FoldRange>>();
nextCacheByDatabaseType.set(cacheKey, byOpenerLine);
rangeCache.set(tree, nextCacheByDatabaseType);
return byOpenerLine;
}
/** Adds procedural-block, query-parenthesis, and UNION-branch folds to the SQL editor. */
export const sqlBlockFoldService = foldService.of((state, lineStart) => {
const range = computeBlockFoldRanges(state).get(state.doc.lineAt(lineStart).number);
return range ?? null;
});
/** Creates folding for REST requests, procedural blocks, query parentheses, and UNION branches. */
export function createSqlBlockFoldService(databaseType?: DatabaseType) {
return foldService.of((state, lineStart) => {
const range = computeBlockFoldRanges(state, databaseType).get(state.doc.lineAt(lineStart).number);
return range ?? null;
});
}
/** Public compatibility extension with REST request auto-detection for text editors. */
export const sqlBlockFoldService = createSqlBlockFoldService();