From 28a749d7b14ea4bdb8b0973adeaf1d78e335ac16 Mon Sep 17 00:00:00 2001 From: "Visal .In" Date: Thu, 12 Sep 2024 18:25:58 +0700 Subject: [PATCH] feat: add experiment statement gutter highlight (#155) * add experiment statement gutter highlight * initial highlight code * change how we select statement --- package-lock.json | 9 - package.json | 1 - src/components/gui/sql-editor/index.tsx | 8 + .../sql-editor/statement-highlight.test.ts | 155 ++++++++++++++++ .../gui/sql-editor/statement-highlight.ts | 172 ++++++++++++++++++ src/components/gui/tabs/query-tab.tsx | 30 +-- src/drivers/sqlite/sql-helper.test.ts | 28 --- src/drivers/sqlite/sql-helper.ts | 11 -- 8 files changed, 352 insertions(+), 62 deletions(-) create mode 100644 src/components/gui/sql-editor/statement-highlight.test.ts create mode 100644 src/components/gui/sql-editor/statement-highlight.ts diff --git a/package-lock.json b/package-lock.json index 767a399..a342ec0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -75,7 +75,6 @@ "react-resizable-panels": "^1.0.9", "sonner": "^1.4.41", "sql-formatter": "^15.3.2", - "sql-query-identifier": "^2.6.0", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" @@ -21931,14 +21930,6 @@ "sql-formatter": "bin/sql-formatter-cli.cjs" } }, - "node_modules/sql-query-identifier": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/sql-query-identifier/-/sql-query-identifier-2.7.0.tgz", - "integrity": "sha512-MTDRfn0kUv+9ELnt9wt/FATi03Wq1j3YvhE5Up8ToE5Afk5zzBgVHPuIu1bVhqNqfF0aeIPl3vEzZ60H1yv0ag==", - "engines": { - "node": ">= 10.13" - } - }, "node_modules/sql.js": { "version": "1.10.3", "resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.10.3.tgz", diff --git a/package.json b/package.json index fa5b8e6..a035b1e 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,6 @@ "react-resizable-panels": "^1.0.9", "sonner": "^1.4.41", "sql-formatter": "^15.3.2", - "sql-query-identifier": "^2.6.0", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", "zod": "^3.22.4" diff --git a/src/components/gui/sql-editor/index.tsx b/src/components/gui/sql-editor/index.tsx index dddac6d..bcd2340 100644 --- a/src/components/gui/sql-editor/index.tsx +++ b/src/components/gui/sql-editor/index.tsx @@ -21,6 +21,7 @@ import { sqliteDialect } from "@/drivers/sqlite/sqlite-dialect"; import { functionTooltip } from "./function-tooltips"; import sqliteFunctionList from "@/drivers/sqlite/function-tooltip.json"; import { toast } from "sonner"; +import SqlStatementHighlightPlugin from "./statement-highlight"; import { SupportedDialect } from "@/drivers/base-driver"; interface SqlEditorProps { @@ -142,10 +143,17 @@ const SqlEditor = forwardRef( } return [ + EditorView.baseTheme({ + "& .cm-line": { + borderLeft: "3px solid transparent", + paddingLeft: "10px", + }, + }), keyExtensions, sqlDialect, tooltipExtension, tableNameHighlightPlugin, + SqlStatementHighlightPlugin, EditorView.updateListener.of((state) => { const pos = state.state.selection.main.head; const line = state.state.doc.lineAt(pos); diff --git a/src/components/gui/sql-editor/statement-highlight.test.ts b/src/components/gui/sql-editor/statement-highlight.test.ts new file mode 100644 index 0000000..324202a --- /dev/null +++ b/src/components/gui/sql-editor/statement-highlight.test.ts @@ -0,0 +1,155 @@ +import { MySQL, SQLite } from "@codemirror/lang-sql"; +import { EditorState } from "@codemirror/state"; +import { splitSqlQuery } from "./statement-highlight"; + +function sqlite(code: string) { + const state = EditorState.create({ doc: code, extensions: [SQLite] }); + return splitSqlQuery(state).map((p) => p.text); +} + +function mysql(code: string) { + const state = EditorState.create({ doc: code, extensions: [MySQL] }); + return splitSqlQuery(state).map((p) => p.text); +} + +describe("split sql statements", () => { + test("should parse a query with different statements in a single line", () => { + expect( + sqlite( + `INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');SELECT * FROM Persons` + ) + ).toEqual([ + `INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');`, + `SELECT * FROM Persons`, + ]); + }); + + test("should identify a query with different statements in multiple lines", () => { + expect( + sqlite(` + INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack'); + SELECT * FROM Persons'; + `) + ).toEqual([ + `INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');`, + `SELECT * FROM Persons';\n `, + ]); + }); + + test("sholud be able to split statement with BEGIN and END", () => { + expect( + sqlite(`CREATE TABLE customer( + cust_id INTEGER PRIMARY KEY, + cust_name TEXT, + cust_addr TEXT +); + +-- some comment here that should be ignore + + +CREATE VIEW customer_address AS + SELECT cust_id, cust_addr FROM customer; +CREATE TRIGGER cust_addr_chng +INSTEAD OF UPDATE OF cust_addr ON customer_address +BEGIN + UPDATE customer SET cust_addr=NEW.cust_addr + WHERE cust_id=NEW.cust_id; +END ;`) + ).toEqual([ + `CREATE TABLE customer(\n cust_id INTEGER PRIMARY KEY,\n cust_name TEXT,\n cust_addr TEXT\n);`, + `CREATE VIEW customer_address AS\n SELECT cust_id, cust_addr FROM customer;`, + `CREATE TRIGGER cust_addr_chng\nINSTEAD OF UPDATE OF cust_addr ON customer_address\nBEGIN\n UPDATE customer SET cust_addr=NEW.cust_addr\n WHERE cust_id=NEW.cust_id;\nEND ;`, + ]); + }); + + test("should be able to split statement with BEGIN and END and CONDITION inside", () => { + expect( + mysql(`CREATE TRIGGER upd_check BEFORE UPDATE ON account +FOR EACH ROW +BEGIN + IF NEW.amount < 0 THEN + SET NEW.amount = 0; + ELSEIF NEW.amount > 100 THEN + SET NEW.amount = 100; + END IF; +END; SELECT * FROM hello`) + ).toEqual([ + `CREATE TRIGGER upd_check BEFORE UPDATE ON account\nFOR EACH ROW\nBEGIN\n IF NEW.amount < 0 THEN\n SET NEW.amount = 0;\n ELSEIF NEW.amount > 100 THEN\n SET NEW.amount = 100;\n END IF;\nEND;`, + "SELECT * FROM hello", + ]); + }); + + test("should be able to split statement with BEGIN with no end", () => { + expect( + mysql(`SELECT * FROM outerbase; CREATE TRIGGER upd_check BEFORE UPDATE ON account +FOR EACH ROW +BEGIN + IF NEW.amount < 0 THEN + SET NEW.amount = 0; + ELSEIF NEW.amount > 100 THEN + SET NEW.amount = 100;`) + ).toEqual([ + "SELECT * FROM outerbase;", + `CREATE TRIGGER upd_check BEFORE UPDATE ON account +FOR EACH ROW +BEGIN + IF NEW.amount < 0 THEN + SET NEW.amount = 0; + ELSEIF NEW.amount > 100 THEN + SET NEW.amount = 100;`, + ]); + }); + + test("should be able to split TRIGGER without begin", () => { + expect( + mysql(`create trigger hire_log after insert on employees +for each row insert into hiring values (new.id, current_time()); + +insert into employees (first_name, last_name) values ("Tim", "Sehn");`) + ).toEqual([ + `create trigger hire_log after insert on employees \nfor each row insert into hiring values (new.id, current_time());`, + `insert into employees (first_name, last_name) values ("Tim", "Sehn");`, + ]); + }); + + test("should be able to split nested BEGIN", () => { + expect( + mysql( + `CREATE PROCEDURE procCreateCarTable +IS +BEGIN + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE CARS'; + EXCEPTION WHEN OTHERS THEN NULL; + EXECUTE IMMEDIATE 'CREATE TABLE CARS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE + VARCHAR2(10))'; + END; + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE TRUCKS'; + EXCEPTION WHEN OTHERS THEN NULL; + EXECUTE IMMEDIATE 'CREATE TABLE TRUCKS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE + VARCHAR2(10))'; + END; +END; SELECT * FROM outeerbase;` + ) + ).toEqual([ + `CREATE PROCEDURE procCreateCarTable +IS +BEGIN + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE CARS'; + EXCEPTION WHEN OTHERS THEN NULL; + EXECUTE IMMEDIATE 'CREATE TABLE CARS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE + VARCHAR2(10))'; + END; + BEGIN + EXECUTE IMMEDIATE 'DROP TABLE TRUCKS'; + EXCEPTION WHEN OTHERS THEN NULL; + EXECUTE IMMEDIATE 'CREATE TABLE TRUCKS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE + VARCHAR2(10))'; + END; +END;`, + "SELECT * FROM outeerbase;", + ]); + }); +}); diff --git a/src/components/gui/sql-editor/statement-highlight.ts b/src/components/gui/sql-editor/statement-highlight.ts new file mode 100644 index 0000000..49cfb85 --- /dev/null +++ b/src/components/gui/sql-editor/statement-highlight.ts @@ -0,0 +1,172 @@ +import { + Decoration, + EditorState, + EditorView, + StateField, + Range, +} from "@uiw/react-codemirror"; +import { syntaxTree } from "@codemirror/language"; +import { SyntaxNode } from "@lezer/common"; + +const statementLineHighlight = Decoration.line({ + class: "cm-highlight-statement", +}); + +export interface StatementSegment { + from: number; + to: number; + text: string; +} + +function toNodeString(state: EditorState, node: SyntaxNode) { + return state.doc.sliceString(node.from, node.to); +} + +function isRequireEndStatement(state: EditorState, node: SyntaxNode): number { + const ptr = node.firstChild; + if (!ptr) return 0; + + // Majority of the query will fall in SELECT, INSERT, UPDATE, DELETE + const firstKeyword = toNodeString(state, ptr).toLowerCase(); + if (firstKeyword === "select") return 0; + if (firstKeyword === "insert") return 0; + if (firstKeyword === "update") return 0; + if (firstKeyword === "delete") return 0; + + const keywords = node.getChildren("Keyword"); + if (keywords.length === 0) return 0; + + return keywords.filter( + (k) => toNodeString(state, k).toLowerCase() === "begin" + ).length; +} + +function isEndStatement(state: EditorState, node: SyntaxNode) { + let ptr = node.firstChild; + if (!ptr) return false; + if (toNodeString(state, ptr).toLowerCase() !== "end") return false; + + ptr = ptr.nextSibling; + if (!ptr) return false; + if (toNodeString(state, ptr) !== ";") return false; + + return true; +} + +export function splitSqlQuery( + state: EditorState, + generateText: boolean = true +): StatementSegment[] { + const topNode = syntaxTree(state).topNode; + + // Get all the statements + let needEndStatementCounter = 0; + const statements = topNode.getChildren("Statement"); + + if (statements.length === 0) return []; + + const statementGroups: SyntaxNode[][] = []; + let accumulateNodes: SyntaxNode[] = []; + let i = 0; + + for (; i < statements.length; i++) { + const statement = statements[i]; + needEndStatementCounter += isRequireEndStatement(state, statement); + + if (needEndStatementCounter) { + accumulateNodes.push(statement); + } else { + statementGroups.push([statement]); + } + + if (needEndStatementCounter && isEndStatement(state, statement)) { + needEndStatementCounter--; + if (needEndStatementCounter === 0) { + statementGroups.push(accumulateNodes); + accumulateNodes = []; + } + } + } + + if (accumulateNodes.length > 0) { + statementGroups.push(accumulateNodes); + } + + return statementGroups.map((r) => ({ + from: r[0].from, + to: r[r.length - 1].to, + text: generateText + ? state.doc.sliceString(r[0].from, r[r.length - 1].to) + : "", + })); +} + +export function resolveToNearestStatement( + state: EditorState +): { from: number; to: number } | null { + // Breakdown and grouping the statement + const cursor = state.selection.main.from; + const statements = splitSqlQuery(state, false); + + if (statements.length === 0) return null; + + // Check if our current cursor is within any statement + let i = 0; + for (; i < statements.length; i++) { + const statement = statements[i]; + if (cursor < statement.from) break; + if (cursor > statement.to) continue; + if (cursor >= statement.from && cursor <= statement.to) return statement; + } + + if (i === 0) return statements[0]; + if (i === statements.length) return statements[i - 1]; + + const cursorLine = state.doc.lineAt(cursor).number; + const topLine = state.doc.lineAt(statements[i - 1].to).number; + const bottomLine = state.doc.lineAt(statements[i].from).number; + + if (cursorLine - topLine >= bottomLine - cursorLine) { + return statements[i]; + } else { + return statements[i - 1]; + } +} +function getDecorationFromState(state: EditorState) { + const statement = resolveToNearestStatement(state); + + if (!statement) return Decoration.none; + + // Get the line of the node + const fromLineNumber = state.doc.lineAt(statement.from).number; + const toLineNumber = state.doc.lineAt(statement.to).number; + + const d: Range[] = []; + for (let i = fromLineNumber; i <= toLineNumber; i++) { + d.push(statementLineHighlight.range(state.doc.line(i).from)); + } + + return Decoration.set(d); +} + +const SqlStatementStateField = StateField.define({ + create(state) { + return getDecorationFromState(state); + }, + + update(_, tr) { + return getDecorationFromState(tr.state); + }, + + provide: (f) => EditorView.decorations.from(f), +}); + +const SqlStatementTheme = EditorView.baseTheme({ + ".cm-highlight-statement": { + borderLeft: "3px solid #ff9ff3 !important", + }, +}); + +const SqlStatementHighlightPlugin = [SqlStatementStateField, SqlStatementTheme]; + +export default SqlStatementHighlightPlugin; diff --git a/src/components/gui/tabs/query-tab.tsx b/src/components/gui/tabs/query-tab.tsx index f347e78..2a48e58 100644 --- a/src/components/gui/tabs/query-tab.tsx +++ b/src/components/gui/tabs/query-tab.tsx @@ -1,6 +1,5 @@ import { format } from "sql-formatter"; import { useCallback, useMemo, useRef, useState } from "react"; -import { identify } from "sql-query-identifier"; import { LucideFastForward, LucideGrid, @@ -18,7 +17,6 @@ import { Separator } from "@/components/ui/separator"; import { Button } from "@/components/ui/button"; import { KEY_BINDING } from "@/lib/key-matcher"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; -import { selectStatementFromPosition } from "@/drivers/sqlite/sql-helper"; import QueryProgressLog from "../query-progress-log"; import { useDatabaseDriver } from "@/context/driver-provider"; import { @@ -41,6 +39,10 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; +import { + resolveToNearestStatement, + splitSqlQuery, +} from "../sql-editor/statement-highlight"; interface QueryWindowProps { initialCode?: string; @@ -89,23 +91,25 @@ export default function QueryWindow({ }; const onRunClicked = (all = false) => { - const statements = identify(code, { - dialect: "sqlite", - strict: false, - }); - let finalStatements: string[] = []; - const editor = editorRef.current; + const editorState = editorRef.current?.view?.state; + + if (!editorState) return; + console.log(editorState); if (all) { - finalStatements = statements.map((s) => s.text); - } else if (editor?.view) { - const position = editor.view.state.selection.main.head; - const statement = selectStatementFromPosition(statements, position); + finalStatements = splitSqlQuery(editorState).map((q) => q.text); + } else { + const segment = resolveToNearestStatement(editorState); + if (!segment) return; + + console.log(segment); + + const statement = editorState.doc.sliceString(segment.from, segment.to); if (statement) { - finalStatements = [statement.text]; + finalStatements = [statement]; } } diff --git a/src/drivers/sqlite/sql-helper.test.ts b/src/drivers/sqlite/sql-helper.test.ts index 1e82104..84dbc6e 100644 --- a/src/drivers/sqlite/sql-helper.test.ts +++ b/src/drivers/sqlite/sql-helper.test.ts @@ -5,10 +5,8 @@ import { escapeSqlBinary, escapeSqlString, escapeSqlValue, - selectStatementFromPosition, unescapeIdentity, } from "./sql-helper"; -import { identify } from "sql-query-identifier"; describe("Escape SQL", () => { it("escape sql string", () => { @@ -85,29 +83,3 @@ describe("Mapping sqlite column type to our table type", () => { }); } }); - -function ss(sql: string) { - const pos = sql.indexOf("|"); - const statements = identify(sql.replace("|", "")); - return selectStatementFromPosition(statements, pos); -} - -describe("Select current query", () => { - it("select current query", () => { - expect(ss("select * from |t1; update t1 set name='visal';")?.text).toBe( - "select * from t1;" - ); - - expect(ss("select * from t1|; update t1 set name='visal';")?.text).toBe( - "select * from t1;" - ); - - expect(ss("select * from t1;| update t1 set name='visal';")?.text).toBe( - "select * from t1;" - ); - - expect(ss("select * from t1; update| t1 set name='visal';")?.text).toBe( - "update t1 set name='visal';" - ); - }); -}); diff --git a/src/drivers/sqlite/sql-helper.ts b/src/drivers/sqlite/sql-helper.ts index 9d16884..19a7a18 100644 --- a/src/drivers/sqlite/sql-helper.ts +++ b/src/drivers/sqlite/sql-helper.ts @@ -1,6 +1,5 @@ import { DatabaseValue, TableColumnDataType } from "@/drivers/base-driver"; import { hex } from "@/lib/bit-operation"; -import type { IdentifyResult } from "sql-query-identifier/lib/defines"; export function escapeIdentity(str: string) { return `"${str.replace(/"/g, `""`)}"`; @@ -63,16 +62,6 @@ export function convertSqliteType( return TableColumnDataType.TEXT; } -export function selectStatementFromPosition( - statements: IdentifyResult[], - pos: number -): IdentifyResult | undefined { - for (const statement of statements) { - if (statement.end + 1 >= pos) return statement; - } - return undefined; -} - export function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) { return "";