mirror of
https://github.com/sqlchat/sqlchat.git
synced 2025-09-27 10:06:23 +08:00
feat: support run non-select SQL (#35)
This commit is contained in:
39
src/components/ExecutionView/DataTableView.tsx
Normal file
39
src/components/ExecutionView/DataTableView.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { head } from "lodash-es";
|
||||||
|
import DataTable from "react-data-table-component";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { RawResult } from "@/types";
|
||||||
|
import Icon from "../Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
rawResults: RawResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DataTableView = (props: Props) => {
|
||||||
|
const { rawResults } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const columns = Object.keys(head(rawResults) || {}).map((key) => {
|
||||||
|
return {
|
||||||
|
name: key,
|
||||||
|
sortable: true,
|
||||||
|
selector: (row: any) => row[key],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return rawResults.length === 0 ? (
|
||||||
|
<div className="w-full flex flex-col justify-center items-center py-6 pt-10">
|
||||||
|
<Icon.BsBox2 className="w-7 h-auto opacity-70" />
|
||||||
|
<span className="text-sm font-mono text-gray-500 mt-2">{t("execution.message.no-data")}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable
|
||||||
|
className="w-full border !rounded-lg dark:border-zinc-700"
|
||||||
|
columns={columns}
|
||||||
|
data={rawResults}
|
||||||
|
fixedHeader
|
||||||
|
pagination
|
||||||
|
responsive
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DataTableView;
|
22
src/components/ExecutionView/ExecutionWarningBanner.tsx
Normal file
22
src/components/ExecutionView/ExecutionWarningBanner.tsx
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import Icon from "../Icon";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ExecutionWarningBanner = (props: Props) => {
|
||||||
|
const { className } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${className || ""} relative w-full flex flex-row justify-start items-center px-4 py-2 bg-yellow-100 dark:bg-zinc-700`}>
|
||||||
|
<span className="text-sm leading-6 pr-4">
|
||||||
|
<Icon.IoInformationCircleOutline className="inline-block h-5 w-auto -mt-0.5 mr-0.5 opacity-80" />
|
||||||
|
{t("banner.non-select-sql-warning")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExecutionWarningBanner;
|
12
src/components/ExecutionView/NotificationView.tsx
Normal file
12
src/components/ExecutionView/NotificationView.tsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
interface Props {
|
||||||
|
message: string;
|
||||||
|
style: "info" | "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
const NotificationView = (props: Props) => {
|
||||||
|
const { message, style } = props;
|
||||||
|
const additionalStyle = style === "error" ? "text-red-500" : "text-gray-500";
|
||||||
|
return <p className={`${additionalStyle} w-full pl-4 mt-4 font-mono text-sm whitespace-pre-wrap`}>{message}</p>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NotificationView;
|
@ -1,41 +1,38 @@
|
|||||||
import { Drawer } from "@mui/material";
|
import { Drawer } from "@mui/material";
|
||||||
import { head } from "lodash-es";
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import DataTable from "react-data-table-component";
|
|
||||||
import { toast } from "react-hot-toast";
|
import { toast } from "react-hot-toast";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import TextareaAutosize from "react-textarea-autosize";
|
import TextareaAutosize from "react-textarea-autosize";
|
||||||
import { useQueryStore } from "@/store";
|
import { useQueryStore } from "@/store";
|
||||||
import { ResponseObject } from "@/types";
|
import { ExecutionResult, ResponseObject } from "@/types";
|
||||||
|
import { checkStatementIsSelect, getMessageFromExecutionResult } from "@/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import EngineIcon from "./EngineIcon";
|
import EngineIcon from "./EngineIcon";
|
||||||
|
import DataTableView from "./ExecutionView/DataTableView";
|
||||||
type RawQueryResult = {
|
import NotificationView from "./ExecutionView/NotificationView";
|
||||||
[key: string]: any;
|
import ExecutionWarningBanner from "./ExecutionView/ExecutionWarningBanner";
|
||||||
};
|
|
||||||
|
|
||||||
const QueryDrawer = () => {
|
const QueryDrawer = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryStore = useQueryStore();
|
const queryStore = useQueryStore();
|
||||||
const [rawResults, setRawResults] = useState<RawQueryResult[]>([]);
|
const [executionResult, setExecutionResult] = useState<ExecutionResult | undefined>(undefined);
|
||||||
|
const [statement, setStatement] = useState<string>("");
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const context = queryStore.context;
|
const context = queryStore.context;
|
||||||
const [statement, setStatement] = useState<string>(context?.statement || "");
|
const executionMessage = executionResult ? getMessageFromExecutionResult(executionResult) : "";
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const showExecutionWarningBanner = !checkStatementIsSelect(statement);
|
||||||
const columns = Object.keys(head(rawResults) || {}).map((key) => {
|
|
||||||
return {
|
|
||||||
name: key,
|
|
||||||
sortable: true,
|
|
||||||
selector: (row: RawQueryResult) => row[key],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!queryStore.showDrawer) {
|
if (!queryStore.showDrawer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setStatement(context?.statement || "");
|
const statement = context?.statement || "";
|
||||||
executeStatement(context?.statement || "");
|
setStatement(statement);
|
||||||
|
if (statement !== "" && checkStatementIsSelect(statement)) {
|
||||||
|
executeStatement(statement);
|
||||||
|
}
|
||||||
|
setExecutionResult(undefined);
|
||||||
}, [context, queryStore.showDrawer]);
|
}, [context, queryStore.showDrawer]);
|
||||||
|
|
||||||
const executeStatement = async (statement: string) => {
|
const executeStatement = async (statement: string) => {
|
||||||
@ -47,12 +44,12 @@ const QueryDrawer = () => {
|
|||||||
if (!context) {
|
if (!context) {
|
||||||
toast.error("No execution context found.");
|
toast.error("No execution context found.");
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setRawResults([]);
|
setExecutionResult(undefined);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setRawResults([]);
|
setExecutionResult(undefined);
|
||||||
const { connection, database } = context;
|
const { connection, database } = context;
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/connection/execute", {
|
const response = await fetch("/api/connection/execute", {
|
||||||
@ -66,11 +63,14 @@ const QueryDrawer = () => {
|
|||||||
statement,
|
statement,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const result = (await response.json()) as ResponseObject<RawQueryResult[]>;
|
const result = (await response.json()) as ResponseObject<ExecutionResult>;
|
||||||
if (result.message) {
|
if (result.message) {
|
||||||
toast.error(result.message);
|
setExecutionResult({
|
||||||
|
rawResult: [],
|
||||||
|
error: result.message,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
setRawResults(result.data);
|
setExecutionResult(result.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@ -101,6 +101,7 @@ const QueryDrawer = () => {
|
|||||||
<EngineIcon className="w-6 h-auto" engine={context.connection.engineType} />
|
<EngineIcon className="w-6 h-auto" engine={context.connection.engineType} />
|
||||||
<span>{context.database?.name}</span>
|
<span>{context.database?.name}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{showExecutionWarningBanner && <ExecutionWarningBanner className="rounded-lg mt-4" />}
|
||||||
<div className="w-full h-auto mt-4 px-2 flex flex-row justify-between items-end border dark:border-zinc-700 rounded-lg overflow-clip">
|
<div className="w-full h-auto mt-4 px-2 flex flex-row justify-between items-end border dark:border-zinc-700 rounded-lg overflow-clip">
|
||||||
<TextareaAutosize
|
<TextareaAutosize
|
||||||
className="w-full h-full outline-none border-none bg-transparent leading-6 pl-2 py-2 resize-none hide-scrollbar text-sm font-mono break-all"
|
className="w-full h-full outline-none border-none bg-transparent leading-6 pl-2 py-2 resize-none hide-scrollbar text-sm font-mono break-all"
|
||||||
@ -124,22 +125,18 @@ const QueryDrawer = () => {
|
|||||||
<Icon.BiLoaderAlt className="w-7 h-auto opacity-70 animate-spin" />
|
<Icon.BiLoaderAlt className="w-7 h-auto opacity-70 animate-spin" />
|
||||||
<span className="text-sm font-mono text-gray-500 mt-2">{t("execution.message.executing")}</span>
|
<span className="text-sm font-mono text-gray-500 mt-2">{t("execution.message.executing")}</span>
|
||||||
</div>
|
</div>
|
||||||
) : rawResults.length === 0 ? (
|
|
||||||
<div className="w-full flex flex-col justify-center items-center py-6 pt-10">
|
|
||||||
<Icon.BsBox2 className="w-7 h-auto opacity-70" />
|
|
||||||
<span className="text-sm font-mono text-gray-500 mt-2">{t("execution.message.no-data")}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full">
|
<>
|
||||||
<DataTable
|
{executionResult ? (
|
||||||
className="w-full border !rounded-lg dark:border-zinc-700"
|
executionMessage ? (
|
||||||
columns={columns}
|
<NotificationView message={executionMessage} style={executionResult?.error ? "error" : "info"} />
|
||||||
data={rawResults}
|
) : (
|
||||||
fixedHeader
|
<DataTableView rawResults={executionResult?.rawResult || []} />
|
||||||
pagination
|
)
|
||||||
responsive
|
) : (
|
||||||
/>
|
<></>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
@ -1,11 +1,11 @@
|
|||||||
import { Connection, Engine } from "@/types";
|
import { Connection, Engine, ExecutionResult } from "@/types";
|
||||||
import mysql from "./mysql";
|
import mysql from "./mysql";
|
||||||
import postgres from "./postgres";
|
import postgres from "./postgres";
|
||||||
import mssql from "./mssql";
|
import mssql from "./mssql";
|
||||||
|
|
||||||
export interface Connector {
|
export interface Connector {
|
||||||
testConnection: () => Promise<boolean>;
|
testConnection: () => Promise<boolean>;
|
||||||
execute: (databaseName: string, statement: string) => Promise<any>;
|
execute: (databaseName: string, statement: string) => Promise<ExecutionResult>;
|
||||||
getDatabases: () => Promise<string[]>;
|
getDatabases: () => Promise<string[]>;
|
||||||
getTables: (databaseName: string) => Promise<string[]>;
|
getTables: (databaseName: string) => Promise<string[]>;
|
||||||
getTableStructure: (databaseName: string, tableName: string) => Promise<string>;
|
getTableStructure: (databaseName: string, tableName: string) => Promise<string>;
|
||||||
@ -17,8 +17,8 @@ export const newConnector = (connection: Connection): Connector => {
|
|||||||
return mysql(connection);
|
return mysql(connection);
|
||||||
case Engine.PostgreSQL:
|
case Engine.PostgreSQL:
|
||||||
return postgres(connection);
|
return postgres(connection);
|
||||||
case Engine.MSSQL:
|
case Engine.MSSQL:
|
||||||
return mssql(connection);
|
return mssql(connection);
|
||||||
default:
|
default:
|
||||||
throw new Error("Unsupported engine type.");
|
throw new Error("Unsupported engine type.");
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { ConnectionPool } from "mssql";
|
import { ConnectionPool } from "mssql";
|
||||||
import { Connection } from "@/types";
|
import { Connection, ExecutionResult } from "@/types";
|
||||||
import { Connector } from "..";
|
import { Connector } from "..";
|
||||||
|
|
||||||
const systemDatabases = ["master", "tempdb", "model", "msdb"];
|
const systemDatabases = ["master", "tempdb", "model", "msdb"];
|
||||||
@ -37,7 +37,12 @@ const execute = async (connection: Connection, databaseName: string, statement:
|
|||||||
const request = pool.request();
|
const request = pool.request();
|
||||||
const result = await request.query(`USE ${databaseName}; ${statement}`);
|
const result = await request.query(`USE ${databaseName}; ${statement}`);
|
||||||
await pool.close();
|
await pool.close();
|
||||||
return result.recordset;
|
|
||||||
|
const executionResult: ExecutionResult = {
|
||||||
|
rawResult: result.recordset,
|
||||||
|
affectedRows: result.rowsAffected.length,
|
||||||
|
};
|
||||||
|
return executionResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import { ConnectionOptions } from "mysql2";
|
import { ConnectionOptions } from "mysql2";
|
||||||
import mysql, { RowDataPacket } from "mysql2/promise";
|
import mysql, { RowDataPacket } from "mysql2/promise";
|
||||||
import { Connection } from "@/types";
|
import { Connection, ExecutionResult } from "@/types";
|
||||||
import { Connector } from "..";
|
import { Connector } from "..";
|
||||||
|
|
||||||
const systemDatabases = ["information_schema", "mysql", "performance_schema", "sys"];
|
const systemDatabases = ["information_schema", "mysql", "performance_schema", "sys"];
|
||||||
@ -33,9 +33,19 @@ const testConnection = async (connection: Connection): Promise<boolean> => {
|
|||||||
const execute = async (connection: Connection, databaseName: string, statement: string): Promise<any> => {
|
const execute = async (connection: Connection, databaseName: string, statement: string): Promise<any> => {
|
||||||
connection.database = databaseName;
|
connection.database = databaseName;
|
||||||
const conn = await getMySQLConnection(connection);
|
const conn = await getMySQLConnection(connection);
|
||||||
const [rows] = await conn.query<RowDataPacket[]>(statement);
|
const [rows] = await conn.execute(statement);
|
||||||
conn.destroy();
|
conn.destroy();
|
||||||
return rows;
|
|
||||||
|
const executionResult: ExecutionResult = {
|
||||||
|
rawResult: [],
|
||||||
|
affectedRows: 0,
|
||||||
|
};
|
||||||
|
if (Array.isArray(rows)) {
|
||||||
|
executionResult.rawResult = rows;
|
||||||
|
} else {
|
||||||
|
executionResult.affectedRows = rows.affectedRows;
|
||||||
|
}
|
||||||
|
return executionResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import { Client, ClientConfig } from "pg";
|
import { Client, ClientConfig } from "pg";
|
||||||
import { Connection } from "@/types";
|
import { Connection, ExecutionResult } from "@/types";
|
||||||
import { Connector } from "..";
|
import { Connector } from "..";
|
||||||
|
|
||||||
const newPostgresClient = (connection: Connection) => {
|
const newPostgresClient = (connection: Connection) => {
|
||||||
@ -27,12 +27,18 @@ const testConnection = async (connection: Connection): Promise<boolean> => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const execute = async (connection: Connection, _: string, statement: string): Promise<any> => {
|
const execute = async (connection: Connection, databaseName: string, statement: string): Promise<any> => {
|
||||||
|
connection.database = databaseName;
|
||||||
const client = newPostgresClient(connection);
|
const client = newPostgresClient(connection);
|
||||||
await client.connect();
|
await client.connect();
|
||||||
const { rows } = await client.query(statement);
|
const { rows, rowCount } = await client.query(statement);
|
||||||
await client.end();
|
await client.end();
|
||||||
return rows;
|
|
||||||
|
const executionResult: ExecutionResult = {
|
||||||
|
rawResult: rows,
|
||||||
|
affectedRows: rowCount,
|
||||||
|
};
|
||||||
|
return executionResult;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
const getDatabases = async (connection: Connection): Promise<string[]> => {
|
||||||
|
@ -57,6 +57,7 @@
|
|||||||
"join-wechat-group": "Join WeChat Group"
|
"join-wechat-group": "Join WeChat Group"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"data-storage": "Connection settings are stored in your local browser"
|
"data-storage": "Connection settings are stored in your local browser",
|
||||||
|
"non-select-sql-warning": "The current statement may be non-SELECT SQL, which will result in a database schema or data change. Make sure you know what you are doing."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,7 @@
|
|||||||
"join-wechat-group": "加入微信群"
|
"join-wechat-group": "加入微信群"
|
||||||
},
|
},
|
||||||
"banner": {
|
"banner": {
|
||||||
"data-storage": "连接设置存储在您的本地浏览器中"
|
"data-storage": "连接设置存储在您的本地浏览器中",
|
||||||
|
"non-select-sql-warning": "当前语句可能是非 SELECT SQL,这将导致数据库模式或数据变化。"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { NextApiRequest, NextApiResponse } from "next";
|
import { NextApiRequest, NextApiResponse } from "next";
|
||||||
import { newConnector } from "@/lib/connectors";
|
import { newConnector } from "@/lib/connectors";
|
||||||
import { Connection } from "@/types";
|
import { Connection } from "@/types";
|
||||||
import { checkStatementIsSelect } from "@/utils";
|
|
||||||
|
|
||||||
// POST /api/connection/execute
|
// POST /api/connection/execute
|
||||||
// req body: { connection: Connection, db: string, statement: string }
|
// req body: { connection: Connection, db: string, statement: string }
|
||||||
@ -13,10 +12,6 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||||||
const connection = req.body.connection as Connection;
|
const connection = req.body.connection as Connection;
|
||||||
const db = req.body.db as string;
|
const db = req.body.db as string;
|
||||||
const statement = req.body.statement as string;
|
const statement = req.body.statement as string;
|
||||||
// We only support SELECT statements for now.
|
|
||||||
if (!checkStatementIsSelect(statement)) {
|
|
||||||
return res.status(400).json([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const connector = newConnector(connection);
|
const connector = newConnector(connection);
|
||||||
|
9
src/types/connector.ts
Normal file
9
src/types/connector.ts
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
export type RawResult = {
|
||||||
|
[key: string]: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ExecutionResult {
|
||||||
|
rawResult: RawResult[];
|
||||||
|
affectedRows?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
@ -6,3 +6,4 @@ export * from "./conversation";
|
|||||||
export * from "./message";
|
export * from "./message";
|
||||||
export * from "./setting";
|
export * from "./setting";
|
||||||
export * from "./api";
|
export * from "./api";
|
||||||
|
export * from "./connector";
|
||||||
|
11
src/utils/execution.ts
Normal file
11
src/utils/execution.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { ExecutionResult } from "@/types";
|
||||||
|
|
||||||
|
export const getMessageFromExecutionResult = (result: ExecutionResult): string => {
|
||||||
|
if (result.error) {
|
||||||
|
return result.error;
|
||||||
|
}
|
||||||
|
if (result.affectedRows) {
|
||||||
|
return `${result.affectedRows} rows affected.`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
};
|
@ -1,3 +1,4 @@
|
|||||||
export * from "./id";
|
export * from "./id";
|
||||||
export * from "./openai";
|
export * from "./openai";
|
||||||
export * from "./sql";
|
export * from "./sql";
|
||||||
|
export * from "./execution";
|
||||||
|
Reference in New Issue
Block a user