Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add experiment statement gutter highlight #155

Merged
merged 5 commits into from
Sep 12, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions src/components/gui/sql-editor/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -142,10 +143,17 @@ const SqlEditor = forwardRef<ReactCodeMirrorRef, SqlEditorProps>(
}

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);
Expand Down
155 changes: 155 additions & 0 deletions src/components/gui/sql-editor/statement-highlight.test.ts
Original file line number Diff line number Diff line change
@@ -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;",
]);
});
});
172 changes: 172 additions & 0 deletions src/components/gui/sql-editor/statement-highlight.ts
Original file line number Diff line number Diff line change
@@ -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;

Check warning on line 27 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 27 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

// 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;

Check warning on line 33 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 33 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (firstKeyword === "delete") return 0;

Check warning on line 34 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 34 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

const keywords = node.getChildren("Keyword");
if (keywords.length === 0) return 0;

Check warning on line 37 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 37 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

return keywords.filter(
(k) => toNodeString(state, k).toLowerCase() === "begin"
).length;
}

function isEndStatement(state: EditorState, node: SyntaxNode) {
let ptr = node.firstChild;
if (!ptr) return false;

Check warning on line 46 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 46 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (toNodeString(state, ptr).toLowerCase() !== "end") return false;

ptr = ptr.nextSibling;
if (!ptr) return false;

Check warning on line 50 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 50 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
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)
: "",

Check warning on line 100 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}));
}

export function resolveToNearestStatement(

Check warning on line 104 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
state: EditorState
): { from: number; to: number } | null {
// Breakdown and grouping the statement
const cursor = state.selection.main.from;

Check warning on line 108 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const statements = splitSqlQuery(state, false);

Check warning on line 109 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

if (statements.length === 0) return null;

Check warning on line 111 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 111 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 111 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

// Check if our current cursor is within any statement
let i = 0;

Check warning on line 114 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
for (; i < statements.length; i++) {
const statement = statements[i];

Check warning on line 116 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
if (cursor < statement.from) break;

Check warning on line 117 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 117 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 117 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (cursor > statement.to) continue;

Check warning on line 118 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 118 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 118 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (cursor >= statement.from && cursor <= statement.to) return statement;

Check warning on line 119 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 119 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 119 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 119 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 119 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}

Check warning on line 120 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

if (i === 0) return statements[0];

Check warning on line 122 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 122 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 122 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
if (i === statements.length) return statements[i - 1];

Check warning on line 123 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 123 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const cursorLine = state.doc.lineAt(cursor).number;

Check warning on line 125 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const topLine = state.doc.lineAt(statements[i - 1].to).number;

Check warning on line 126 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const bottomLine = state.doc.lineAt(statements[i].from).number;

Check warning on line 127 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

if (cursorLine - topLine >= bottomLine - cursorLine) {
return statements[i];

Check warning on line 130 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
} else {
return statements[i - 1];

Check warning on line 132 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 133 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}
function getDecorationFromState(state: EditorState) {
const statement = resolveToNearestStatement(state);

Check warning on line 136 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

if (!statement) return Decoration.none;

Check warning on line 138 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 138 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

// Get the line of the node
const fromLineNumber = state.doc.lineAt(statement.from).number;

Check warning on line 141 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const toLineNumber = state.doc.lineAt(statement.to).number;

Check warning on line 142 in src/components/gui/sql-editor/statement-highlight.ts

View workflow job for this annotation

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const d: Range<Decoration>[] = [];
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;
Loading
Loading