feat: implement mysql connector

This commit is contained in:
steven
2023-03-23 13:36:07 +08:00
parent bad550bdea
commit eb978bd669
11 changed files with 297 additions and 8 deletions

View File

@ -1,6 +1,7 @@
{
"extends": "next/core-web-vitals",
"rules": {
"import/no-anonymous-default-export": "off",
"@next/next/no-img-element": "off",
"react/jsx-no-target-blank": "off"
}

18
lib/connectors/index.ts Normal file
View File

@ -0,0 +1,18 @@
import { Connection } from "@/types";
import mysql from "./mysql";
export interface Connector {
testConnection: () => Promise<boolean>;
getDatabases: () => Promise<string[]>;
getTables: (databaseName: string) => Promise<string[]>;
getTableStructure: (databaseName: string, tableName: string) => Promise<string>;
}
export const newConnector = (connection: Connection): Connector => {
switch (connection.engineType) {
case "MYSQL":
return mysql(connection);
default:
throw new Error("Unsupported engine type.");
}
};

View File

@ -0,0 +1,71 @@
import { Connection } from "@/types";
import mysql, { RowDataPacket } from "mysql2/promise";
import { Connector } from "..";
const convertToConnectionUrl = (connection: Connection): string => {
// Connection URL format: mysql://USER:PASSWORD@HOST:PORT/DATABASE
return `mysql://${connection.username}:${connection.password}@${connection.host}:${connection.port}/${connection.database ?? ""}`;
};
const testConnection = async (connection: Connection): Promise<boolean> => {
const connectionUrl = convertToConnectionUrl(connection);
try {
const conn = await mysql.createConnection(connectionUrl);
conn.destroy();
return true;
} catch (error) {
return false;
}
};
const getDatabases = async (connection: Connection): Promise<string[]> => {
const connectionUrl = convertToConnectionUrl(connection);
const conn = await mysql.createConnection(connectionUrl);
const [rows] = await conn.query<RowDataPacket[]>("SELECT schema_name as db_name FROM information_schema.schemata;");
conn.destroy();
const databaseList = [];
for (const row of rows) {
if (row["db_name"]) {
databaseList.push(row["db_name"]);
}
}
return databaseList;
};
const getTables = async (connection: Connection, databaseName: string): Promise<string[]> => {
const connectionUrl = convertToConnectionUrl(connection);
const conn = await mysql.createConnection(connectionUrl);
const [rows] = await conn.query<RowDataPacket[]>(
`SELECT TABLE_NAME as table_name FROM information_schema.tables WHERE TABLE_SCHEMA='${databaseName}' AND TABLE_TYPE='BASE TABLE';`
);
conn.destroy();
const databaseList = [];
for (const row of rows) {
if (row["table_name"]) {
databaseList.push(row["table_name"]);
}
}
return databaseList;
};
const getTableStructure = async (connection: Connection, databaseName: string, tableName: string): Promise<string> => {
const connectionUrl = convertToConnectionUrl(connection);
const conn = await mysql.createConnection(connectionUrl);
const [rows] = await conn.query<RowDataPacket[]>(`SHOW CREATE TABLE \`${databaseName}\`.\`${tableName}\`;`);
conn.destroy();
if (rows.length !== 1) {
throw new Error("Unexpected number of rows.");
}
return rows[0]["Create Table"] || "";
};
const newConnector = (connection: Connection): Connector => {
return {
testConnection: () => testConnection(connection),
getDatabases: () => getDatabases(connection),
getTables: (databaseName: string) => getTables(connection, databaseName),
getTableStructure: (databaseName: string, tableName: string) => getTableStructure(connection, databaseName, tableName),
};
};
export default newConnector;

View File

@ -35,6 +35,7 @@
"autoprefixer": "^10.4.13",
"eslint": "8.20.0",
"eslint-config-next": "12.2.3",
"mysql2": "^3.2.0",
"postcss": "^8.4.20",
"tailwindcss": "^3.2.4",
"typescript": "^4.9.4"

View File

@ -0,0 +1,25 @@
import { NextApiRequest, NextApiResponse } from "next";
import { newConnector } from "@/lib/connectors";
import { Connection } from "@/types";
// POST /api/connection/db
// req body: { connection: Connection }
// It's mainly used to get the database list.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json([]);
}
const connection = req.body.connection as Connection;
try {
const connector = newConnector(connection);
const databaseNameList = await connector.getDatabases();
res.status(200).json(databaseNameList);
} catch (error) {
res.status(400).json({
error: error,
});
}
};
export default handler;

View File

@ -0,0 +1,31 @@
import { NextApiRequest, NextApiResponse } from "next";
import { newConnector } from "@/lib/connectors";
import { Connection } from "@/types";
// POST /api/connection/db_schema
// req body: { connection: Connection, db: string }
// It's mainly used to get the database list.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json([]);
}
const connection = req.body.connection as Connection;
const db = req.body.db as string;
try {
const connector = newConnector(connection);
const tableStructures: string[] = [];
const tables = await connector.getTables(db);
for (const table of tables) {
const structure = await connector.getTableStructure(db, table);
tableStructures.push(structure);
}
res.status(200).json(tableStructures);
} catch (error) {
res.status(400).json({
error: error,
});
}
};
export default handler;

View File

@ -0,0 +1,23 @@
import { NextApiRequest, NextApiResponse } from "next";
import { newConnector } from "@/lib/connectors";
import { Connection } from "@/types";
// POST /api/connection/test
// req body: { connection: Connection }
// It's mainly used to test the connection.
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (req.method !== "POST") {
return res.status(405).json(false);
}
const connection = req.body.connection as Connection;
try {
const connector = newConnector(connection);
const result = await connector.testConnection();
res.status(200).json(result);
} catch (error) {
res.status(400).json(false);
}
};
export default handler;

67
pnpm-lock.yaml generated
View File

@ -18,6 +18,7 @@ specifiers:
highlight.js: ^11.7.0
lodash-es: ^4.17.21
marked: ^4.2.12
mysql2: ^3.2.0
next: ^13.2.4
openai: ^3.0.0
postcss: ^8.4.20
@ -60,6 +61,7 @@ devDependencies:
autoprefixer: 10.4.14_postcss@8.4.21
eslint: 8.20.0
eslint-config-next: 12.2.3_bqegqxcnsisudkhpmmezgt6uoa
mysql2: 3.2.0
postcss: 8.4.21
tailwindcss: 3.2.7_postcss@8.4.21
typescript: 4.9.5
@ -779,6 +781,11 @@ packages:
engines: {node: '>=0.4.0'}
dev: false
/denque/2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
dev: true
/detective/5.2.1:
resolution: {integrity: sha512-v9XE1zRnz1wRtgurGu0Bs8uHKFSTdteYZNbIPFVhUZ39L/S79ppMpdmVOZAnoz1jfEFodc48n6MX483Xo3t1yw==}
engines: {node: '>=0.8.0'}
@ -1311,6 +1318,12 @@ packages:
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
dev: true
/generate-function/2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
dependencies:
is-property: 1.0.2
dev: true
/get-intrinsic/1.2.0:
resolution: {integrity: sha512-L049y6nFOuom5wGyRc3/gdTLO94dySVKRACj1RmJZBQXlbTMhtNIgkWkUHq+jYmZvKf14EW1EoJnnjbmoHij0Q==}
dependencies:
@ -1447,6 +1460,13 @@ packages:
engines: {node: '>=12.0.0'}
dev: false
/iconv-lite/0.6.3:
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
engines: {node: '>=0.10.0'}
dependencies:
safer-buffer: 2.1.2
dev: true
/ignore/5.2.4:
resolution: {integrity: sha512-MAb38BcSbH0eHNBxn7ql2NH/kX33OkB3lZ1BNdh7ENeRChHTYsTvWrMubiIAMNS2llXEEgZ1MUOBtXChP3kaFQ==}
engines: {node: '>= 4'}
@ -1573,6 +1593,10 @@ packages:
engines: {node: '>=0.12.0'}
dev: true
/is-property/1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
dev: true
/is-regex/1.1.4:
resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==}
engines: {node: '>= 0.4'}
@ -1713,6 +1737,10 @@ packages:
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
dev: true
/long/5.2.1:
resolution: {integrity: sha512-GKSNGeNAtw8IryjjkhZxuKB3JzlcLTwjtiQCHKvqQet81I93kXslhDQruGI/QsddO83mcDToBVy7GqGS/zYf/A==}
dev: true
/loose-envify/1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
@ -1726,6 +1754,11 @@ packages:
yallist: 4.0.0
dev: true
/lru-cache/7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
dev: true
/marked/4.2.12:
resolution: {integrity: sha512-yr8hSKa3Fv4D3jdZmtMMPghgVt6TWbk86WQaWhDloQjRSQhMMYCAro7jP7VDJrjjdV8pxVxMssXS8B8Y5DZ5aw==}
engines: {node: '>= 12'}
@ -1775,6 +1808,27 @@ packages:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
dev: true
/mysql2/3.2.0:
resolution: {integrity: sha512-0Vn6a9WSrq6fWwvPgrvIwnOCldiEcgbzapVRDAtDZ4cMTxN7pnGqCTx8EG32S/NYXl6AXkdO+9hV1tSIi/LigA==}
engines: {node: '>= 8.0'}
dependencies:
denque: 2.1.0
generate-function: 2.3.1
iconv-lite: 0.6.3
long: 5.2.1
lru-cache: 7.18.3
named-placeholders: 1.1.3
seq-queue: 0.0.5
sqlstring: 2.3.3
dev: true
/named-placeholders/1.1.3:
resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
engines: {node: '>=12.0.0'}
dependencies:
lru-cache: 7.18.3
dev: true
/nanoid/3.3.4:
resolution: {integrity: sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@ -2236,6 +2290,10 @@ packages:
is-regex: 1.1.4
dev: true
/safer-buffer/2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
dev: true
/scheduler/0.23.0:
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
dependencies:
@ -2255,6 +2313,10 @@ packages:
lru-cache: 6.0.0
dev: true
/seq-queue/0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
dev: true
/shebang-command/2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
@ -2284,6 +2346,11 @@ packages:
resolution: {integrity: sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==}
engines: {node: '>=0.10.0'}
/sqlstring/2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
dev: true
/stop-iteration-iterator/1.0.0:
resolution: {integrity: sha512-iCGQj+0l0HOdZ2AEeBADlsRC+vsnDsZsbdSiH1yNSjcfKM7fdpCMfqAL/dwF5BLiw/XhRft/Wax6zQbhq2BcjQ==}
engines: {node: '>= 0.4'}

View File

@ -1,10 +1,23 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import { Connection, Database } from "@/types";
import { Connection, Database, Engine, UNKNOWN_ID } from "@/types";
import { generateUUID } from "@/utils";
import axios from "axios";
import { uniqBy } from "lodash-es";
export const connectionSampleData: Connection = {
id: UNKNOWN_ID,
title: "",
engineType: Engine.MySQL,
host: "127.0.0.1",
port: "3306",
username: "root",
password: "",
};
interface ConnectionContext {
connection: Connection;
database: Database;
database?: Database;
}
interface ConnectionState {
@ -13,25 +26,64 @@ interface ConnectionState {
currentConnectionCtx?: ConnectionContext;
createConnection: (connection: Connection) => void;
setCurrentConnectionCtx: (connectionCtx: ConnectionContext) => void;
getOrFetchDatabaseList: (connection: Connection) => Promise<Database[]>;
}
export const useConnectionStore = create<ConnectionState>()(
persist(
(set) => ({
connectionList: [],
(set, get) => ({
connectionList: [connectionSampleData],
databaseList: [],
createConnection: (connection: Connection) => {
set((state) => ({
connectionList: [...state.connectionList, connection],
...state,
connectionList: [
...state.connectionList,
{
...connection,
id: generateUUID(),
},
],
}));
},
setCurrentConnectionCtx: (connectionCtx: ConnectionContext) =>
set(() => ({
set((state) => ({
...state,
currentConnectionCtx: connectionCtx,
})),
getOrFetchDatabaseList: async (connection: Connection) => {
const { data } = await axios.post<string[]>("/api/connection/db", {
connection,
});
const fetchedDatabaseList = data.map(
(dbName) =>
({
connectionId: connection.id,
name: dbName,
tableList: {},
} as Database)
);
const state = get();
const databaseList = uniqBy(
[...state.databaseList, ...fetchedDatabaseList],
(database) => `${database.connectionId}_${database.name}`
);
set((state) => ({
...state,
databaseList,
}));
return databaseList.filter((database) => database.connectionId === connection.id);
},
}),
{
name: "connection-storage",
}
)
);
export const testConnection = async (connection: Connection) => {
const { data: result } = await axios.post<boolean>("/api/connection/test", {
connection,
});
return result;
};

View File

@ -1,6 +1,6 @@
import { Id } from "./";
enum Engine {
export enum Engine {
MySQL = "MYSQL",
PostgreSQL = "POSTGRESQL",
}

View File

@ -6,7 +6,7 @@ export interface Database {
tableList: Table[];
}
interface Table {
export interface Table {
name: string;
// structure is a string of the table structure.
// It's mainly used for providing a chat context for the assistant.