feat: mysql driver working, tests passing.

This commit is contained in:
2024-07-11 12:25:02 +02:00
parent 024eb61a41
commit d387b1cda2
8 changed files with 281 additions and 203 deletions

View File

@@ -17,16 +17,49 @@ done
# Set up PostgreSQL test data # Set up PostgreSQL test data
docker exec -i db-postgres-test psql -U postgres <<EOF docker exec -i db-postgres-test psql -U postgres <<EOF
CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name VARCHAR(50)); CREATE TABLE IF NOT EXISTS users (
INSERT INTO test_table (name) VALUES ('John Doe'); user_id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
order_id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
order_date DATE NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (user_id)
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
INSERT INTO users (name) VALUES ('John Doe');
INSERT INTO users (name) VALUES ('Alice Smith');
INSERT INTO orders (user_id, order_date) VALUES (1, '2023-01-01');
INSERT INTO orders (user_id, order_date) VALUES (2, '2023-02-01');
EOF EOF
# Set up MySQL test data # Set up MySQL test data
docker exec -i db-mysql-test mysql -uroot -pmysecretpassword<<EOF docker exec -i db-mysql-test mysql -uroot -pmysecretpassword<<EOF
CREATE DATABASE IF NOT EXISTS test_db; CREATE DATABASE IF NOT EXISTS test_db;
USE 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'); CREATE TABLE IF NOT EXISTS users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL
);
CREATE TABLE IF NOT EXISTS orders (
order_id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
order_date DATE NOT NULL,
FOREIGN KEY (user_id) REFERENCES users (user_id)
);
CREATE INDEX idx_orders_user_id ON orders(user_id);
INSERT INTO users (name) VALUES ('Jane Doe');
INSERT INTO users (name) VALUES ('Bob Brown');
INSERT INTO orders (user_id, order_date) VALUES (1, '2023-03-01');
INSERT INTO orders (user_id, order_date) VALUES (2, '2023-04-01');
EOF EOF
echo "Test databases are ready" echo "Test databases are ready"

View File

@@ -2,12 +2,14 @@
# Clean up PostgreSQL test data # Clean up PostgreSQL test data
docker exec -i db-postgres-test psql -U postgres <<EOF docker exec -i db-postgres-test psql -U postgres <<EOF
DROP TABLE IF EXISTS test_table; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS orders;
EOF EOF
# Clean up MySQL test data # Clean up MySQL test data
docker exec -i db-mysql-test mysql -uroot -pmysecretpassword --database=test_db<<EOF docker exec -i db-mysql-test mysql -uroot -pmysecretpassword --database=test_db<<EOF
DROP TABLE IF EXISTS test_table; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS orders;
EOF EOF
# Stop and remove Docker containers and volumes # Stop and remove Docker containers and volumes

View File

@@ -1,4 +1,4 @@
import mysql, { type ResultSetHeader } from "mysql2/promise"; import mysql from "mysql2/promise";
import type { import type {
Credentials, Credentials,
Driver, Driver,
@@ -6,35 +6,17 @@ import type {
WithSortPagination, WithSortPagination,
} from "./driver.interface"; } 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 { export class MySQLDriver implements Driver {
parseCredentials({ parseCredentials({
username, username,
password, password,
host, host,
type,
port, port,
database, database,
}: { }: {
username: string; username: string;
password: string; password: string;
host: string; host: string;
type: string;
port: string; port: string;
database: string; database: string;
ssl: string; ssl: string;
@@ -43,7 +25,6 @@ export class MySQLDriver implements Driver {
user: username, user: username,
password, password,
host, host,
type,
port: Number.parseInt(port, 10), port: Number.parseInt(port, 10),
database, database,
ssl: { ssl: {
@@ -58,9 +39,9 @@ export class MySQLDriver implements Driver {
if ("connectionString" in credentials) { if ("connectionString" in credentials) {
connection = await mysql.createConnection(credentials.connectionString); connection = await mysql.createConnection(credentials.connectionString);
} else { } else {
connection = await mysql.createConnection( const creds = this.parseCredentials(credentials);
this.parseCredentials(credentials), console.log(creds);
); connection = await mysql.createConnection(creds);
} }
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -69,22 +50,6 @@ export class MySQLDriver implements Driver {
return connection; 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) { async getAllDatabases(credentials: Credentials) {
console.log("Get all databases"); console.log("Get all databases");
@@ -102,11 +67,15 @@ export class MySQLDriver implements Driver {
async getAllTables( async getAllTables(
credentials: Credentials, credentials: Credentials,
{ sortDesc, sortField, dbName }: WithSort<{ dbName: string }>, {
sortDesc,
sortField = "schema_name",
dbName,
}: WithSort<{ dbName: string }>,
) { ) {
const connection = await this.queryRunner(credentials); const connection = await this.queryRunner(credentials);
const tablesQuery = ` let tablesQuery = `
SELECT SELECT
TABLE_NAME as table_name, TABLE_NAME as table_name,
TABLE_SCHEMA as schema_name, TABLE_SCHEMA as schema_name,
@@ -118,8 +87,11 @@ export class MySQLDriver implements Driver {
FROM FROM
information_schema.tables information_schema.tables
WHERE WHERE
table_schema = ?; table_schema = ?
`; `;
if (sortField) {
tablesQuery += ` ORDER BY ${sortField} ${sortDesc ? "DESC" : "ASC"}`;
}
const [tables] = await connection.execute(tablesQuery, [dbName]); const [tables] = await connection.execute(tablesQuery, [dbName]);
@@ -301,74 +273,71 @@ export class MySQLDriver implements Driver {
credentials: Credentials, credentials: Credentials,
{ dbName, tableName }: { dbName: string; tableName: string }, { dbName, tableName }: { dbName: string; tableName: string },
) { ) {
const sql = await this.queryRunner(credentials); const connection = await this.queryRunner(credentials);
const result = await sql` try {
SELECT const [rows] = await connection.execute(
conname, `
condeferrable::int AS deferrable, SELECT
pg_get_constraintdef(oid) AS definition rc.CONSTRAINT_NAME as conname,
FROM rc.UPDATE_RULE as on_update,
pg_constraint rc.DELETE_RULE as on_delete,
WHERE kcu.COLUMN_NAME as source,
conrelid = ( kcu.REFERENCED_TABLE_NAME as \`table\`,
SELECT pc.oid kcu.REFERENCED_COLUMN_NAME as target
FROM pg_class AS pc FROM
INNER JOIN pg_namespace AS pn ON (pn.oid = pc.relnamespace) information_schema.REFERENTIAL_CONSTRAINTS rc
WHERE pc.relname = ${tableName} JOIN
AND pn.nspname = ${dbName} information_schema.KEY_COLUMN_USAGE kcu
) ON
AND contype = 'f'::char rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
ORDER BY conkey, conname AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
`; WHERE
kcu.TABLE_SCHEMA = ?
void sql.end(); AND kcu.TABLE_NAME = ?
`,
return result.map((row) => { [dbName, tableName],
const match = row.definition.match(
/FOREIGN KEY\s*\((.+)\)\s*REFERENCES (.+)\((.+)\)(.*)$/iy,
); );
if (match) {
const sourceColumns = match[1] await connection.end();
.split(",")
.map((col) => col.replaceAll('"', "").trim()); const foreignKeys: { [key: string]: ForeignKeyInfo } = {};
const targetTableMatch = match[2].match( (rows as any[]).forEach((row) => {
/^(("([^"]|"")+"|[^"]+)\.)?"?("([^"]|"")+"|[^"]+)$/, if (!foreignKeys[row.conname]) {
); foreignKeys[row.conname] = {
const targetTable = targetTableMatch conname: row.conname,
? targetTableMatch[0].trim() deferrable: false,
: null; definition: `FOREIGN KEY (${row.source}) REFERENCES ${row.table}(${row.target})`,
const targetColumns = match[3] source: [],
.split(",") ns: row.table,
.map((col) => col.replaceAll('"', "").trim()); table: row.table,
const { onDelete, onUpdate } = this.getActions(match[4]); target: [],
return { on_delete: row.on_delete,
conname: row.conname, on_update: row.on_update,
deferrable: Boolean(row.deferrable), };
definition: row.definition, }
source: sourceColumns, foreignKeys[row.conname].source.push(row.source);
ns: targetTableMatch foreignKeys[row.conname].target.push(row.target);
? targetTableMatch[0].replaceAll('"', "").trim() });
: null,
table: targetTable.replaceAll('"', ""), return Object.values(foreignKeys);
target: targetColumns, } catch (error) {
on_delete: onDelete ?? "NO ACTION", console.error("Error fetching foreign keys:", error);
on_update: onUpdate ?? "NO ACTION", await connection.end();
}; throw error;
} }
});
} }
async executeQuery(credentials: Credentials, query: string) { 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 { return {
count: result.length, count: rows?.length,
data: result, data: rows,
}; };
} }
} }

View File

@@ -6,22 +6,19 @@ import type { AppType } from "../index";
const fetch = edenFetch<AppType>("http://localhost:3000"); const fetch = edenFetch<AppType>("http://localhost:3000");
const testDBName = "users";
const pgCookie = cookie.serialize( const pgCookie = cookie.serialize(
"auth", "auth",
jwt.sign( jwt.sign(
// {
// type: "postgres",
// username: "postgres",
// password: "mysecretpassword",
// host: "localhost",
// port: "5432",
// database: "postgres",
// ssl: "prefer",
// },
{ {
type: "postgres", type: "postgres",
connectionString: username: "postgres",
"postgresql://flashcards_owner:pBYW18waUHtV@ep-gentle-heart-a225yqws.eu-central-1.aws.neon.tech/flashcards?sslmode=require&schema=flashcards", password: "mysecretpassword",
host: "localhost",
port: "5432",
database: "postgres",
ssl: "prefer",
}, },
"Fischl von Luftschloss Narfidort", "Fischl von Luftschloss Narfidort",
{ noTimestamp: true }, { noTimestamp: true },
@@ -213,7 +210,7 @@ describe("databases/:dbName/tables/:tableName/data", () => {
const res = await fetch("/api/databases/:dbName/tables/:tableName/data", { const res = await fetch("/api/databases/:dbName/tables/:tableName/data", {
params: { params: {
dbName: "public", dbName: "public",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -240,7 +237,7 @@ describe("databases/:dbName/tables/:tableName/data", () => {
const res = await fetch("/api/databases/:dbName/tables/:tableName/data", { const res = await fetch("/api/databases/:dbName/tables/:tableName/data", {
params: { params: {
dbName: "test_db", dbName: "test_db",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -271,7 +268,7 @@ describe("databases/:dbName/tables/:tableName/indexes", () => {
{ {
params: { params: {
dbName: "public", dbName: "public",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -302,7 +299,7 @@ describe("databases/:dbName/tables/:tableName/indexes", () => {
{ {
params: { params: {
dbName: "test_db", dbName: "test_db",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -335,7 +332,7 @@ describe("databases/:dbName/tables/:tableName/columns", () => {
{ {
params: { params: {
dbName: "public", dbName: "public",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -375,7 +372,7 @@ describe("databases/:dbName/tables/:tableName/columns", () => {
{ {
params: { params: {
dbName: "test_db", dbName: "test_db",
tableName: "test_table", tableName: testDBName,
}, },
method: "GET", method: "GET",
headers: { headers: {
@@ -407,81 +404,149 @@ describe("databases/:dbName/tables/:tableName/columns", () => {
}); });
describe("databases/:dbName/tables/:tableName/foreign-keys", () => { describe("databases/:dbName/tables/:tableName/foreign-keys", () => {
// it("should return correct data from PostgreSQL", async () => { it("should return correct data from PostgreSQL", async () => {
// const res = await fetch( const res = await fetch(
// "/api/databases/:dbName/tables/:tableName/foreign-keys", "/api/databases/:dbName/tables/:tableName/foreign-keys",
// { {
// params: { params: {
// dbName: "public", dbName: "public",
// tableName: "test_table", tableName: "orders",
// }, },
// method: "GET", method: "GET",
// headers: { headers: {
// cookie: pgCookie, cookie: pgCookie,
// }, },
// }, },
// ); );
// expect(res.status).toEqual(200); expect(res.status).toEqual(200);
//
// expect(res.data).not.toEqual("Unauthorized"); expect(res.data).not.toEqual("Unauthorized");
// if (res.data === "Unauthorized") return; if (res.data === "Unauthorized") return;
//
// console.log(res.data); expect(res.data).toBeDefined();
// if (!res.data) return;
// expect(res.data).toBeDefined();
// if (!res.data) return; expect(res.data).toBeArray();
//
// expect(res.data).toBeArray(); for (const row of res.data) {
// expect(row).toContainAllKeys([
// for (const row of res.data) { "conname",
// expect(row).toContainAllKeys([ "deferrable",
// "column_name", "definition",
// "data_type", "source",
// "udt_name", "ns",
// "column_comment", "on_delete",
// ]); "on_update",
// expect(row.column_name).toBeString(); "table",
// expect(row.data_type).toBeString(); "target",
// expect(row.udt_name).toBeString(); ]);
// expect( expect(row.conname).toBeString();
// row.column_comment === null || typeof row.column_comment === "string", expect(row.deferrable).toBeBoolean();
// ).toBeTrue(); expect(row.definition).toBeString();
// } expect(row.source).toBeArray();
// }); expect(row.ns).toBeString();
// it("should return correct data from MySQL", async () => { expect(row.on_delete).toBeString();
// const res = await fetch( expect(row.on_update).toBeString();
// "/api/databases/:dbName/tables/:tableName/columns", expect(row.table).toBeString();
// { expect(row.target).toBeArray();
// params: { }
// dbName: "test_db", });
// tableName: "test_table", it("should return correct data from MySQL", async () => {
// }, const res = await fetch(
// method: "GET", "/api/databases/:dbName/tables/:tableName/foreign-keys",
// headers: { {
// cookie: mysqlCookie, params: {
// }, dbName: "test_db",
// }, tableName: "orders",
// ); },
// console.log(res.data); method: "GET",
// headers: {
// expect(res.data).toBeDefined(); cookie: mysqlCookie,
// if (!res.data) return; },
// },
// expect(res.data).toBeArray(); );
//
// for (const row of res.data) { expect(res.data).toBeDefined();
// expect(row).toContainAllKeys([ if (!res.data) return;
// "column_name",
// "data_type", expect(res.data).toBeArray();
// "udt_name",
// "column_comment", for (const row of res.data) {
// ]); expect(row).toContainAllKeys([
// expect(row.column_name).toBeString(); "conname",
// expect(row.data_type).toBeString(); "deferrable",
// expect(row.udt_name).toBeString(); "definition",
// expect( "source",
// row.column_comment === null || typeof row.column_comment === "string", "ns",
// ).toBeTrue(); "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();
});
}); });

View File

@@ -95,7 +95,7 @@ export const DataTable = ({
</div> </div>
); );
}, },
sortable: true, enableSorting: true,
cell: ({ row }) => { cell: ({ row }) => {
const value = row.getValue(column_name) as any; const value = row.getValue(column_name) as any;
let finalValue = value; let finalValue = value;

View File

@@ -23,7 +23,13 @@ declare module "@tanstack/react-router" {
} }
} }
const queryClient = new QueryClient(); const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
// Render the app // Render the app
const rootElement = document.getElementById("root"); const rootElement = document.getElementById("root");

View File

@@ -29,6 +29,7 @@ function TableView() {
return ( return (
<div className="p-3 h-layout w-layout"> <div className="p-3 h-layout w-layout">
<DataTable <DataTable
key={tableName}
dbName={dbName} dbName={dbName}
tableName={tableName} tableName={tableName}
pageSize={pageSize} pageSize={pageSize}

View File

@@ -80,10 +80,12 @@ const createColumns = (dbName: string) => {
columnHelper.accessor("primary_key", { columnHelper.accessor("primary_key", {
header: "Primary key", header: "Primary key",
enableSorting: false,
}), }),
columnHelper.accessor("indexes", { columnHelper.accessor("indexes", {
header: "Indexes", header: "Indexes",
enableSorting: false,
cell: (props) => { cell: (props) => {
const indexes = props.getValue(); const indexes = props.getValue();
if (!indexes) return null; if (!indexes) return null;