From 024eb61a410d2cadd76f228ec6cf641ca6fcaad6 Mon Sep 17 00:00:00 2001 From: andres Date: Thu, 11 Jul 2024 11:20:36 +0200 Subject: [PATCH] wip: mysql driver add tests for pg driver and mysql driver (in progress) --- api/bun.lockb | Bin 11041 -> 22839 bytes api/docker-compose.yml | 32 ++ api/package.json | 6 + api/scripts/setup-test-db.sh | 32 ++ api/scripts/teardown-test-db.sh | 16 + api/src/drivers/mysql.ts | 376 +++++++++++++++++++ api/src/drivers/postgres.ts | 2 +- api/src/index.ts | 68 ++-- api/src/test/index.test.ts | 487 +++++++++++++++++++++++++ frontend/src/routes/auth/login.tsx | 44 ++- frontend/src/services/db/db.types.ts | 1 + frontend/src/state/db-session-store.ts | 1 + 12 files changed, 1027 insertions(+), 38 deletions(-) create mode 100644 api/docker-compose.yml create mode 100644 api/scripts/setup-test-db.sh create mode 100644 api/scripts/teardown-test-db.sh create mode 100644 api/src/drivers/mysql.ts create mode 100644 api/src/test/index.test.ts diff --git a/api/bun.lockb b/api/bun.lockb index 06c8d450a7a50e618156452bf356e973d4c5d843..f005ddee5b8684e6716c6e7a0a74f511cdf81c32 100644 GIT binary patch literal 22839 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 gHB_x%ClgmX#C~SGG_(OqagNxdw?4}V7LP8o| zZIqnWT2cd~O^lJWNoh50HEC_Mpw}>?7hiC> z4mcfzn2|@JzpLBf@i-mb3Ep_8%Z*V&!mtNm96M3>p;4eD#1F9#(I3%-sNUqKljwwa zpY;c;5hIbWa)js*?;?gGUPh!FJ%@;k^n*9v>x~<7Up-Q{JaWfqUOl%^YOF1v{N)kU zDzafxVaTl4k4`r?pLQJmtK+Mo;AP`j#-7)#Ub`@Ry(>DI;JJ(P3OmyCI%6@pH zIKkAxu(E_5BrxJ9sv{Ue_TaJ%%&T%)4+9#1QGFhrJJy{(x$1wAl_T?qF)mkK$`VqB zjFTn|u%O{Z^#yb`qLaY9Pc9p&fRO-^Yfuo92RnTW*--^(0!8j3ItnDmngVLHB3r41 z5rk1CT;oOd1_Cm|!3eT44m3d`SBkH#1^A#sZXB&9$-0kLon(o=gjgkO7%iJ*Euytk zvJzF^JoaT3XhI75FhXdjO}6QU(zY4#z$8%eJ{@Tsi%Dt}@$@8kok~dKAzuGhM9T55 zVIU-^kJ@K4VZV-tOUO7-8||PA=i!ji%J{;^$Yzm6#aS6O%*ET`a0CzO30B4r zdJ^oA6v@L6$apX%+F=3N8;MpX5Pn8Bq~{?a-pT|)ev%#Xq5|M%Tn!TfV@Y;q3;coO zRwzxjGokPnj$yEhqYj=+u`}T?g<}Lf#xZinmKw%Dzf!4{g~AVunhFa{rB2cSOlGK5 z`S|ftF+k@xGyR!qoTkgGSf})GyRgO>fv=6eI$9;P$$ZwEmK_D1MP=Y$Y~X3i7VPwH z0^w{?WH2o$EGN>6f<@aBO*T4dBE4ypH>yl@15#zub!fHGO2IFO;NA#|6~T>@C)g-z zu%n7e`P7G!DHRD_lvXxHR4-|t;lPVw8f`REZ1u5lOFSG#HFbI4z@3{uYYa=j2dSCi zXh}GDDzs{rJ=o@PxIF|iwntiI)rD{0{nga4!m-TWOu-^#5+dvJnc)@3wzi~djN}NJ zf{Bnh0%Ez&A`AI%3&{t5Dmod02`0fLpnl)OC8^ZMoMxA@gFpOTdT5qeFy%ZX@L9P| zuf;ck2koCo=ub<1#nG;1#9{+|dggip_*d+L%M}rD7$r$nL z$&Cd0S8-{2M~U8^H?a}9%fWj%RsOe>q@PuFE=J$qNRa=&J3SSuY(Isxsh8l>vgP8)j1LOze#7`2Ys$8(Uz|R;&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 < { + 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 = []; + 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(); diff --git a/api/src/drivers/postgres.ts b/api/src/drivers/postgres.ts index 70b0e86..6865dae 100644 --- a/api/src/drivers/postgres.ts +++ b/api/src/drivers/postgres.ts @@ -180,7 +180,7 @@ export class PostgresDriver implements Driver { void sql.end(); return { - count: count.count, + count: Number.parseInt(count.count, 10), data, }; } diff --git a/api/src/index.ts b/api/src/index.ts index 7b9836b..6d84db6 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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; diff --git a/api/src/test/index.test.ts b/api/src/test/index.test.ts new file mode 100644 index 0000000..9ebfd6a --- /dev/null +++ b/api/src/test/index.test.ts @@ -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("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(); + // } + // }); +}); diff --git a/frontend/src/routes/auth/login.tsx b/frontend/src/routes/auth/login.tsx index 0f96af9..47e3eaf 100644 --- a/frontend/src/routes/auth/login.tsx +++ b/frontend/src/routes/auth/login.tsx @@ -26,6 +26,23 @@ export const Route = createFileRoute("/auth/login")({ component: LoginForm, }); +function DatabaseTypeSelector() { + return ( +
+ + +
+ ); +} + function LoginForm() { const [connectionMethod, setConnectionMethod] = useState("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() { {connectionMethod === "fields" ? ( <> -
- - -
+
) : (
+