Skip to content

Commit 28a749d

Browse files
authored
feat: add experiment statement gutter highlight (#155)
* add experiment statement gutter highlight * initial highlight code * change how we select statement
1 parent 0045213 commit 28a749d

8 files changed

Lines changed: 352 additions & 62 deletions

File tree

package-lock.json

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@
9494
"react-resizable-panels": "^1.0.9",
9595
"sonner": "^1.4.41",
9696
"sql-formatter": "^15.3.2",
97-
"sql-query-identifier": "^2.6.0",
9897
"tailwind-merge": "^2.2.2",
9998
"tailwindcss-animate": "^1.0.7",
10099
"zod": "^3.22.4"

src/components/gui/sql-editor/index.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { sqliteDialect } from "@/drivers/sqlite/sqlite-dialect";
2121
import { functionTooltip } from "./function-tooltips";
2222
import sqliteFunctionList from "@/drivers/sqlite/function-tooltip.json";
2323
import { toast } from "sonner";
24+
import SqlStatementHighlightPlugin from "./statement-highlight";
2425
import { SupportedDialect } from "@/drivers/base-driver";
2526

2627
interface SqlEditorProps {
@@ -142,10 +143,17 @@ const SqlEditor = forwardRef<ReactCodeMirrorRef, SqlEditorProps>(
142143
}
143144

144145
return [
146+
EditorView.baseTheme({
147+
"& .cm-line": {
148+
borderLeft: "3px solid transparent",
149+
paddingLeft: "10px",
150+
},
151+
}),
145152
keyExtensions,
146153
sqlDialect,
147154
tooltipExtension,
148155
tableNameHighlightPlugin,
156+
SqlStatementHighlightPlugin,
149157
EditorView.updateListener.of((state) => {
150158
const pos = state.state.selection.main.head;
151159
const line = state.state.doc.lineAt(pos);
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import { MySQL, SQLite } from "@codemirror/lang-sql";
2+
import { EditorState } from "@codemirror/state";
3+
import { splitSqlQuery } from "./statement-highlight";
4+
5+
function sqlite(code: string) {
6+
const state = EditorState.create({ doc: code, extensions: [SQLite] });
7+
return splitSqlQuery(state).map((p) => p.text);
8+
}
9+
10+
function mysql(code: string) {
11+
const state = EditorState.create({ doc: code, extensions: [MySQL] });
12+
return splitSqlQuery(state).map((p) => p.text);
13+
}
14+
15+
describe("split sql statements", () => {
16+
test("should parse a query with different statements in a single line", () => {
17+
expect(
18+
sqlite(
19+
`INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');SELECT * FROM Persons`
20+
)
21+
).toEqual([
22+
`INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');`,
23+
`SELECT * FROM Persons`,
24+
]);
25+
});
26+
27+
test("should identify a query with different statements in multiple lines", () => {
28+
expect(
29+
sqlite(`
30+
INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');
31+
SELECT * FROM Persons';
32+
`)
33+
).toEqual([
34+
`INSERT INTO Persons (PersonID, Name) VALUES (1, 'Jack');`,
35+
`SELECT * FROM Persons';\n `,
36+
]);
37+
});
38+
39+
test("sholud be able to split statement with BEGIN and END", () => {
40+
expect(
41+
sqlite(`CREATE TABLE customer(
42+
cust_id INTEGER PRIMARY KEY,
43+
cust_name TEXT,
44+
cust_addr TEXT
45+
);
46+
47+
-- some comment here that should be ignore
48+
49+
50+
CREATE VIEW customer_address AS
51+
SELECT cust_id, cust_addr FROM customer;
52+
CREATE TRIGGER cust_addr_chng
53+
INSTEAD OF UPDATE OF cust_addr ON customer_address
54+
BEGIN
55+
UPDATE customer SET cust_addr=NEW.cust_addr
56+
WHERE cust_id=NEW.cust_id;
57+
END ;`)
58+
).toEqual([
59+
`CREATE TABLE customer(\n cust_id INTEGER PRIMARY KEY,\n cust_name TEXT,\n cust_addr TEXT\n);`,
60+
`CREATE VIEW customer_address AS\n SELECT cust_id, cust_addr FROM customer;`,
61+
`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 ;`,
62+
]);
63+
});
64+
65+
test("should be able to split statement with BEGIN and END and CONDITION inside", () => {
66+
expect(
67+
mysql(`CREATE TRIGGER upd_check BEFORE UPDATE ON account
68+
FOR EACH ROW
69+
BEGIN
70+
IF NEW.amount < 0 THEN
71+
SET NEW.amount = 0;
72+
ELSEIF NEW.amount > 100 THEN
73+
SET NEW.amount = 100;
74+
END IF;
75+
END; SELECT * FROM hello`)
76+
).toEqual([
77+
`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;`,
78+
"SELECT * FROM hello",
79+
]);
80+
});
81+
82+
test("should be able to split statement with BEGIN with no end", () => {
83+
expect(
84+
mysql(`SELECT * FROM outerbase; CREATE TRIGGER upd_check BEFORE UPDATE ON account
85+
FOR EACH ROW
86+
BEGIN
87+
IF NEW.amount < 0 THEN
88+
SET NEW.amount = 0;
89+
ELSEIF NEW.amount > 100 THEN
90+
SET NEW.amount = 100;`)
91+
).toEqual([
92+
"SELECT * FROM outerbase;",
93+
`CREATE TRIGGER upd_check BEFORE UPDATE ON account
94+
FOR EACH ROW
95+
BEGIN
96+
IF NEW.amount < 0 THEN
97+
SET NEW.amount = 0;
98+
ELSEIF NEW.amount > 100 THEN
99+
SET NEW.amount = 100;`,
100+
]);
101+
});
102+
103+
test("should be able to split TRIGGER without begin", () => {
104+
expect(
105+
mysql(`create trigger hire_log after insert on employees
106+
for each row insert into hiring values (new.id, current_time());
107+
108+
insert into employees (first_name, last_name) values ("Tim", "Sehn");`)
109+
).toEqual([
110+
`create trigger hire_log after insert on employees \nfor each row insert into hiring values (new.id, current_time());`,
111+
`insert into employees (first_name, last_name) values ("Tim", "Sehn");`,
112+
]);
113+
});
114+
115+
test("should be able to split nested BEGIN", () => {
116+
expect(
117+
mysql(
118+
`CREATE PROCEDURE procCreateCarTable
119+
IS
120+
BEGIN
121+
BEGIN
122+
EXECUTE IMMEDIATE 'DROP TABLE CARS';
123+
EXCEPTION WHEN OTHERS THEN NULL;
124+
EXECUTE IMMEDIATE 'CREATE TABLE CARS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE
125+
VARCHAR2(10))';
126+
END;
127+
BEGIN
128+
EXECUTE IMMEDIATE 'DROP TABLE TRUCKS';
129+
EXCEPTION WHEN OTHERS THEN NULL;
130+
EXECUTE IMMEDIATE 'CREATE TABLE TRUCKS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE
131+
VARCHAR2(10))';
132+
END;
133+
END; SELECT * FROM outeerbase;`
134+
)
135+
).toEqual([
136+
`CREATE PROCEDURE procCreateCarTable
137+
IS
138+
BEGIN
139+
BEGIN
140+
EXECUTE IMMEDIATE 'DROP TABLE CARS';
141+
EXCEPTION WHEN OTHERS THEN NULL;
142+
EXECUTE IMMEDIATE 'CREATE TABLE CARS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE
143+
VARCHAR2(10))';
144+
END;
145+
BEGIN
146+
EXECUTE IMMEDIATE 'DROP TABLE TRUCKS';
147+
EXCEPTION WHEN OTHERS THEN NULL;
148+
EXECUTE IMMEDIATE 'CREATE TABLE TRUCKS (ID VARCHAR2(1), NAME VARCHAR2(10), TITLE
149+
VARCHAR2(10))';
150+
END;
151+
END;`,
152+
"SELECT * FROM outeerbase;",
153+
]);
154+
});
155+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import {
2+
Decoration,
3+
EditorState,
4+
EditorView,
5+
StateField,
6+
Range,
7+
} from "@uiw/react-codemirror";
8+
import { syntaxTree } from "@codemirror/language";
9+
import { SyntaxNode } from "@lezer/common";
10+
11+
const statementLineHighlight = Decoration.line({
12+
class: "cm-highlight-statement",
13+
});
14+
15+
export interface StatementSegment {
16+
from: number;
17+
to: number;
18+
text: string;
19+
}
20+
21+
function toNodeString(state: EditorState, node: SyntaxNode) {
22+
return state.doc.sliceString(node.from, node.to);
23+
}
24+
25+
function isRequireEndStatement(state: EditorState, node: SyntaxNode): number {
26+
const ptr = node.firstChild;
27+
if (!ptr) return 0;
28+
29+
// Majority of the query will fall in SELECT, INSERT, UPDATE, DELETE
30+
const firstKeyword = toNodeString(state, ptr).toLowerCase();
31+
if (firstKeyword === "select") return 0;
32+
if (firstKeyword === "insert") return 0;
33+
if (firstKeyword === "update") return 0;
34+
if (firstKeyword === "delete") return 0;
35+
36+
const keywords = node.getChildren("Keyword");
37+
if (keywords.length === 0) return 0;
38+
39+
return keywords.filter(
40+
(k) => toNodeString(state, k).toLowerCase() === "begin"
41+
).length;
42+
}
43+
44+
function isEndStatement(state: EditorState, node: SyntaxNode) {
45+
let ptr = node.firstChild;
46+
if (!ptr) return false;
47+
if (toNodeString(state, ptr).toLowerCase() !== "end") return false;
48+
49+
ptr = ptr.nextSibling;
50+
if (!ptr) return false;
51+
if (toNodeString(state, ptr) !== ";") return false;
52+
53+
return true;
54+
}
55+
56+
export function splitSqlQuery(
57+
state: EditorState,
58+
generateText: boolean = true
59+
): StatementSegment[] {
60+
const topNode = syntaxTree(state).topNode;
61+
62+
// Get all the statements
63+
let needEndStatementCounter = 0;
64+
const statements = topNode.getChildren("Statement");
65+
66+
if (statements.length === 0) return [];
67+
68+
const statementGroups: SyntaxNode[][] = [];
69+
let accumulateNodes: SyntaxNode[] = [];
70+
let i = 0;
71+
72+
for (; i < statements.length; i++) {
73+
const statement = statements[i];
74+
needEndStatementCounter += isRequireEndStatement(state, statement);
75+
76+
if (needEndStatementCounter) {
77+
accumulateNodes.push(statement);
78+
} else {
79+
statementGroups.push([statement]);
80+
}
81+
82+
if (needEndStatementCounter && isEndStatement(state, statement)) {
83+
needEndStatementCounter--;
84+
if (needEndStatementCounter === 0) {
85+
statementGroups.push(accumulateNodes);
86+
accumulateNodes = [];
87+
}
88+
}
89+
}
90+
91+
if (accumulateNodes.length > 0) {
92+
statementGroups.push(accumulateNodes);
93+
}
94+
95+
return statementGroups.map((r) => ({
96+
from: r[0].from,
97+
to: r[r.length - 1].to,
98+
text: generateText
99+
? state.doc.sliceString(r[0].from, r[r.length - 1].to)
100+
: "",
101+
}));
102+
}
103+
104+
export function resolveToNearestStatement(
105+
state: EditorState
106+
): { from: number; to: number } | null {
107+
// Breakdown and grouping the statement
108+
const cursor = state.selection.main.from;
109+
const statements = splitSqlQuery(state, false);
110+
111+
if (statements.length === 0) return null;
112+
113+
// Check if our current cursor is within any statement
114+
let i = 0;
115+
for (; i < statements.length; i++) {
116+
const statement = statements[i];
117+
if (cursor < statement.from) break;
118+
if (cursor > statement.to) continue;
119+
if (cursor >= statement.from && cursor <= statement.to) return statement;
120+
}
121+
122+
if (i === 0) return statements[0];
123+
if (i === statements.length) return statements[i - 1];
124+
125+
const cursorLine = state.doc.lineAt(cursor).number;
126+
const topLine = state.doc.lineAt(statements[i - 1].to).number;
127+
const bottomLine = state.doc.lineAt(statements[i].from).number;
128+
129+
if (cursorLine - topLine >= bottomLine - cursorLine) {
130+
return statements[i];
131+
} else {
132+
return statements[i - 1];
133+
}
134+
}
135+
function getDecorationFromState(state: EditorState) {
136+
const statement = resolveToNearestStatement(state);
137+
138+
if (!statement) return Decoration.none;
139+
140+
// Get the line of the node
141+
const fromLineNumber = state.doc.lineAt(statement.from).number;
142+
const toLineNumber = state.doc.lineAt(statement.to).number;
143+
144+
const d: Range<Decoration>[] = [];
145+
for (let i = fromLineNumber; i <= toLineNumber; i++) {
146+
d.push(statementLineHighlight.range(state.doc.line(i).from));
147+
}
148+
149+
return Decoration.set(d);
150+
}
151+
152+
const SqlStatementStateField = StateField.define({
153+
create(state) {
154+
return getDecorationFromState(state);
155+
},
156+
157+
update(_, tr) {
158+
return getDecorationFromState(tr.state);
159+
},
160+
161+
provide: (f) => EditorView.decorations.from(f),
162+
});
163+
164+
const SqlStatementTheme = EditorView.baseTheme({
165+
".cm-highlight-statement": {
166+
borderLeft: "3px solid #ff9ff3 !important",
167+
},
168+
});
169+
170+
const SqlStatementHighlightPlugin = [SqlStatementStateField, SqlStatementTheme];
171+
172+
export default SqlStatementHighlightPlugin;

0 commit comments

Comments
 (0)