diff --git a/api/bun.lockb b/api/bun.lockb index 06c8d45..f005dde 100644 Binary files a/api/bun.lockb and b/api/bun.lockb differ diff --git a/api/docker-compose.yml b/api/docker-compose.yml new file mode 100644 index 0000000..c37d035 --- /dev/null +++ b/api/docker-compose.yml @@ -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: diff --git a/api/package.json b/api/package.json index 9099af1..9b4a17f 100644 --- a/api/package.json +++ b/api/package.json @@ -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": { diff --git a/api/scripts/setup-test-db.sh b/api/scripts/setup-test-db.sh new file mode 100644 index 0000000..b109b12 --- /dev/null +++ b/api/scripts/setup-test-db.sh @@ -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 < { + 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" ? ( <> -
- - -
+
) : (
+