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
docker exec -i db-postgres-test psql -U postgres <<EOF
CREATE TABLE IF NOT EXISTS test_table (id SERIAL PRIMARY KEY, name VARCHAR(50));
INSERT INTO test_table (name) VALUES ('John Doe');
CREATE TABLE IF NOT EXISTS users (
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
# Set up MySQL test data
docker exec -i db-mysql-test mysql -uroot -pmysecretpassword<<EOF
CREATE DATABASE IF NOT EXISTS test_db;
USE test_db;
CREATE TABLE IF NOT EXISTS test_table (id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50));
INSERT INTO test_table (name) VALUES ('Jane Doe');
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
echo "Test databases are ready"

View File

@@ -2,12 +2,14 @@
# Clean up PostgreSQL test data
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
# Clean up MySQL test data
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
# 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 {
Credentials,
Driver,
@@ -6,35 +6,17 @@ import type {
WithSortPagination,
} from "./driver.interface";
const isResultSetHeader = (data: unknown): data is ResultSetHeader => {
if (!data || typeof data !== "object") return false;
const keys = [
"fieldCount",
"affectedRows",
"insertId",
"info",
"serverStatus",
"warningStatus",
"changedRows",
];
return keys.every((key) => key in data);
};
export class MySQLDriver implements Driver {
parseCredentials({
username,
password,
host,
type,
port,
database,
}: {
username: string;
password: string;
host: string;
type: string;
port: string;
database: string;
ssl: string;
@@ -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`
try {
const [rows] = await connection.execute(
`
SELECT
conname,
condeferrable::int AS deferrable,
pg_get_constraintdef(oid) AS definition
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
pg_constraint
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
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,
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 {
await connection.end();
const foreignKeys: { [key: string]: ForeignKeyInfo } = {};
(rows as any[]).forEach((row) => {
if (!foreignKeys[row.conname]) {
foreignKeys[row.conname] = {
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",
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,
};
}
}

View File

@@ -6,22 +6,19 @@ import type { AppType } from "../index";
const fetch = edenFetch<AppType>("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();
});
});

View File

@@ -95,7 +95,7 @@ export const DataTable = ({
</div>
);
},
sortable: true,
enableSorting: true,
cell: ({ row }) => {
const value = row.getValue(column_name) as any;
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
const rootElement = document.getElementById("root");

View File

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

View File

@@ -80,10 +80,12 @@ const createColumns = (dbName: string) => {
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;