From d387b1cda2997be0b59ebd771d364f54fb8e67c0 Mon Sep 17 00:00:00 2001 From: andres Date: Thu, 11 Jul 2024 12:25:02 +0200 Subject: [PATCH] feat: mysql driver working, tests passing. --- api/scripts/setup-test-db.sh | 41 ++- api/scripts/teardown-test-db.sh | 6 +- api/src/drivers/mysql.ts | 171 +++++------- api/src/test/index.test.ts | 253 +++++++++++------- .../components/db-table-view/data-table.tsx | 2 +- frontend/src/main.tsx | 8 +- .../db/$dbName/tables/$tableName/data.tsx | 1 + .../src/routes/db/$dbName/tables/index.tsx | 2 + 8 files changed, 281 insertions(+), 203 deletions(-) diff --git a/api/scripts/setup-test-db.sh b/api/scripts/setup-test-db.sh index b109b12..d57a5ce 100644 --- a/api/scripts/setup-test-db.sh +++ b/api/scripts/setup-test-db.sh @@ -17,16 +17,49 @@ 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; @@ -43,7 +25,6 @@ export class MySQLDriver implements Driver { user: username, password, host, - type, port: Number.parseInt(port, 10), database, ssl: { @@ -58,9 +39,9 @@ export class MySQLDriver implements Driver { if ("connectionString" in credentials) { connection = await mysql.createConnection(credentials.connectionString); } else { - connection = await mysql.createConnection( - this.parseCredentials(credentials), - ); + const creds = this.parseCredentials(credentials); + console.log(creds); + connection = await mysql.createConnection(creds); } } catch (error) { console.error(error); @@ -69,22 +50,6 @@ export class MySQLDriver implements Driver { 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"); @@ -102,11 +67,15 @@ export class MySQLDriver implements Driver { async getAllTables( credentials: Credentials, - { sortDesc, sortField, dbName }: WithSort<{ dbName: string }>, + { + sortDesc, + sortField = "schema_name", + dbName, + }: WithSort<{ dbName: string }>, ) { const connection = await this.queryRunner(credentials); - const tablesQuery = ` + let tablesQuery = ` SELECT TABLE_NAME as table_name, TABLE_SCHEMA as schema_name, @@ -118,8 +87,11 @@ export class MySQLDriver implements Driver { FROM information_schema.tables WHERE - table_schema = ?; + table_schema = ? `; + if (sortField) { + tablesQuery += ` ORDER BY ${sortField} ${sortDesc ? "DESC" : "ASC"}`; + } const [tables] = await connection.execute(tablesQuery, [dbName]); @@ -301,74 +273,71 @@ export class MySQLDriver implements Driver { credentials: Credentials, { dbName, tableName }: { dbName: string; tableName: string }, ) { - const sql = await this.queryRunner(credentials); + const connection = 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, + try { + const [rows] = await connection.execute( + ` + SELECT + rc.CONSTRAINT_NAME as conname, + rc.UPDATE_RULE as on_update, + rc.DELETE_RULE as on_delete, + kcu.COLUMN_NAME as source, + kcu.REFERENCED_TABLE_NAME as \`table\`, + kcu.REFERENCED_COLUMN_NAME as target + FROM + information_schema.REFERENTIAL_CONSTRAINTS rc + JOIN + information_schema.KEY_COLUMN_USAGE kcu + ON + rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA + AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME + WHERE + kcu.TABLE_SCHEMA = ? + AND kcu.TABLE_NAME = ? + `, + [dbName, tableName], ); - 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", - }; - } - }); + + await connection.end(); + + const foreignKeys: { [key: string]: ForeignKeyInfo } = {}; + (rows as any[]).forEach((row) => { + if (!foreignKeys[row.conname]) { + foreignKeys[row.conname] = { + conname: row.conname, + deferrable: false, + definition: `FOREIGN KEY (${row.source}) REFERENCES ${row.table}(${row.target})`, + source: [], + ns: row.table, + table: row.table, + target: [], + on_delete: row.on_delete, + on_update: row.on_update, + }; + } + foreignKeys[row.conname].source.push(row.source); + foreignKeys[row.conname].target.push(row.target); + }); + + return Object.values(foreignKeys); + } catch (error) { + console.error("Error fetching foreign keys:", error); + await connection.end(); + throw error; + } } async executeQuery(credentials: Credentials, query: string) { - const sql = await this.queryRunner(credentials); + const connection = await this.queryRunner(credentials); - const result = await sql.unsafe(query); + const [rows, fields] = await connection.execute(query); - void sql.end(); + await connection.end(); return { - count: result.length, - data: result, + count: rows?.length, + data: rows, }; } } diff --git a/api/src/test/index.test.ts b/api/src/test/index.test.ts index 9ebfd6a..5837f9c 100644 --- a/api/src/test/index.test.ts +++ b/api/src/test/index.test.ts @@ -6,22 +6,19 @@ import type { AppType } from "../index"; const fetch = edenFetch("http://localhost:3000"); +const testDBName = "users"; + 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", + username: "postgres", + password: "mysecretpassword", + host: "localhost", + port: "5432", + database: "postgres", + ssl: "prefer", }, "Fischl von Luftschloss Narfidort", { noTimestamp: true }, @@ -213,7 +210,7 @@ describe("databases/:dbName/tables/:tableName/data", () => { const res = await fetch("/api/databases/:dbName/tables/:tableName/data", { params: { dbName: "public", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -240,7 +237,7 @@ describe("databases/:dbName/tables/:tableName/data", () => { const res = await fetch("/api/databases/:dbName/tables/:tableName/data", { params: { dbName: "test_db", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -271,7 +268,7 @@ describe("databases/:dbName/tables/:tableName/indexes", () => { { params: { dbName: "public", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -302,7 +299,7 @@ describe("databases/:dbName/tables/:tableName/indexes", () => { { params: { dbName: "test_db", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -335,7 +332,7 @@ describe("databases/:dbName/tables/:tableName/columns", () => { { params: { dbName: "public", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -375,7 +372,7 @@ describe("databases/:dbName/tables/:tableName/columns", () => { { params: { dbName: "test_db", - tableName: "test_table", + tableName: testDBName, }, method: "GET", headers: { @@ -407,81 +404,149 @@ describe("databases/:dbName/tables/:tableName/columns", () => { }); 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(); - // } - // }); + it("should return correct data from PostgreSQL", async () => { + const res = await fetch( + "/api/databases/:dbName/tables/:tableName/foreign-keys", + { + params: { + dbName: "public", + tableName: "orders", + }, + 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([ + "conname", + "deferrable", + "definition", + "source", + "ns", + "on_delete", + "on_update", + "table", + "target", + ]); + expect(row.conname).toBeString(); + expect(row.deferrable).toBeBoolean(); + expect(row.definition).toBeString(); + expect(row.source).toBeArray(); + expect(row.ns).toBeString(); + expect(row.on_delete).toBeString(); + expect(row.on_update).toBeString(); + expect(row.table).toBeString(); + expect(row.target).toBeArray(); + } + }); + it("should return correct data from MySQL", async () => { + const res = await fetch( + "/api/databases/:dbName/tables/:tableName/foreign-keys", + { + params: { + dbName: "test_db", + tableName: "orders", + }, + 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([ + "conname", + "deferrable", + "definition", + "source", + "ns", + "on_delete", + "on_update", + "table", + "target", + ]); + expect(row.conname).toBeString(); + expect(row.deferrable).toBeBoolean(); + expect(row.definition).toBeString(); + expect(row.source).toBeArray(); + expect(row.ns).toBeString(); + expect(row.on_delete).toBeString(); + expect(row.on_update).toBeString(); + expect(row.table).toBeString(); + expect(row.target).toBeArray(); + } + }); +}); + +describe("raw", () => { + it("should return correct data from PostgreSQL", async () => { + const query = "SELECT * FROM information_schema.tables;"; + const res = await fetch("/api/raw", { + method: "POST", + headers: { + cookie: pgCookie, + }, + body: { + query, + }, + }); + 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.count).toBeNumber(); + expect(res.data.count).toBeGreaterThan(0); + + expect(res.data.data).toBeArray(); + expect(res.data.data.length).toBeGreaterThan(0); + expect(res.data.data[0]).toBeObject(); + }); + it("should return correct data from MySQL", async () => { + const query = "SELECT * FROM information_schema.tables;"; + const res = await fetch("/api/raw", { + method: "POST", + headers: { + cookie: mysqlCookie, + }, + body: { + query, + }, + }); + 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.count).toBeNumber(); + expect(res.data.count).toBeGreaterThan(0); + + expect(res.data.data).toBeArray(); + expect(res.data.data.length).toBeGreaterThan(0); + expect(res.data.data[0]).toBeObject(); + }); }); diff --git a/frontend/src/components/db-table-view/data-table.tsx b/frontend/src/components/db-table-view/data-table.tsx index e8a8d62..0f14415 100644 --- a/frontend/src/components/db-table-view/data-table.tsx +++ b/frontend/src/components/db-table-view/data-table.tsx @@ -95,7 +95,7 @@ export const DataTable = ({ ); }, - sortable: true, + enableSorting: true, cell: ({ row }) => { const value = row.getValue(column_name) as any; let finalValue = value; diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5154e6d..0df4cb6 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -23,7 +23,13 @@ declare module "@tanstack/react-router" { } } -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, +}); // Render the app const rootElement = document.getElementById("root"); diff --git a/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx index 765ee41..5937aaa 100644 --- a/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx +++ b/frontend/src/routes/db/$dbName/tables/$tableName/data.tsx @@ -29,6 +29,7 @@ function TableView() { return (
{ columnHelper.accessor("primary_key", { header: "Primary key", + enableSorting: false, }), columnHelper.accessor("indexes", { header: "Indexes", + enableSorting: false, cell: (props) => { const indexes = props.getValue(); if (!indexes) return null;