mirror of
https://github.com/ershisan99/db-studio.git
synced 2025-12-16 05:09:26 +00:00
wip: mysql driver
add tests for pg driver and mysql driver (in progress)
This commit is contained in:
BIN
api/bun.lockb
BIN
api/bun.lockb
Binary file not shown.
32
api/docker-compose.yml
Normal file
32
api/docker-compose.yml
Normal file
@@ -0,0 +1,32 @@
|
||||
version: '3.3'
|
||||
|
||||
services:
|
||||
adminer:
|
||||
image: adminer
|
||||
restart: always
|
||||
ports:
|
||||
- "8080:8080"
|
||||
postgres:
|
||||
container_name: db-postgres-test
|
||||
image: postgres:latest
|
||||
environment:
|
||||
POSTGRES_PASSWORD: mysecretpassword
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
||||
db:
|
||||
container_name: db-mysql-test
|
||||
image: mysql:latest
|
||||
environment:
|
||||
- MYSQL_PASSWORD=mysecretpassword
|
||||
- MYSQL_ROOT_PASSWORD=mysecretpassword
|
||||
ports:
|
||||
- "3306:3306"
|
||||
volumes:
|
||||
- mysqldata:/var/lib/mysql
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
mysqldata:
|
||||
@@ -7,9 +7,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@elysiajs/cors": "^1.0.2",
|
||||
"@elysiajs/eden": "^1.0.14",
|
||||
"@elysiajs/jwt": "^1.0.2",
|
||||
"@it-incubator/prettier-config": "^0.1.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"cookie": "^0.6.0",
|
||||
"elysia": "latest",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.10.2",
|
||||
"postgres": "^3.4.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
32
api/scripts/setup-test-db.sh
Normal file
32
api/scripts/setup-test-db.sh
Normal file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Start Docker containers
|
||||
docker-compose up -d
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
until docker exec db-postgres-test pg_isready -U postgres; do
|
||||
>&2 echo "Postgres is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Wait for MySQL to be ready
|
||||
until docker exec db-mysql-test mysqladmin ping -h "localhost" --silent; do
|
||||
>&2 echo "MySQL is unavailable - sleeping"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Set up PostgreSQL test data
|
||||
docker exec -i db-postgres-test psql -U postgres <<EOF
|
||||
CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name VARCHAR(50));
|
||||
INSERT INTO test_table (name) VALUES ('John Doe');
|
||||
EOF
|
||||
|
||||
# Set up MySQL test data
|
||||
docker exec -i db-mysql-test mysql -uroot -pmysecretpassword<<EOF
|
||||
CREATE DATABASE IF NOT EXISTS test_db;
|
||||
USE test_db;
|
||||
CREATE TABLE IF NOT EXISTS test_table (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
|
||||
INSERT INTO test_table (name) VALUES ('Jane Doe');
|
||||
EOF
|
||||
|
||||
echo "Test databases are ready"
|
||||
16
api/scripts/teardown-test-db.sh
Normal file
16
api/scripts/teardown-test-db.sh
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Clean up PostgreSQL test data
|
||||
docker exec -i db-postgres-test psql -U postgres <<EOF
|
||||
DROP TABLE IF EXISTS test_table;
|
||||
EOF
|
||||
|
||||
# Clean up MySQL test data
|
||||
docker exec -i db-mysql-test mysql -uroot -pmysecretpassword --database=test_db<<EOF
|
||||
DROP TABLE IF EXISTS test_table;
|
||||
EOF
|
||||
|
||||
# Stop and remove Docker containers and volumes
|
||||
docker-compose down -v
|
||||
|
||||
echo "Test databases are cleaned up"
|
||||
376
api/src/drivers/mysql.ts
Normal file
376
api/src/drivers/mysql.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import mysql, { type ResultSetHeader } from "mysql2/promise";
|
||||
import type {
|
||||
Credentials,
|
||||
Driver,
|
||||
WithSort,
|
||||
WithSortPagination,
|
||||
} from "./driver.interface";
|
||||
|
||||
const isResultSetHeader = (data: unknown): data is ResultSetHeader => {
|
||||
if (!data || typeof data !== "object") return false;
|
||||
|
||||
const keys = [
|
||||
"fieldCount",
|
||||
"affectedRows",
|
||||
"insertId",
|
||||
"info",
|
||||
"serverStatus",
|
||||
"warningStatus",
|
||||
"changedRows",
|
||||
];
|
||||
|
||||
return keys.every((key) => key in data);
|
||||
};
|
||||
|
||||
export class MySQLDriver implements Driver {
|
||||
parseCredentials({
|
||||
username,
|
||||
password,
|
||||
host,
|
||||
type,
|
||||
port,
|
||||
database,
|
||||
}: {
|
||||
username: string;
|
||||
password: string;
|
||||
host: string;
|
||||
type: string;
|
||||
port: string;
|
||||
database: string;
|
||||
ssl: string;
|
||||
}) {
|
||||
return {
|
||||
user: username,
|
||||
password,
|
||||
host,
|
||||
type,
|
||||
port: Number.parseInt(port, 10),
|
||||
database,
|
||||
ssl: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async queryRunner(credentials: Credentials) {
|
||||
let connection: mysql.Connection;
|
||||
try {
|
||||
if ("connectionString" in credentials) {
|
||||
connection = await mysql.createConnection(credentials.connectionString);
|
||||
} else {
|
||||
connection = await mysql.createConnection(
|
||||
this.parseCredentials(credentials),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
throw new Error(`Invalid connection string, ${JSON.stringify(error)}`);
|
||||
}
|
||||
|
||||
return connection;
|
||||
}
|
||||
private getActions(matchString: string) {
|
||||
const onActions = "RESTRICT|NO ACTION|CASCADE|SET NULL|SET DEFAULT";
|
||||
const onDeleteRegex = new RegExp(`ON DELETE (${onActions})`);
|
||||
const onUpdateRegex = new RegExp(`ON UPDATE (${onActions})`);
|
||||
|
||||
const onDeleteMatch = matchString.match(onDeleteRegex);
|
||||
const onUpdateMatch = matchString.match(onUpdateRegex);
|
||||
|
||||
const onDeleteAction = onDeleteMatch ? onDeleteMatch[1] : "NO ACTION";
|
||||
const onUpdateAction = onUpdateMatch ? onUpdateMatch[1] : "NO ACTION";
|
||||
|
||||
return {
|
||||
onDelete: onDeleteAction,
|
||||
onUpdate: onUpdateAction,
|
||||
};
|
||||
}
|
||||
|
||||
async getAllDatabases(credentials: Credentials) {
|
||||
console.log("Get all databases");
|
||||
const connection = await this.queryRunner(credentials);
|
||||
|
||||
const databases: Array<string> = [];
|
||||
const [databases_raw] = await connection.query("SHOW DATABASES;"); // Get all databases
|
||||
|
||||
for (const db of Array.from(databases_raw)) {
|
||||
databases.push(db.Database);
|
||||
}
|
||||
connection.destroy();
|
||||
return databases;
|
||||
}
|
||||
|
||||
async getAllTables(
|
||||
credentials: Credentials,
|
||||
{ sortDesc, sortField, dbName }: WithSort<{ dbName: string }>,
|
||||
) {
|
||||
const connection = await this.queryRunner(credentials);
|
||||
|
||||
const tablesQuery = `
|
||||
SELECT
|
||||
TABLE_NAME as table_name,
|
||||
TABLE_SCHEMA as schema_name,
|
||||
TABLE_ROWS as row_count,
|
||||
(DATA_LENGTH + INDEX_LENGTH) as total_size,
|
||||
DATA_LENGTH as table_size,
|
||||
INDEX_LENGTH as index_size,
|
||||
TABLE_COMMENT as comments
|
||||
FROM
|
||||
information_schema.tables
|
||||
WHERE
|
||||
table_schema = ?;
|
||||
`;
|
||||
|
||||
const [tables] = await connection.execute(tablesQuery, [dbName]);
|
||||
|
||||
const primaryKeysQuery = `
|
||||
SELECT
|
||||
TABLE_NAME,
|
||||
COLUMN_NAME
|
||||
FROM
|
||||
information_schema.KEY_COLUMN_USAGE
|
||||
WHERE
|
||||
TABLE_SCHEMA = ? AND
|
||||
CONSTRAINT_NAME = 'PRIMARY';
|
||||
`;
|
||||
|
||||
const [primaryKeys] = await connection.execute(primaryKeysQuery, [dbName]);
|
||||
|
||||
const indexesQuery = `
|
||||
SELECT
|
||||
TABLE_NAME
|
||||
FROM
|
||||
information_schema.STATISTICS
|
||||
WHERE
|
||||
TABLE_SCHEMA = ?;
|
||||
`;
|
||||
|
||||
const [indexes] = await connection.execute(indexesQuery, [dbName]);
|
||||
|
||||
const formattedTables = tables.map((table) => {
|
||||
const primaryKey = primaryKeys
|
||||
.filter((pk) => pk.TABLE_NAME === table.table_name)
|
||||
.map((pk) => pk.COLUMN_NAME)
|
||||
.join(", ");
|
||||
|
||||
const tableIndexes = indexes
|
||||
.filter((idx) => idx.TABLE_NAME === table.table_name)
|
||||
.map((idx) => idx.TABLE_NAME)
|
||||
.join(", ");
|
||||
|
||||
return {
|
||||
comments: table.comments,
|
||||
index_size: table.index_size,
|
||||
indexes: tableIndexes,
|
||||
owner: null, // No information on table owner in `information_schema`
|
||||
primary_key: primaryKey,
|
||||
row_count: table.row_count,
|
||||
table_name: table.table_name,
|
||||
table_size: table.table_size,
|
||||
total_size: table.total_size,
|
||||
schema_name: table.schema_name,
|
||||
};
|
||||
});
|
||||
|
||||
connection.destroy();
|
||||
return formattedTables;
|
||||
}
|
||||
|
||||
async getTableData(
|
||||
credentials: Credentials,
|
||||
{
|
||||
tableName,
|
||||
dbName,
|
||||
perPage,
|
||||
page,
|
||||
sortDesc,
|
||||
sortField,
|
||||
}: WithSortPagination<{ tableName: string; dbName: string }>,
|
||||
) {
|
||||
const connection = await this.queryRunner(credentials);
|
||||
|
||||
const offset = perPage * page;
|
||||
|
||||
// Get the count of rows
|
||||
const [rows] = await connection.execute(
|
||||
`SELECT COUNT(*) as count FROM ${dbName}.${tableName}`,
|
||||
);
|
||||
if ("fieldCount" in rows) {
|
||||
return;
|
||||
}
|
||||
|
||||
const count = rows[0].count;
|
||||
// Construct the query for table data with optional sorting
|
||||
let query = `SELECT * FROM ${dbName}.${tableName}`;
|
||||
const params = [];
|
||||
|
||||
if (sortField) {
|
||||
query += ` ORDER BY ${sortField} ${sortDesc ? "DESC" : "ASC"}`;
|
||||
}
|
||||
console.log(perPage, offset, page);
|
||||
query += ` LIMIT ${perPage} OFFSET ${offset}`;
|
||||
params.push(perPage.toString());
|
||||
params.push(offset.toString());
|
||||
// Execute the query with parameters
|
||||
const [data] = await connection.execute(query);
|
||||
await connection.end();
|
||||
|
||||
return {
|
||||
count: count,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
async getTableColumns(
|
||||
credentials: Credentials,
|
||||
{ tableName, dbName }: { dbName: string; tableName: string },
|
||||
) {
|
||||
const connection = await this.queryRunner(credentials);
|
||||
|
||||
const [rows] = await connection.execute(
|
||||
`
|
||||
SELECT
|
||||
COLUMN_NAME as column_name,
|
||||
DATA_TYPE as data_type,
|
||||
COLUMN_TYPE as udt_name,
|
||||
COLUMN_COMMENT as column_comment
|
||||
FROM
|
||||
information_schema.COLUMNS
|
||||
WHERE
|
||||
TABLE_SCHEMA = ?
|
||||
AND TABLE_NAME = ?
|
||||
`,
|
||||
[dbName, tableName],
|
||||
);
|
||||
|
||||
await connection.end();
|
||||
|
||||
return (rows as any[]).map((row) => ({
|
||||
column_name: row.column_name,
|
||||
data_type: row.data_type,
|
||||
udt_name: row.udt_name,
|
||||
column_comment: row.column_comment,
|
||||
}));
|
||||
}
|
||||
|
||||
async getTableIndexes(
|
||||
credentials: Credentials,
|
||||
{ dbName, tableName }: { dbName: string; tableName: string },
|
||||
) {
|
||||
const connection = await this.queryRunner(credentials);
|
||||
|
||||
try {
|
||||
const [rows] = await connection.execute(
|
||||
`
|
||||
SELECT
|
||||
index_name AS relname,
|
||||
index_name AS \`key\`,
|
||||
CASE
|
||||
WHEN non_unique = 0 AND index_name = 'PRIMARY' THEN 'PRIMARY'
|
||||
WHEN non_unique = 0 THEN 'UNIQUE'
|
||||
ELSE 'INDEX'
|
||||
END AS type,
|
||||
GROUP_CONCAT(column_name ORDER BY seq_in_index) AS columns
|
||||
FROM
|
||||
information_schema.statistics
|
||||
WHERE
|
||||
table_schema = ?
|
||||
AND table_name = ?
|
||||
GROUP BY
|
||||
index_name, non_unique
|
||||
`,
|
||||
[dbName, tableName],
|
||||
);
|
||||
|
||||
await connection.end();
|
||||
|
||||
return (rows as any[]).map((row) => ({
|
||||
relname: row.relname,
|
||||
key: row.key,
|
||||
type: row.type,
|
||||
columns: row.columns.split(","),
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error("Error fetching indexes:", error);
|
||||
await connection.end();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getTableForeignKeys(
|
||||
credentials: Credentials,
|
||||
{ dbName, tableName }: { dbName: string; tableName: string },
|
||||
) {
|
||||
const sql = await this.queryRunner(credentials);
|
||||
|
||||
const result = await sql`
|
||||
SELECT
|
||||
conname,
|
||||
condeferrable::int AS deferrable,
|
||||
pg_get_constraintdef(oid) AS definition
|
||||
FROM
|
||||
pg_constraint
|
||||
WHERE
|
||||
conrelid = (
|
||||
SELECT pc.oid
|
||||
FROM pg_class AS pc
|
||||
INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace)
|
||||
WHERE pc.relname = ${tableName}
|
||||
AND pn.nspname = ${dbName}
|
||||
)
|
||||
AND contype = 'f'::char
|
||||
ORDER BY conkey, conname
|
||||
`;
|
||||
|
||||
void sql.end();
|
||||
|
||||
return result.map((row) => {
|
||||
const match = row.definition.match(
|
||||
/FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy,
|
||||
);
|
||||
if (match) {
|
||||
const sourceColumns = match[1]
|
||||
.split(",")
|
||||
.map((col) => col.replaceAll('"', "").trim());
|
||||
const targetTableMatch = match[2].match(
|
||||
/^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/,
|
||||
);
|
||||
const targetTable = targetTableMatch
|
||||
? targetTableMatch[0].trim()
|
||||
: null;
|
||||
const targetColumns = match[3]
|
||||
.split(",")
|
||||
.map((col) => col.replaceAll('"', "").trim());
|
||||
const { onDelete, onUpdate } = this.getActions(match[4]);
|
||||
return {
|
||||
conname: row.conname,
|
||||
deferrable: Boolean(row.deferrable),
|
||||
definition: row.definition,
|
||||
source: sourceColumns,
|
||||
ns: targetTableMatch
|
||||
? targetTableMatch[0].replaceAll('"', "").trim()
|
||||
: null,
|
||||
table: targetTable.replaceAll('"', ""),
|
||||
target: targetColumns,
|
||||
on_delete: onDelete ?? "NO ACTION",
|
||||
on_update: onUpdate ?? "NO ACTION",
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async executeQuery(credentials: Credentials, query: string) {
|
||||
const sql = await this.queryRunner(credentials);
|
||||
|
||||
const result = await sql.unsafe(query);
|
||||
|
||||
void sql.end();
|
||||
|
||||
return {
|
||||
count: result.length,
|
||||
data: result,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const mySQLDriver = new MySQLDriver();
|
||||
@@ -180,7 +180,7 @@ export class PostgresDriver implements Driver {
|
||||
void sql.end();
|
||||
|
||||
return {
|
||||
count: count.count,
|
||||
count: Number.parseInt(count.count, 10),
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import cors from "@elysiajs/cors";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
import { Elysia, t } from "elysia";
|
||||
import type { Driver } from "./drivers/driver.interface";
|
||||
import { mySQLDriver } from "./drivers/mysql";
|
||||
import { postgresDriver } from "./drivers/postgres";
|
||||
|
||||
const credentialsSchema = t.Union([
|
||||
@@ -15,8 +17,21 @@ const credentialsSchema = t.Union([
|
||||
}),
|
||||
t.Object({
|
||||
connectionString: t.String(),
|
||||
type: t.String(),
|
||||
}),
|
||||
]);
|
||||
|
||||
const getDriver = (type: string): Driver => {
|
||||
switch (type) {
|
||||
case "mysql":
|
||||
return mySQLDriver;
|
||||
case "postgres":
|
||||
return postgresDriver;
|
||||
default:
|
||||
throw new Error("Invalid type");
|
||||
}
|
||||
};
|
||||
|
||||
const app = new Elysia({ prefix: "/api" })
|
||||
.use(
|
||||
jwt({
|
||||
@@ -29,7 +44,9 @@ const app = new Elysia({ prefix: "/api" })
|
||||
.post(
|
||||
"/auth/login",
|
||||
async ({ body, jwt, cookie: { auth } }) => {
|
||||
const databases = await postgresDriver.getAllDatabases(body);
|
||||
const driver = getDriver(body.type);
|
||||
|
||||
const databases = await driver.getAllDatabases(body);
|
||||
|
||||
auth.set({
|
||||
value: await jwt.sign(body),
|
||||
@@ -42,13 +59,15 @@ const app = new Elysia({ prefix: "/api" })
|
||||
)
|
||||
.get("/databases", async ({ jwt, set, cookie: { auth } }) => {
|
||||
const credentials = await jwt.verify(auth.value);
|
||||
|
||||
console.log(auth.value);
|
||||
if (!credentials) {
|
||||
set.status = 401;
|
||||
return "Unauthorized";
|
||||
}
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
const databases = await driver.getAllDatabases(credentials);
|
||||
|
||||
const databases = await postgresDriver.getAllDatabases(credentials);
|
||||
return new Response(JSON.stringify(databases, null, 2)).json();
|
||||
})
|
||||
.get(
|
||||
@@ -62,8 +81,8 @@ const app = new Elysia({ prefix: "/api" })
|
||||
set.status = 401;
|
||||
return "Unauthorized";
|
||||
}
|
||||
|
||||
const tables = await postgresDriver.getAllTables(credentials, {
|
||||
const driver = getDriver(credentials.type);
|
||||
const tables = await driver.getAllTables(credentials, {
|
||||
dbName,
|
||||
sortField,
|
||||
sortDesc: sortDesc === "true",
|
||||
@@ -73,7 +92,7 @@ const app = new Elysia({ prefix: "/api" })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"databases/:dbName/tables/:tableName/data",
|
||||
"/databases/:dbName/tables/:tableName/data",
|
||||
async ({ query, params, jwt, set, cookie: { auth } }) => {
|
||||
const { tableName, dbName } = params;
|
||||
const { perPage = "50", page = "0", sortField, sortDesc } = query;
|
||||
@@ -83,8 +102,9 @@ const app = new Elysia({ prefix: "/api" })
|
||||
set.status = 401;
|
||||
return "Unauthorized";
|
||||
}
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
return postgresDriver.getTableData(credentials, {
|
||||
return driver.getTableData(credentials, {
|
||||
tableName,
|
||||
dbName,
|
||||
perPage: Number.parseInt(perPage, 10),
|
||||
@@ -95,7 +115,7 @@ const app = new Elysia({ prefix: "/api" })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"databases/:dbName/tables/:tableName/columns",
|
||||
"/databases/:dbName/tables/:tableName/columns",
|
||||
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||
const { tableName, dbName } = params;
|
||||
const credentials = await jwt.verify(auth.value);
|
||||
@@ -105,7 +125,9 @@ const app = new Elysia({ prefix: "/api" })
|
||||
return "Unauthorized";
|
||||
}
|
||||
|
||||
const columns = await postgresDriver.getTableColumns(credentials, {
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
const columns = await driver.getTableColumns(credentials, {
|
||||
dbName,
|
||||
tableName,
|
||||
});
|
||||
@@ -113,7 +135,7 @@ const app = new Elysia({ prefix: "/api" })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"databases/:dbName/tables/:tableName/indexes",
|
||||
"/databases/:dbName/tables/:tableName/indexes",
|
||||
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||
const { tableName, dbName } = params;
|
||||
const credentials = await jwt.verify(auth.value);
|
||||
@@ -123,7 +145,9 @@ const app = new Elysia({ prefix: "/api" })
|
||||
return "Unauthorized";
|
||||
}
|
||||
|
||||
const indexes = await postgresDriver.getTableIndexes(credentials, {
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
const indexes = await driver.getTableIndexes(credentials, {
|
||||
dbName,
|
||||
tableName,
|
||||
});
|
||||
@@ -131,7 +155,7 @@ const app = new Elysia({ prefix: "/api" })
|
||||
},
|
||||
)
|
||||
.get(
|
||||
"databases/:dbName/tables/:tableName/foreign-keys",
|
||||
"/databases/:dbName/tables/:tableName/foreign-keys",
|
||||
async ({ params, jwt, set, cookie: { auth } }) => {
|
||||
const { tableName, dbName } = params;
|
||||
const credentials = await jwt.verify(auth.value);
|
||||
@@ -141,19 +165,18 @@ const app = new Elysia({ prefix: "/api" })
|
||||
return "Unauthorized";
|
||||
}
|
||||
|
||||
const foreignKeys = await postgresDriver.getTableForeignKeys(
|
||||
credentials,
|
||||
{
|
||||
dbName,
|
||||
tableName,
|
||||
},
|
||||
);
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
const foreignKeys = await driver.getTableForeignKeys(credentials, {
|
||||
dbName,
|
||||
tableName,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify(foreignKeys, null, 2)).json();
|
||||
},
|
||||
)
|
||||
.post(
|
||||
"raw",
|
||||
"/raw",
|
||||
async ({ body, jwt, set, cookie: { auth } }) => {
|
||||
const credentials = await jwt.verify(auth.value);
|
||||
|
||||
@@ -161,9 +184,10 @@ const app = new Elysia({ prefix: "/api" })
|
||||
set.status = 401;
|
||||
return "Unauthorized";
|
||||
}
|
||||
const driver = getDriver(credentials.type);
|
||||
|
||||
const { query } = body;
|
||||
return await postgresDriver.executeQuery(credentials, query);
|
||||
return await driver.executeQuery(credentials, query);
|
||||
},
|
||||
{
|
||||
body: t.Object({
|
||||
@@ -182,3 +206,5 @@ const app = new Elysia({ prefix: "/api" })
|
||||
console.log(
|
||||
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
|
||||
);
|
||||
|
||||
export type AppType = typeof app;
|
||||
|
||||
487
api/src/test/index.test.ts
Normal file
487
api/src/test/index.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { edenFetch } from "@elysiajs/eden";
|
||||
import cookie from "cookie";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type { AppType } from "../index";
|
||||
|
||||
const fetch = edenFetch<AppType>("http://localhost:3000");
|
||||
|
||||
const pgCookie = cookie.serialize(
|
||||
"auth",
|
||||
jwt.sign(
|
||||
// {
|
||||
// type: "postgres",
|
||||
// username: "postgres",
|
||||
// password: "mysecretpassword",
|
||||
// host: "localhost",
|
||||
// port: "5432",
|
||||
// database: "postgres",
|
||||
// ssl: "prefer",
|
||||
// },
|
||||
{
|
||||
type: "postgres",
|
||||
connectionString:
|
||||
"postgresql://flashcards_owner:pBYW18waUHtV@ep-gentle-heart-a225yqws.eu-central-1.aws.neon.tech/flashcards?sslmode=require&schema=flashcards",
|
||||
},
|
||||
"Fischl von Luftschloss Narfidort",
|
||||
{ noTimestamp: true },
|
||||
),
|
||||
);
|
||||
|
||||
const mysqlCookie = cookie.serialize(
|
||||
"auth",
|
||||
jwt.sign(
|
||||
{
|
||||
type: "mysql",
|
||||
username: "root",
|
||||
password: "mysecretpassword",
|
||||
host: "localhost",
|
||||
port: "3306",
|
||||
database: "mysql",
|
||||
ssl: "prefer",
|
||||
},
|
||||
"Fischl von Luftschloss Narfidort",
|
||||
{ noTimestamp: true },
|
||||
),
|
||||
);
|
||||
|
||||
describe("/auth/login", () => {
|
||||
it("should log in correctly with PostgreSQL", async () => {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
type: "postgres",
|
||||
username: "postgres",
|
||||
password: "mysecretpassword",
|
||||
host: "localhost",
|
||||
port: "5432",
|
||||
database: "postgres",
|
||||
ssl: "prefer",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.data?.databases).toEqual([
|
||||
"pg_toast",
|
||||
"pg_catalog",
|
||||
"public",
|
||||
"information_schema",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should log in correctly with MySQL", async () => {
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: {
|
||||
type: "mysql",
|
||||
username: "root",
|
||||
password: "mysecretpassword",
|
||||
host: "localhost",
|
||||
port: "3306",
|
||||
database: "mysql",
|
||||
ssl: "prefer",
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.data?.databases).toEqual([
|
||||
"information_schema",
|
||||
"mysql",
|
||||
"performance_schema",
|
||||
"sys",
|
||||
"test_db",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/databases", () => {
|
||||
it("should return correct data from PostgreSQL", async () => {
|
||||
const res = await fetch("/api/databases", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: pgCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.data).toEqual([
|
||||
"pg_toast",
|
||||
"pg_catalog",
|
||||
"public",
|
||||
"information_schema",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should return correct data from MySQL", async () => {
|
||||
const res = await fetch("/api/databases", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: mysqlCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
expect(res.data).toEqual([
|
||||
"information_schema",
|
||||
"mysql",
|
||||
"performance_schema",
|
||||
"sys",
|
||||
"test_db",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("/databases/:dbName/tables", () => {
|
||||
it("should return correct data from PostgreSQL", async () => {
|
||||
const res = await fetch("/api/databases/:dbName/tables", {
|
||||
params: {
|
||||
dbName: "public",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: pgCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
for (const table of res.data) {
|
||||
expect(table).toContainAllKeys([
|
||||
"comments",
|
||||
"index_size",
|
||||
"indexes",
|
||||
"owner",
|
||||
"primary_key",
|
||||
"row_count",
|
||||
"schema_name",
|
||||
"table_name",
|
||||
"table_size",
|
||||
"total_size",
|
||||
]);
|
||||
expect(table.comments).toBeString();
|
||||
expect(table.index_size).toBeNumber();
|
||||
expect(table.indexes).toBeString();
|
||||
expect(
|
||||
typeof table.owner === "string" || table.owner === null,
|
||||
).toBeTrue();
|
||||
expect(table.primary_key).toBeString();
|
||||
expect(table.row_count).toBeNumber();
|
||||
expect(table.schema_name).toBeString();
|
||||
expect(table.table_name).toBeString();
|
||||
expect(table.table_size).toBeNumber();
|
||||
expect(table.total_size).toBeNumber();
|
||||
}
|
||||
});
|
||||
|
||||
it("should return correct data from MySQL", async () => {
|
||||
const res = await fetch("/api/databases/:dbName/tables", {
|
||||
params: {
|
||||
dbName: "test_db",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: mysqlCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
for (const table of res.data) {
|
||||
expect(table).toContainAllKeys([
|
||||
"comments",
|
||||
"index_size",
|
||||
"indexes",
|
||||
"owner",
|
||||
"primary_key",
|
||||
"row_count",
|
||||
"schema_name",
|
||||
"table_name",
|
||||
"table_size",
|
||||
"total_size",
|
||||
]);
|
||||
expect(table.comments).toBeString();
|
||||
expect(table.index_size).toBeNumber();
|
||||
expect(table.indexes).toBeString();
|
||||
expect(
|
||||
typeof table.owner === "string" || table.owner === null,
|
||||
).toBeTrue();
|
||||
expect(table.primary_key).toBeString();
|
||||
expect(table.row_count).toBeNumber();
|
||||
expect(table.schema_name).toBeString();
|
||||
expect(table.table_name).toBeString();
|
||||
expect(table.table_size).toBeNumber();
|
||||
expect(table.total_size).toBeNumber();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("databases/:dbName/tables/:tableName/data", () => {
|
||||
it("should return correct data from PostgreSQL", async () => {
|
||||
const res = await fetch("/api/databases/:dbName/tables/:tableName/data", {
|
||||
params: {
|
||||
dbName: "public",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: pgCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(res.data).not.toEqual("Unauthorized");
|
||||
if (res.data === "Unauthorized") return;
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data?.data.length).toBeGreaterThan(0);
|
||||
expect(res.data?.count).toEqual(res.data?.data.length);
|
||||
|
||||
for (const row of res.data.data) {
|
||||
expect(row).toBeObject();
|
||||
}
|
||||
});
|
||||
|
||||
it("should return correct data from MySQL", async () => {
|
||||
const res = await fetch("/api/databases/:dbName/tables/:tableName/data", {
|
||||
params: {
|
||||
dbName: "test_db",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: mysqlCookie,
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(res.data).not.toEqual("Unauthorized");
|
||||
if (res.data === "Unauthorized") return;
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data?.data.length).toBeGreaterThan(0);
|
||||
expect(res.data?.count).toEqual(res.data?.data.length);
|
||||
|
||||
for (const row of res.data.data) {
|
||||
expect(row).toBeObject();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("databases/:dbName/tables/:tableName/indexes", () => {
|
||||
it("should return correct data from PostgreSQL", async () => {
|
||||
const res = await fetch(
|
||||
"/api/databases/:dbName/tables/:tableName/indexes",
|
||||
{
|
||||
params: {
|
||||
dbName: "public",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: pgCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(res.data).not.toEqual("Unauthorized");
|
||||
if (res.data === "Unauthorized") return;
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data).toBeArray();
|
||||
|
||||
expect(res.data[0].relname).toBeString();
|
||||
expect(res.data[0].key).toBeString();
|
||||
expect(res.data[0].type).toBeString();
|
||||
expect(res.data[0].columns).toBeArray();
|
||||
expect(res.data[0].columns[0]).toBeString();
|
||||
});
|
||||
|
||||
it("should return correct data from MySQL", async () => {
|
||||
const res = await fetch(
|
||||
"/api/databases/:dbName/tables/:tableName/indexes",
|
||||
{
|
||||
params: {
|
||||
dbName: "test_db",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: mysqlCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(res.data).not.toEqual("Unauthorized");
|
||||
if (res.data === "Unauthorized") return;
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data).toBeArray();
|
||||
|
||||
expect(res.data[0].relname).toBeString();
|
||||
expect(res.data[0].key).toBeString();
|
||||
expect(res.data[0].type).toBeString();
|
||||
expect(res.data[0].columns).toBeArray();
|
||||
expect(res.data[0].columns[0]).toBeString();
|
||||
});
|
||||
});
|
||||
|
||||
describe("databases/:dbName/tables/:tableName/columns", () => {
|
||||
it("should return correct data from PostgreSQL", async () => {
|
||||
const res = await fetch(
|
||||
"/api/databases/:dbName/tables/:tableName/columns",
|
||||
{
|
||||
params: {
|
||||
dbName: "public",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: pgCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
expect(res.data).not.toEqual("Unauthorized");
|
||||
if (res.data === "Unauthorized") return;
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data).toBeArray();
|
||||
|
||||
for (const row of res.data) {
|
||||
expect(row).toContainAllKeys([
|
||||
"column_name",
|
||||
"data_type",
|
||||
"udt_name",
|
||||
"column_comment",
|
||||
]);
|
||||
expect(row.column_name).toBeString();
|
||||
expect(row.data_type).toBeString();
|
||||
expect(row.udt_name).toBeString();
|
||||
expect(
|
||||
row.column_comment === null || typeof row.column_comment === "string",
|
||||
).toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it("should return correct data from MySQL", async () => {
|
||||
const res = await fetch(
|
||||
"/api/databases/:dbName/tables/:tableName/columns",
|
||||
{
|
||||
params: {
|
||||
dbName: "test_db",
|
||||
tableName: "test_table",
|
||||
},
|
||||
method: "GET",
|
||||
headers: {
|
||||
cookie: mysqlCookie,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(res.data).toBeDefined();
|
||||
if (!res.data) return;
|
||||
|
||||
expect(res.data).toBeArray();
|
||||
|
||||
for (const row of res.data) {
|
||||
expect(row).toContainAllKeys([
|
||||
"column_name",
|
||||
"data_type",
|
||||
"udt_name",
|
||||
"column_comment",
|
||||
]);
|
||||
expect(row.column_name).toBeString();
|
||||
expect(row.data_type).toBeString();
|
||||
expect(row.udt_name).toBeString();
|
||||
expect(
|
||||
row.column_comment === null || typeof row.column_comment === "string",
|
||||
).toBeTrue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("databases/:dbName/tables/:tableName/foreign-keys", () => {
|
||||
// it("should return correct data from PostgreSQL", async () => {
|
||||
// const res = await fetch(
|
||||
// "/api/databases/:dbName/tables/:tableName/foreign-keys",
|
||||
// {
|
||||
// params: {
|
||||
// dbName: "public",
|
||||
// tableName: "test_table",
|
||||
// },
|
||||
// method: "GET",
|
||||
// headers: {
|
||||
// cookie: pgCookie,
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
// expect(res.status).toEqual(200);
|
||||
//
|
||||
// expect(res.data).not.toEqual("Unauthorized");
|
||||
// if (res.data === "Unauthorized") return;
|
||||
//
|
||||
// console.log(res.data);
|
||||
//
|
||||
// expect(res.data).toBeDefined();
|
||||
// if (!res.data) return;
|
||||
//
|
||||
// expect(res.data).toBeArray();
|
||||
//
|
||||
// for (const row of res.data) {
|
||||
// expect(row).toContainAllKeys([
|
||||
// "column_name",
|
||||
// "data_type",
|
||||
// "udt_name",
|
||||
// "column_comment",
|
||||
// ]);
|
||||
// expect(row.column_name).toBeString();
|
||||
// expect(row.data_type).toBeString();
|
||||
// expect(row.udt_name).toBeString();
|
||||
// expect(
|
||||
// row.column_comment === null || typeof row.column_comment === "string",
|
||||
// ).toBeTrue();
|
||||
// }
|
||||
// });
|
||||
// it("should return correct data from MySQL", async () => {
|
||||
// const res = await fetch(
|
||||
// "/api/databases/:dbName/tables/:tableName/columns",
|
||||
// {
|
||||
// params: {
|
||||
// dbName: "test_db",
|
||||
// tableName: "test_table",
|
||||
// },
|
||||
// method: "GET",
|
||||
// headers: {
|
||||
// cookie: mysqlCookie,
|
||||
// },
|
||||
// },
|
||||
// );
|
||||
// console.log(res.data);
|
||||
//
|
||||
// expect(res.data).toBeDefined();
|
||||
// if (!res.data) return;
|
||||
//
|
||||
// expect(res.data).toBeArray();
|
||||
//
|
||||
// for (const row of res.data) {
|
||||
// expect(row).toContainAllKeys([
|
||||
// "column_name",
|
||||
// "data_type",
|
||||
// "udt_name",
|
||||
// "column_comment",
|
||||
// ]);
|
||||
// expect(row.column_name).toBeString();
|
||||
// expect(row.data_type).toBeString();
|
||||
// expect(row.udt_name).toBeString();
|
||||
// expect(
|
||||
// row.column_comment === null || typeof row.column_comment === "string",
|
||||
// ).toBeTrue();
|
||||
// }
|
||||
// });
|
||||
});
|
||||
@@ -26,6 +26,23 @@ export const Route = createFileRoute("/auth/login")({
|
||||
component: LoginForm,
|
||||
});
|
||||
|
||||
function DatabaseTypeSelector() {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbType">Database type</Label>
|
||||
<Select defaultValue={"postgres"} name={"type"}>
|
||||
<SelectTrigger className="w-full" id={"dbType"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="postgres">Postgres</SelectItem>
|
||||
<SelectItem value="mysql">MySQL</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LoginForm() {
|
||||
const [connectionMethod, setConnectionMethod] =
|
||||
useState<string>("connectionString");
|
||||
@@ -36,12 +53,17 @@ function LoginForm() {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const connectionString = formData.get("connectionString");
|
||||
|
||||
const type = formData.get("type");
|
||||
if (connectionMethod === "connectionString") {
|
||||
if (connectionString != null && typeof connectionString === "string") {
|
||||
if (
|
||||
connectionString != null &&
|
||||
typeof connectionString === "string" &&
|
||||
type != null &&
|
||||
typeof type === "string"
|
||||
) {
|
||||
try {
|
||||
await mutateAsync({ connectionString });
|
||||
addSession({ connectionString });
|
||||
await mutateAsync({ connectionString, type });
|
||||
addSession({ connectionString, type });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
toast.error("Invalid connection string");
|
||||
@@ -56,7 +78,6 @@ function LoginForm() {
|
||||
const username = formData.get("username");
|
||||
const password = formData.get("password");
|
||||
const host = formData.get("host");
|
||||
const type = formData.get("type");
|
||||
const port = formData.get("port");
|
||||
const database = formData.get("database");
|
||||
const ssl = formData.get("ssl");
|
||||
@@ -138,17 +159,7 @@ function LoginForm() {
|
||||
</ToggleGroup>
|
||||
{connectionMethod === "fields" ? (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="dbType">Database type</Label>
|
||||
<Select defaultValue={"postgres"} name={"type"}>
|
||||
<SelectTrigger className="w-full" id={"dbType"}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="postgres">Postgres</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DatabaseTypeSelector />
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="host">Host</Label>
|
||||
<Input
|
||||
@@ -212,6 +223,7 @@ function LoginForm() {
|
||||
</>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<DatabaseTypeSelector />
|
||||
<Label htmlFor="connectionString">Connection string</Label>
|
||||
<Input
|
||||
name="connectionString"
|
||||
|
||||
@@ -10,6 +10,7 @@ export type LoginArgs =
|
||||
}
|
||||
| {
|
||||
connectionString: string;
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type LoginResponse = {
|
||||
|
||||
@@ -16,6 +16,7 @@ type SessionFields = {
|
||||
type SessionConnectionString = {
|
||||
id: number;
|
||||
connectionString: string;
|
||||
type: string;
|
||||
};
|
||||
type Session = SessionFields | SessionConnectionString;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user