diff --git a/components/CodeBlock.tsx b/components/CodeBlock.tsx index 64642dd..b16b999 100644 --- a/components/CodeBlock.tsx +++ b/components/CodeBlock.tsx @@ -1,45 +1,80 @@ import { useState } from "react"; +import { createPortal } from "react-dom"; +import { toast } from "react-hot-toast"; import { Prism as SyntaxHighlighter } from "react-syntax-highlighter"; import { oneDark } from "react-syntax-highlighter/dist/cjs/styles/prism"; +import { useConnectionStore } from "@/store"; +import Icon from "./Icon"; +import ExecuteStatementModal from "./ExecuteStatementModal"; interface Props { language: string; value: string; } +const checkStatementIsSelect = (statement: string) => { + return statement.toUpperCase().trim().startsWith("SELECT"); +}; + export const CodeBlock = (props: Props) => { const { language, value } = props; - const [isCopied, setIsCopied] = useState(false); + const connectionStore = useConnectionStore(); + const [showExecuteQueryModal, setShowExecuteQueryModal] = useState(false); + const currentConnectionCtx = connectionStore.currentConnectionCtx; + // Only show execute button in the following situations: + // * SQL code, and it is a SELECT statement; + // * Connection setup; + const showExecuteButton = + language.toUpperCase() === "SQL" && checkStatementIsSelect(value) && currentConnectionCtx?.connection && currentConnectionCtx?.database; const copyToClipboard = () => { if (!navigator.clipboard || !navigator.clipboard.writeText) { + toast.error("Failed to copy to clipboard"); return; } - navigator.clipboard.writeText(value).then(() => { - setIsCopied(true); - setTimeout(() => { - setIsCopied(false); - }, 2000); + toast.success("Copied to clipboard"); }); }; return ( -
-
- {language} -
- + <> +
+
+ {language} +
+ + {showExecuteButton && ( + + )} +
+ + {value} +
- - {value} - -
+ + {showExecuteQueryModal && + showExecuteButton && + createPortal( + setShowExecuteQueryModal(false)} + />, + document.body + )} + ); }; diff --git a/components/ExecuteStatementModal.tsx b/components/ExecuteStatementModal.tsx new file mode 100644 index 0000000..d08bb63 --- /dev/null +++ b/components/ExecuteStatementModal.tsx @@ -0,0 +1,81 @@ +import { head } from "lodash-es"; +import { useEffect, useState } from "react"; +import DataTable from "react-data-table-component"; +import { toast } from "react-hot-toast"; +import { Connection } from "@/types"; +import Icon from "./Icon"; + +interface Props { + connection: Connection; + databaseName: string; + statement: string; + close: () => void; +} + +type RawQueryResult = { + [key: string]: any; +}; + +const ExecuteStatementModal = (props: Props) => { + const { close, connection, databaseName, statement } = props; + const [rawResults, setRawResults] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const columns = Object.keys(head(rawResults) || {}).map((key) => { + return { + name: key, + selector: (row: RawQueryResult) => row[key], + }; + }); + + useEffect(() => { + const executeStatement = async () => { + try { + const response = await fetch("/api/connection/execute", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + connection, + db: databaseName, + statement, + }), + }); + const result = await response.json(); + setIsLoading(false); + setRawResults(result); + } catch (error) { + console.error(error); + toast.error("Failed to execute statement"); + } + }; + + executeStatement(); + }, []); + + return ( +
+
+

Execute query

+ +
+ {isLoading ? ( +
+ +
+ ) : rawResults.length === 0 ? ( +
No data return.
+ ) : ( +
+ +
+ )} +
+
+
+ ); +}; + +export default ExecuteStatementModal; diff --git a/lib/connectors/index.ts b/lib/connectors/index.ts index 3bd882c..c48c5b6 100644 --- a/lib/connectors/index.ts +++ b/lib/connectors/index.ts @@ -4,6 +4,7 @@ import postgres from "./postgres"; export interface Connector { testConnection: () => Promise; + execute: (databaseName: string, statement: string) => Promise; getDatabases: () => Promise; getTables: (databaseName: string) => Promise; getTableStructure: (databaseName: string, tableName: string) => Promise; diff --git a/lib/connectors/mysql/index.ts b/lib/connectors/mysql/index.ts index d426f46..c541186 100644 --- a/lib/connectors/mysql/index.ts +++ b/lib/connectors/mysql/index.ts @@ -20,6 +20,15 @@ const testConnection = async (connection: Connection): Promise => { } }; +const execute = async (connection: Connection, databaseName: string, statement: string): Promise => { + connection.database = databaseName; + const connectionUrl = convertToConnectionUrl(connection); + const conn = await mysql.createConnection(connectionUrl); + const [rows] = await conn.query(statement); + conn.destroy(); + return rows; +}; + const getDatabases = async (connection: Connection): Promise => { const connectionUrl = convertToConnectionUrl(connection); const conn = await mysql.createConnection(connectionUrl); @@ -68,6 +77,7 @@ const getTableStructure = async (connection: Connection, databaseName: string, t const newConnector = (connection: Connection): Connector => { return { testConnection: () => testConnection(connection), + execute: (databaseName: string, statement: string) => execute(connection, databaseName, statement), getDatabases: () => getDatabases(connection), getTables: (databaseName: string) => getTables(connection, databaseName), getTableStructure: (databaseName: string, tableName: string) => getTableStructure(connection, databaseName, tableName), diff --git a/lib/connectors/postgres/index.ts b/lib/connectors/postgres/index.ts index 03c845d..5310482 100644 --- a/lib/connectors/postgres/index.ts +++ b/lib/connectors/postgres/index.ts @@ -27,6 +27,14 @@ const testConnection = async (connection: Connection): Promise => { } }; +const execute = async (connection: Connection, _: string, statement: string): Promise => { + const client = newPostgresClient(connection); + await client.connect(); + const { rows } = await client.query(statement); + await client.end(); + return rows; +}; + const getDatabases = async (connection: Connection): Promise => { const client = newPostgresClient(connection); await client.connect(); @@ -75,6 +83,7 @@ const getTableStructure = async (connection: Connection, _: string, tableName: s const newConnector = (connection: Connection): Connector => { return { testConnection: () => testConnection(connection), + execute: (databaseName: string, statement: string) => execute(connection, databaseName, statement), getDatabases: () => getDatabases(connection), getTables: (databaseName: string) => getTables(connection, databaseName), getTableStructure: (databaseName: string, tableName: string) => getTableStructure(connection, databaseName, tableName), diff --git a/package.json b/package.json index 593abb1..62da092 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "lodash-es": "^4.17.21", "next": "^13.2.4", "react": "^18.2.0", + "react-data-table-component": "^7.5.3", "react-dom": "^18.2.0", "react-hot-toast": "^2.4.0", "react-icons": "^4.8.0", @@ -24,6 +25,7 @@ "react-markdown": "^8.0.6", "react-textarea-autosize": "^8.4.0", "remark-gfm": "^3.0.1", + "styled-components": "^5.3.9", "uuid": "^9.0.0", "zustand": "^4.3.6" }, diff --git a/pages/api/connection/execute.ts b/pages/api/connection/execute.ts new file mode 100644 index 0000000..37a364e --- /dev/null +++ b/pages/api/connection/execute.ts @@ -0,0 +1,24 @@ +import { NextApiRequest, NextApiResponse } from "next"; +import { newConnector } from "@/lib/connectors"; +import { Connection } from "@/types"; + +// POST /api/connection/execute +// req body: { connection: Connection, db: string, statement: string } +const handler = async (req: NextApiRequest, res: NextApiResponse) => { + if (req.method !== "POST") { + return res.status(405).json(false); + } + + const connection = req.body.connection as Connection; + const db = req.body.db as string; + const statement = req.body.statement as string; + try { + const connector = newConnector(connection); + const result = await connector.execute(db, statement); + res.status(200).json(result); + } catch (error) { + res.status(400).json([]); + } +}; + +export default handler; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c855ed0..ae99ec9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,6 +25,7 @@ specifiers: pg: ^8.10.0 postcss: ^8.4.20 react: ^18.2.0 + react-data-table-component: ^7.5.3 react-dom: ^18.2.0 react-hot-toast: ^2.4.0 react-icons: ^4.8.0 @@ -33,6 +34,7 @@ specifiers: react-syntax-highlighter: ^15.5.0 react-textarea-autosize: ^8.4.0 remark-gfm: ^3.0.1 + styled-components: ^5.3.9 tailwindcss: ^3.2.4 typescript: ^4.9.4 uuid: ^9.0.0 @@ -49,6 +51,7 @@ dependencies: lodash-es: 4.17.21 next: 13.2.4_biqbaboplfbrettd7655fr4n2y react: 18.2.0 + react-data-table-component: 7.5.3_hsvf4mq4wiyrkiyc3wzg6vdhxa react-dom: 18.2.0_react@18.2.0 react-hot-toast: 2.4.0_owo25xnefcwdq3zjgtohz6dbju react-icons: 4.8.0_react@18.2.0 @@ -56,6 +59,7 @@ dependencies: react-markdown: 8.0.6_pmekkgnqduwlme35zpnqhenc34 react-textarea-autosize: 8.4.0_pmekkgnqduwlme35zpnqhenc34 remark-gfm: 3.0.1 + styled-components: 5.3.9_biqbaboplfbrettd7655fr4n2y uuid: 9.0.0 zustand: 4.3.6_react@18.2.0 @@ -793,7 +797,7 @@ packages: babel-plugin-syntax-jsx: 6.18.0 lodash: 4.17.21 picomatch: 2.3.1 - styled-components: 5.3.9_7i5myeigehqah43i5u7wbekgba + styled-components: 5.3.9_biqbaboplfbrettd7655fr4n2y dev: false /babel-plugin-syntax-jsx/6.18.0: @@ -1099,6 +1103,11 @@ packages: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true + /deepmerge/4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + dev: false + /define-properties/1.2.0: resolution: {integrity: sha512-xvqAVKGfT1+UAvPwKTVw/njhdQ8ZhXK4lI0bCIuCMrp2up9nPnaDftrLtmpTazqd1o+UY4zgzU+avtMbDP+ldA==} engines: {node: '>= 0.4'} @@ -3078,6 +3087,17 @@ packages: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} + /react-data-table-component/7.5.3_hsvf4mq4wiyrkiyc3wzg6vdhxa: + resolution: {integrity: sha512-JhatRTgThAAa1HobPaPmkNPsjLT6+fnMIdtcXRCy+0bSYN7XJnTgob9Qyi4bjHh/8tMPTHtxZCV/TKiPwRvFMA==} + peerDependencies: + react: '>= 16.8.3' + styled-components: '>= 4' + dependencies: + deepmerge: 4.3.1 + react: 18.2.0 + styled-components: 5.3.9_biqbaboplfbrettd7655fr4n2y + dev: false + /react-dom/18.2.0_react@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -3484,6 +3504,28 @@ packages: supports-color: 5.5.0 dev: false + /styled-components/5.3.9_biqbaboplfbrettd7655fr4n2y: + resolution: {integrity: sha512-Aj3kb13B75DQBo2oRwRa/APdB5rSmwUfN5exyarpX+x/tlM/rwZA2vVk2vQgVSP6WKaZJHWwiFrzgHt+CLtB4A==} + engines: {node: '>=10'} + peerDependencies: + react: '>= 16.8.0' + react-dom: '>= 16.8.0' + react-is: '>= 16.8.0' + dependencies: + '@babel/helper-module-imports': 7.18.6 + '@babel/traverse': 7.21.3_supports-color@5.5.0 + '@emotion/is-prop-valid': 1.2.0 + '@emotion/stylis': 0.8.5 + '@emotion/unitless': 0.7.5 + babel-plugin-styled-components: 2.0.7_styled-components@5.3.9 + css-to-react-native: 3.2.0 + hoist-non-react-statics: 3.3.2 + react: 18.2.0 + react-dom: 18.2.0_react@18.2.0 + shallowequal: 1.1.0 + supports-color: 5.5.0 + dev: false + /styled-jsx/5.1.1_react@18.2.0: resolution: {integrity: sha512-pW7uC1l4mBZ8ugbiZrcIsiIvVx1UmTfw7UkC3Um2tmfUq9Bhk8IiyEIPl6F8agHgjzku6j0xQEZbfA5uSgSaCw==} engines: {node: '>= 12.0.0'}