From 65a652cab44c00cc521ce72489f522b69890ef07 Mon Sep 17 00:00:00 2001 From: Kariamos Date: Fri, 6 Feb 2026 10:59:06 +0100 Subject: [PATCH] feat: add transaction support and examples to the database module --- src/features/db/TRANSACTIONS.md.ts | 194 +++++++++++++++++++++++++ src/features/db/class/Database.spec.ts | 100 +++++++++++++ src/features/db/class/Database.ts | 32 ++-- src/features/db/index.ts | 31 +++- 4 files changed, 346 insertions(+), 11 deletions(-) create mode 100644 src/features/db/TRANSACTIONS.md.ts diff --git a/src/features/db/TRANSACTIONS.md.ts b/src/features/db/TRANSACTIONS.md.ts new file mode 100644 index 000000000..cede8c6b4 --- /dev/null +++ b/src/features/db/TRANSACTIONS.md.ts @@ -0,0 +1,194 @@ +/** + * Transactions Support in Tryber API + * + * This file provides examples of how to use Knex transactions in the codebase. + * Transactions ensure that multiple database operations are executed atomically: + * either all succeed or all are rolled back. + */ + +import * as db from "@src/features/db"; +import { tryber } from "@src/features/database"; + +/** + * Example 1: Using db.transaction() helper with raw queries + * + * The transaction() helper automatically handles commit/rollback: + * - If the callback completes successfully, the transaction is committed + * - If an error is thrown, the transaction is rolled back + */ +export async function exampleRawQueries() { + await db.transaction(async (trx) => { + // Execute multiple queries in the same transaction + await db.query("INSERT INTO users (name) VALUES ('John')", trx); + await db.query( + "INSERT INTO profiles (user_id, bio) VALUES (1, 'Bio')", + trx + ); + + // If any query fails, all changes are rolled back + }); +} + +/** + * Example 2: Using transactions with Database class + * + * All methods in the Database class now accept an optional `trx` parameter + */ +export async function exampleDatabaseClass() { + const Experience = new ( + await import("@src/features/db/class/Experience") + ).default(); + + await db.transaction(async (trx) => { + // Insert operations within a transaction + const result1 = await Experience.insert( + { + tester_id: 1, + amount: 100, + creation_date: new Date().toISOString(), + activity_id: 1, + reason: "Campaign participation", + campaign_id: 1, + pm_id: 1, + }, + trx + ); + + const result2 = await Experience.insert( + { + tester_id: 1, + amount: 50, + creation_date: new Date().toISOString(), + activity_id: 2, + reason: "Bug report", + campaign_id: 1, + pm_id: 1, + }, + trx + ); + + // Query within the same transaction + const records = await Experience.query({ + where: [{ tester_id: 1 }], + trx, + }); + + // Update within the same transaction + await Experience.update({ + data: { amount: 150 }, + where: [{ id: result1.insertId }], + trx, + }); + + // Delete within the same transaction + await Experience.delete([{ id: result2.insertId }], trx); + }); +} + +/** + * Example 3: Using transactions with tryber tables (Knex query builder) + * + * You can also use the tryber instance directly with transactions + */ +export async function exampleKnexQueryBuilder() { + await db.transaction(async (trx) => { + // Using Knex query builder with transaction + await tryber.tables.WpUsers.do().transacting(trx).insert({ + ID: 123, + user_login: "test_user", + user_email: "test@example.com", + }); + + // Multiple operations in the same transaction + const user = await tryber.tables.WpUsers.do() + .transacting(trx) + .where({ user_email: "test@example.com" }) + .first(); + + if (user) { + await tryber.tables.WpAppqEvdProfile.do().transacting(trx).insert({ + wp_user_id: user.ID, + id: 1, + email: user.user_email, + education_id: 1, + employment_id: 1, + }); + } + }); +} + +/** + * Example 4: Error handling and rollback + * + * When an error occurs, the transaction is automatically rolled back + */ +export async function exampleErrorHandling() { + try { + await db.transaction(async (trx) => { + await db.query("INSERT INTO users (name) VALUES ('John')", trx); + + // This will cause an error and rollback all changes + throw new Error("Something went wrong"); + + // This line will never be executed + await db.query( + "INSERT INTO profiles (user_id, bio) VALUES (1, 'Bio')", + trx + ); + }); + } catch (error) { + console.error("Transaction failed:", error); + // All database changes have been rolled back + } +} + +/** + * Example 5: Mixed usage - transaction with both Database class and raw queries + */ +export async function exampleMixedUsage() { + const Experience = new ( + await import("@src/features/db/class/Experience") + ).default(); + + await db.transaction(async (trx) => { + // Use Database class + const expResult = await Experience.insert( + { + tester_id: 1, + amount: 100, + creation_date: new Date().toISOString(), + activity_id: 1, + reason: "Test", + campaign_id: 1, + pm_id: 1, + }, + trx + ); + + // Use raw query + await db.query( + `UPDATE wp_appq_user SET total_exp = total_exp + 100 WHERE id = 1`, + trx + ); + + // Use Knex query builder + await tryber.tables.WpAppqEventTransactionalMail.do() + .transacting(trx) + .insert({ + event_name: "experience_added", + template_id: 1, + last_editor_tester_id: 1, + }); + }); +} + +/** + * IMPORTANT NOTES: + * + * 1. Always pass the `trx` parameter to ALL database operations within the transaction + * 2. Don't mix transactional and non-transactional operations in the same logical flow + * 3. Keep transactions short to avoid locking issues + * 4. The transaction is automatically committed if the callback completes without errors + * 5. The transaction is automatically rolled back if an error is thrown + * 6. All methods are backward compatible - the `trx` parameter is optional + */ diff --git a/src/features/db/class/Database.spec.ts b/src/features/db/class/Database.spec.ts index 67a5c8d4c..74fce259f 100644 --- a/src/features/db/class/Database.spec.ts +++ b/src/features/db/class/Database.spec.ts @@ -1,5 +1,7 @@ import jest from "jest"; import Database from "./Database"; +import * as db from "../index"; +import { tryber } from "@src/features/database"; class TestableDatabase extends Database<{ fields: { id: number; name: string }; @@ -89,3 +91,101 @@ describe("Database connector class", () => { expect(sql).toBe("ORDER BY id DESC, name ASC"); }); }); + +describe("Database transactions", () => { + // Create a real Database instance for testing transactions + class UserDatabase extends Database<{ + fields: { ID: number; user_login: string; user_email: string }; + }> { + constructor() { + super({ + table: "wp_users", + primaryKey: "ID", + fields: ["ID", "user_login", "user_email"], + }); + } + } + + const userDb = new UserDatabase(); + + afterAll(async () => { + // Clean up test data after all tests + await tryber.tables.WpUsers.do().where("ID", ">", 999999).delete(); + }); + + it("Should commit insert operation when transaction succeeds", async () => { + await db.transaction(async (trx) => { + await userDb.insert( + { + ID: 1000000, + user_login: "test_user_1", + user_email: "test1@example.com", + }, + trx + ); + }); + + // Verify data was committed + const user = await userDb.get(1000000); + expect(user).toBeDefined(); + expect(user?.user_login).toBe("test_user_1"); + }); + + it("Should rollback insert operation when transaction fails", async () => { + try { + await db.transaction(async (trx) => { + await userDb.insert( + { + ID: 1000001, + user_login: "test_user_2", + user_email: "test2@example.com", + }, + trx + ); + // Force transaction to fail + throw new Error("Simulated transaction failure"); + }); + } catch (e) { + // Expected error + } + + // Verify data was rolled back + const exists = await userDb.exists(1000001); + expect(exists).toBe(false); + }); + + it("Should rollback all operations when one fails in a transaction", async () => { + try { + await db.transaction(async (trx) => { + // Insert first user + await userDb.insert( + { + ID: 1000008, + user_login: "test_user_9", + user_email: "test9@example.com", + }, + trx + ); + // Insert second user + await userDb.insert( + { + ID: 1000009, + user_login: "test_user_10", + user_email: "test10@example.com", + }, + trx + ); + // Force transaction to fail + throw new Error("Simulated transaction failure"); + }); + } catch (e) { + // Expected error + } + + // Verify both inserts were rolled back + const exists1 = await userDb.exists(1000008); + const exists2 = await userDb.exists(1000009); + expect(exists1).toBe(false); + expect(exists2).toBe(false); + }); +}); diff --git a/src/features/db/class/Database.ts b/src/features/db/class/Database.ts index 0eff83ea0..1f07c81bb 100644 --- a/src/features/db/class/Database.ts +++ b/src/features/db/class/Database.ts @@ -1,4 +1,6 @@ import * as db from "@src/features/db"; +import { Knex } from "knex"; + type Arrayable = { [K in keyof T]: T[K] | T[K][] }; type WhereConditions = @@ -37,13 +39,14 @@ class Database>> { this.fields = fields ? fields : ["*"]; } - public async get(id: number) { + public async get(id: number, trx?: Knex.Transaction) { if (!this.primaryKey) { throw new Error("No primary key defined"); } const result = await this.query({ where: [{ [this.primaryKey]: id }] as Database["where"], limit: 1, + trx, }); if (result.length === 0) { throw new Error(`No ${this.table} with id ${id}`); @@ -51,13 +54,14 @@ class Database>> { return result[0]; } - public async exists(id: number): Promise { + public async exists(id: number, trx?: Knex.Transaction): Promise { if (!this.primaryKey) { throw new Error("No primary key defined"); } const result = await this.query({ where: [{ [this.primaryKey]: id }] as Database["where"], limit: 1, + trx, }); return result.length > 0; } @@ -67,14 +71,16 @@ class Database>> { orderBy, limit, offset, + trx, }: { where?: Database["where"]; orderBy?: Database["orderBy"]; limit?: number; offset?: number; + trx?: Knex.Transaction; }): Promise[]> { const sql = this.constructSelectQuery({ where, orderBy, limit, offset }); - return (await db.query(sql)).map((item: T["fields"]) => + return (await db.query(sql, trx)).map((item: T["fields"]) => this.createObject(item) ); } @@ -84,11 +90,13 @@ class Database>> { orderBy, limit, offset, + trx, }: { where: string; orderBy?: Database["orderBy"]; limit?: number; offset?: number; + trx?: Knex.Transaction; }): Promise[]> { const sql = this.constructSelectQuery({ where: { customWhere: where }, @@ -96,7 +104,7 @@ class Database>> { limit, offset, }); - return (await db.query(sql)).map((item: T["fields"]) => + return (await db.query(sql, trx)).map((item: T["fields"]) => this.createObject(item) ); } @@ -104,24 +112,30 @@ class Database>> { public async update({ data, where, + trx, }: { data: Database["fieldItem"]; where: Database["where"]; + trx?: Knex.Transaction; }) { const sql = this.constructUpdateQuery({ data, where }); - await db.query(sql); + await db.query(sql, trx); } public async insert( - data: Database["fieldItem"] + data: Database["fieldItem"], + trx?: Knex.Transaction ): Promise<{ insertId: number }> { const sql = this.constructInsertQuery({ data }); - return await db.query(sql); + return await db.query(sql, trx); } - public async delete(where: Database["fieldItem"][]) { + public async delete( + where: Database["fieldItem"][], + trx?: Knex.Transaction + ) { const sql = this.constructDeleteQuery({ where }); - await db.query(sql); + await db.query(sql, trx); } public createObject(item: T["fields"]) { diff --git a/src/features/db/index.ts b/src/features/db/index.ts index 50771ba3b..e9929fe6d 100644 --- a/src/features/db/index.ts +++ b/src/features/db/index.ts @@ -1,12 +1,39 @@ import mysql from "mysql"; import { tryber } from "../database"; +import { Knex } from "knex"; export const format = (query: string, data: (string | number)[]) => { return mysql.format(query, data); }; -export const query = async (query: string): Promise => { - const res = await tryber.raw(query); +export const query = async ( + query: string, + trx?: Knex.Transaction +): Promise => { + const connection = trx || tryber; + const res = await connection.raw(query); if (tryber.client === "better-sqlite3") return res; return res ? res[0] : []; }; + +/** + * Execute a function within a database transaction. + * If the function completes successfully, the transaction is committed. + * If an error is thrown, the transaction is rolled back. + * + * @example + * ```typescript + * await transaction(async (trx) => { + * await db.query("INSERT INTO users ...", trx); + * await db.query("INSERT INTO profiles ...", trx); + * }); + * ``` + */ +export const transaction = async ( + callback: (trx: Knex.Transaction) => Promise +): Promise => { + // Access the Knex client through a query builder + // Cast to any to access the transaction method properly + const knex = tryber.tables.WpUsers.do().client as any; + return await knex.transaction(callback); +};