diff --git a/ui/package-lock.json b/ui/package-lock.json index 9be3a4a..f1f4683 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,6 +24,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.394.0", + "monaco-sql-languages": "0.12.2", "react": "^18.2.0", "react-code-blocks": "^0.1.6", "react-data-grid": "7.0.0-beta.44", @@ -3972,6 +3973,31 @@ "node": ">=4" } }, + "node_modules/antlr4-c3": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/antlr4-c3/-/antlr4-c3-3.3.7.tgz", + "integrity": "sha512-F3ndE38wwA6z6AjUbL3heSdEGl4TxulGDPf9xB0/IY4dbRHWBh6XNaqFwur8vHKQk9FS5yNABHeg2wqlqIYO0w==", + "dependencies": { + "antlr4ng": "2.0.11" + } + }, + "node_modules/antlr4ng": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/antlr4ng/-/antlr4ng-2.0.11.tgz", + "integrity": "sha512-9jM91VVtHSqHkAHQsXHaoaiewFETMvUTI1/tXvwTiFw4f7zke3IGlwEyoKN9NS0FqIwDKFvUNW2e1cKPniTkVQ==", + "peerDependencies": { + "antlr4ng-cli": "1.0.7" + } + }, + "node_modules/antlr4ng-cli": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/antlr4ng-cli/-/antlr4ng-cli-1.0.7.tgz", + "integrity": "sha512-qN2FsDBmLvsQcA5CWTrPz8I8gNXeS1fgXBBhI78VyxBSBV/EJgqy8ks6IDTC9jyugpl40csCQ4sL5K4i2YZ/2w==", + "peer": true, + "bin": { + "antlr4ng": "index.js" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -4564,6 +4590,15 @@ "csstype": "^3.0.2" } }, + "node_modules/dt-sql-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/dt-sql-parser/-/dt-sql-parser-4.0.2.tgz", + "integrity": "sha512-8D/kfYLW+wgz7Cwf5K+OCtex7QHiCyIuI18pw0a5vjSXRKCpfQqNQeG7tU5vp4D0RQEZJiMBuKJPBYwoqWxoAA==", + "dependencies": { + "antlr4-c3": "3.3.7", + "antlr4ng": "2.0.11" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -5694,8 +5729,18 @@ "node_modules/monaco-editor": { "version": "0.49.0", "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.49.0.tgz", - "integrity": "sha512-2I8/T3X/hLxB2oPHgqcNYUVdA/ZEFShT7IAujifIPMfKkNbLOqY8XCoyHCXrsdjb36dW9MwoTwBCFpXKMwNwaQ==", - "dev": true + "integrity": "sha512-2I8/T3X/hLxB2oPHgqcNYUVdA/ZEFShT7IAujifIPMfKkNbLOqY8XCoyHCXrsdjb36dW9MwoTwBCFpXKMwNwaQ==" + }, + "node_modules/monaco-sql-languages": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/monaco-sql-languages/-/monaco-sql-languages-0.12.2.tgz", + "integrity": "sha512-FNo/9FLF9JqYmdSBjQtnCCxg+Bo9hGCRDIatEX+0XUB6zvZSis7JJX7yq64kENqL7KwmX4N2ILkhbBxX5F9wyw==", + "dependencies": { + "dt-sql-parser": "4.0.2" + }, + "peerDependencies": { + "monaco-editor": ">=0.31.0" + } }, "node_modules/ms": { "version": "2.1.2", diff --git a/ui/package.json b/ui/package.json index 877f49b..aa3d03d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -27,6 +27,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", "lucide-react": "^0.394.0", + "monaco-sql-languages": "0.12.2", "react": "^18.2.0", "react-code-blocks": "^0.1.6", "react-data-grid": "7.0.0-beta.44", diff --git a/ui/src/components/editor.config.ts b/ui/src/components/editor.config.ts new file mode 100644 index 0000000..ecd2bc5 --- /dev/null +++ b/ui/src/components/editor.config.ts @@ -0,0 +1,96 @@ +import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; + +export const ID_LANGUAGE_SQL = "sql"; + +export const COMMAND_CONFIG: monaco.languages.LanguageConfiguration = { + comments: { + lineComment: "--", + blockComment: ["/*", "*/"], + }, + brackets: [ + ["{", "}"], + ["[", "]"], + ["(", ")"], + ], + autoClosingPairs: [ + { open: "{", close: "}" }, + { open: "[", close: "]" }, + { open: "(", close: ")" }, + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + surroundingPairs: [ + { open: '"', close: '"' }, + { open: "'", close: "'" }, + ], + wordPattern: /(-?\d*\.\d\w*)|([a-zA-Z_]\w*)/g, + indentationRules: { + increaseIndentPattern: /(\{|\[|\()/, + decreaseIndentPattern: /(\}|\]|\))/, + }, +}; + +export const autoSuggestionCompletionItems = ( + range: monaco.languages.CompletionItem['range'], +): monaco.languages.CompletionList => { + const _suggestions = [ + { label: "SELECT", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "SELECT ", range }, + { label: "FROM", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "FROM ", range }, + { label: "WHERE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "WHERE ", range }, + { label: "GROUP BY", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "GROUP BY ", range }, + { label: "HAVING", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "HAVING ", range }, + { label: "ORDER BY", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "ORDER BY ", range }, + { label: "LIMIT", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "LIMIT ", range }, + { label: "AND", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "AND ", range }, + { label: "OR", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "OR ", range }, + { label: "NOT", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "NOT ", range }, + { label: "BETWEEN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "BETWEEN ", range }, + { label: "IN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "IN ", range }, + { label: "LIKE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "LIKE ", range }, + { label: "IS NULL", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "IS NULL ", range }, + { label: "IS NOT NULL", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "IS NOT NULL ", range }, + { label: "INNER JOIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "INNER JOIN ", range }, + { label: "LEFT JOIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "LEFT JOIN ", range }, + { label: "RIGHT JOIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "RIGHT JOIN ", range }, + { label: "FULL JOIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "FULL JOIN ", range }, + { label: "ON", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "ON ", range }, + { label: "AS", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "AS ", range }, + { label: "COUNT", kind: monaco.languages.CompletionItemKind.Function, insertText: "COUNT()", range }, + { label: "SUM", kind: monaco.languages.CompletionItemKind.Function, insertText: "SUM()", range }, + { label: "AVG", kind: monaco.languages.CompletionItemKind.Function, insertText: "AVG()", range }, + { label: "MIN", kind: monaco.languages.CompletionItemKind.Function, insertText: "MIN()", range }, + { label: "MAX", kind: monaco.languages.CompletionItemKind.Function, insertText: "MAX()", range }, + { label: "CAST", kind: monaco.languages.CompletionItemKind.Function, insertText: "CAST()", range }, + { label: "DATE", kind: monaco.languages.CompletionItemKind.Function, insertText: "DATE()", range }, + { label: "NOW", kind: monaco.languages.CompletionItemKind.Function, insertText: "NOW()", range }, + { label: "JOIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "JOIN ", range }, + { label: "INSERT INTO", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "INSERT INTO ", range }, + { label: "UPDATE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "UPDATE ", range }, + { label: "DELETE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "DELETE ", range }, + { label: "CREATE TABLE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "CREATE TABLE ", range }, + { label: "DROP TABLE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "DROP TABLE ", range }, + { label: "PRAGMA", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "PRAGMA ", range }, + { label: "VACUUM", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "VACUUM;", range }, + { label: "ATTACH DATABASE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "ATTACH DATABASE '' AS '';", range }, + { label: "SERIAL", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "SERIAL ", range }, + { label: "RETURNING", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "RETURNING ", range }, + { label: "CREATE EXTENSION", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "CREATE EXTENSION ", range }, + { label: "AUTO_INCREMENT", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "AUTO_INCREMENT ", range }, + { label: "ENGINE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "ENGINE=", range }, + { label: "SHOW DATABASES", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "SHOW DATABASES;", range }, + { label: "SHOW TABLES", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "SHOW TABLES;", range }, + { label: "COPY", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "COPY ", range }, + { label: "EXPORT DATABASE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "EXPORT DATABASE '';", range }, + { label: "IMPORT DATABASE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "IMPORT DATABASE '';", range }, + { label: "CREATE MATERIALIZED VIEW", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "CREATE MATERIALIZED VIEW ", range }, + { label: "OPTIMIZE TABLE", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "OPTIMIZE TABLE ", range }, + { label: "ALTER TABLE", kind: monaco.languages.CompletionItemKind.Snippet, insertText: "ALTER TABLE ", range }, + { label: "EXPLAIN", kind: monaco.languages.CompletionItemKind.Keyword, insertText: "EXPLAIN ", range }, + ]; + + // Remove duplicates from suggestions using filter method + const suggestions = _suggestions.filter((item, index, self) => self.findIndex(t => t.label === item.label) === index); + + + return { suggestions }; +}; diff --git a/ui/src/components/editor.tsx b/ui/src/components/editor.tsx index a68392c..9dbdf9b 100644 --- a/ui/src/components/editor.tsx +++ b/ui/src/components/editor.tsx @@ -1,9 +1,17 @@ import "@/editorWorker"; import * as monaco from "monaco-editor/esm/vs/editor/editor.api"; +import { vsPlusTheme } from "monaco-sql-languages"; import { FunctionComponent, useEffect, useRef, useState } from "react"; import { useTheme } from "@/provider/theme.provider"; +import { + COMMAND_CONFIG, + ID_LANGUAGE_SQL, + autoSuggestionCompletionItems, +} from "./editor.config"; import { Card } from "./ui/card"; +import { fetchAutocomplete } from "@/api"; +import { useQuery } from "@tanstack/react-query"; type Props = { value: string; @@ -16,14 +24,28 @@ export const Editor: FunctionComponent = ({ value, onChange }) => { useState(null); const monacoEl = useRef(null); + const { data: autoCompleteData } = useQuery({ + queryKey: ["autocomplete"], + queryFn: () => fetchAutocomplete(), + }); + useEffect(() => { if (monacoEl) { setEditor((editor) => { if (editor) return editor; + monaco.languages.register({ id: ID_LANGUAGE_SQL }); + monaco.languages.setLanguageConfiguration( + ID_LANGUAGE_SQL, + COMMAND_CONFIG + ); + + monaco.editor.defineTheme("sql-dark", vsPlusTheme.darkThemeData); + monaco.editor.defineTheme("sql-light", vsPlusTheme.lightThemeData); + const newEditor = monaco.editor.create(monacoEl.current!, { value, - language: "sql", + language: ID_LANGUAGE_SQL, minimap: { enabled: false, }, @@ -48,9 +70,72 @@ export const Editor: FunctionComponent = ({ value, onChange }) => { return () => editor?.dispose(); }, [monacoEl.current]); + useEffect(() => { + + if(!autoCompleteData) return + + monaco.languages.registerCompletionItemProvider(ID_LANGUAGE_SQL, { + provideCompletionItems: (model, position) => { + const word = model.getWordUntilPosition(position); + const range = { + startLineNumber: position.lineNumber, + endLineNumber: position.lineNumber, + startColumn: word.startColumn, + endColumn: word.endColumn, + }; + const { suggestions } = autoSuggestionCompletionItems(range); + + const tableColumnSuggestions = autoCompleteData.tables.reduce((acc: any, { table_name, columns }) => { + + const alias = table_name.substring(0, 3); + + const table = { + label: table_name, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: table_name, + range, + } + + const aliasTable = { + label: `${table_name} AS ${alias}`, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: `${table_name} AS ${alias}`, + range, + } + + const col = columns.map((column) => ({ + label: column, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: column, + range, + })); + + const tableColumn = columns.map((column) => ({ + label: `${table_name}.${column}`, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: `${table_name}.${column}`, + range, + })); + + const tableColumnAlias = columns.map((column) => ({ + label: `${alias}.${column}`, + kind: monaco.languages.CompletionItemKind.Variable, + insertText: `${alias}.${column}`, + })); + + return [...acc, table, aliasTable, ...col, ...tableColumn, ...tableColumnAlias]; + }, []); + + return { suggestions: [...suggestions, ...tableColumnSuggestions] }; + }, + }); + }, [autoCompleteData]); + useEffect(() => { if (monacoEl.current) { - monaco.editor.setTheme(currentTheme === "light" ? "vs" : "vs-dark"); + monaco.editor.setTheme( + currentTheme === "light" ? "sql-light" : "sql-dark" + ); } }, [currentTheme]); diff --git a/ui/src/routeTree.gen.ts b/ui/src/routeTree.gen.ts index 16ec80c..4be1bc8 100644 --- a/ui/src/routeTree.gen.ts +++ b/ui/src/routeTree.gen.ts @@ -8,60 +8,60 @@ // This file is auto-generated by TanStack Router -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute } from '@tanstack/react-router' // Import Routes -import { Route as rootRoute } from "./routes/__root"; +import { Route as rootRoute } from './routes/__root' // Create Virtual Routes -const TablesLazyImport = createFileRoute("/tables")(); -const QueryLazyImport = createFileRoute("/query")(); -const IndexLazyImport = createFileRoute("/")(); +const TablesLazyImport = createFileRoute('/tables')() +const QueryLazyImport = createFileRoute('/query')() +const IndexLazyImport = createFileRoute('/')() // Create/Update Routes const TablesLazyRoute = TablesLazyImport.update({ - path: "/tables", + path: '/tables', getParentRoute: () => rootRoute, -} as any).lazy(() => import("./routes/tables.lazy").then((d) => d.Route)); +} as any).lazy(() => import('./routes/tables.lazy').then((d) => d.Route)) const QueryLazyRoute = QueryLazyImport.update({ - path: "/query", + path: '/query', getParentRoute: () => rootRoute, -} as any).lazy(() => import("./routes/query.lazy").then((d) => d.Route)); +} as any).lazy(() => import('./routes/query.lazy').then((d) => d.Route)) const IndexLazyRoute = IndexLazyImport.update({ - path: "/", + path: '/', getParentRoute: () => rootRoute, -} as any).lazy(() => import("./routes/index.lazy").then((d) => d.Route)); +} as any).lazy(() => import('./routes/index.lazy').then((d) => d.Route)) // Populate the FileRoutesByPath interface -declare module "@tanstack/react-router" { +declare module '@tanstack/react-router' { interface FileRoutesByPath { - "/": { - id: "/"; - path: "/"; - fullPath: "/"; - preLoaderRoute: typeof IndexLazyImport; - parentRoute: typeof rootRoute; - }; - "/query": { - id: "/query"; - path: "/query"; - fullPath: "/query"; - preLoaderRoute: typeof QueryLazyImport; - parentRoute: typeof rootRoute; - }; - "/tables": { - id: "/tables"; - path: "/tables"; - fullPath: "/tables"; - preLoaderRoute: typeof TablesLazyImport; - parentRoute: typeof rootRoute; - }; + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexLazyImport + parentRoute: typeof rootRoute + } + '/query': { + id: '/query' + path: '/query' + fullPath: '/query' + preLoaderRoute: typeof QueryLazyImport + parentRoute: typeof rootRoute + } + '/tables': { + id: '/tables' + path: '/tables' + fullPath: '/tables' + preLoaderRoute: typeof TablesLazyImport + parentRoute: typeof rootRoute + } } } @@ -71,7 +71,7 @@ export const routeTree = rootRoute.addChildren({ IndexLazyRoute, QueryLazyRoute, TablesLazyRoute, -}); +}) /* prettier-ignore-end */ diff --git a/ui/src/routes/query.lazy.tsx b/ui/src/routes/query.lazy.tsx index e0fc20e..7887eb3 100644 --- a/ui/src/routes/query.lazy.tsx +++ b/ui/src/routes/query.lazy.tsx @@ -18,7 +18,7 @@ import { import { createFileRoute } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; -import { fetchAutocomplete, fetchQuery } from "@/api"; +import { fetchQuery } from "@/api"; import { useQueries, QueriesProvider, @@ -177,13 +177,6 @@ function Query({ sql, onChange, onSave, onDelete, onUpdate }: QueryProps) { retry: false, }); - const { data: autocompleteData } = useQuery({ - queryKey: ["autocomplete"], - queryFn: () => fetchAutocomplete(), - }); - - console.log(autocompleteData); - const grid = !data ? ( !autoExecute && code && error ? (