From ee0dd09fd737635c0f383aee99c6d45604735682 Mon Sep 17 00:00:00 2001 From: krzys13 Date: Thu, 11 Jun 2026 18:46:47 +0200 Subject: [PATCH 1/2] refactor: add transaction handling in auto CRUD controller #277 Add transaction usage in all methods of auto crud controller Add transaction usage in v1/drafts controler Add transaction usage in validators exactly in foreign key validator --- app/controllers/auto_crud_controller.ts | 1026 +++++++++++++---------- app/controllers/v1/drafts.ts | 48 +- app/utils/model_autogen.ts | 8 +- app/validators/db.ts | 8 +- 4 files changed, 621 insertions(+), 469 deletions(-) diff --git a/app/controllers/auto_crud_controller.ts b/app/controllers/auto_crud_controller.ts index c41dda61..ce62b18c 100644 --- a/app/controllers/auto_crud_controller.ts +++ b/app/controllers/auto_crud_controller.ts @@ -11,6 +11,7 @@ import type { StoreRouteNode, } from "@adonisjs/core/types/http"; import db from "@adonisjs/lucid/services/db"; +import type { TransactionClientContract } from "@adonisjs/lucid/types/database"; import type { ExtractScopes, LucidModel, @@ -147,6 +148,11 @@ export type ControllerAction = export default abstract class AutoCrudController< T extends LucidModel & Scopes, > extends BaseController { + /** + * Isolation level used by all transactions started by the auto CRUD handlers. + */ + private readonly isolationLevel = "repeatable read" as const; + /** * Relations which should be supported in queries * Supports nested relations @@ -682,20 +688,31 @@ export default abstract class AutoCrudController< const { request } = httpCtx; await this.selfValidate(); // Public by default; override requiredPermissionFor to restrict - await this.authenticate(httpCtx, "index"); - const { page, limit } = await request.validateUsing(paginationValidator); - const relations = await request.validateUsing(this.relationValidator); - const baseQuery = this.model - .query() - .withScopes((scopes: ExtractScopes & ScopesWithoutFirstArg) => { - scopes.handleSearchQuery(request.qs()); - scopes.preloadRelations(relations); - scopes.handleSortQuery(request.input("sort")); - }); - if (page === undefined && limit === undefined) { - return { data: await baseQuery }; - } - return await baseQuery.paginate(page ?? 1, limit ?? 10); + const data = await db.transaction( + async (trx) => { + await this.authenticate(httpCtx, "index"); + + const { page, limit } = + await request.validateUsing(paginationValidator); + const relations = await request.validateUsing(this.relationValidator); + const baseQuery = this.model + .query({ client: trx }) + .withScopes((scopes: ExtractScopes & ScopesWithoutFirstArg) => { + scopes.handleSearchQuery(request.qs()); + scopes.preloadRelations(relations); + scopes.handleSortQuery(request.input("sort")); + }); + + if (page === undefined && limit === undefined) { + return { data: await baseQuery }; + } + + return await baseQuery.paginate(page ?? 1, limit ?? 10); + }, + { isolationLevel: this.isolationLevel }, + ); + + return data; } /** @@ -706,34 +723,43 @@ export default abstract class AutoCrudController< async show(httpCtx: HttpContext): Promise { const { request } = httpCtx; await this.selfValidate(); - await this.authenticate(httpCtx, "show"); + const data = await db.transaction( + async (trx) => { + await this.authenticate(httpCtx, "show"); + + let id: string | number; + if (this.singletonId !== undefined) { + id = this.singletonId; + } else { + const { params } = (await request.validateUsing( + this.pathIdValidator, + { meta: { trx } }, + )) as { + params: { id: string | number }; + }; + id = params.id; + } - let id: string | number; - if (this.singletonId !== undefined) { - id = this.singletonId; - } else { - const { params } = (await request.validateUsing( - this.pathIdValidator, - )) as { - params: { id: string | number }; - }; - id = params.id; - } + const primaryColumnName = this.primaryKeyField.columnOptions.columnName; + await this.authorizeById(httpCtx, "show", { localId: id }); + const relations = await request.validateUsing(this.relationValidator); + + const fetchedData = await this.model + .query({ client: trx }) + .withScopes((scopes: ExtractScopes & ScopesWithoutFirstArg) => { + scopes.preloadRelations(relations); + }) + .where(primaryColumnName, id) + .firstOrFail() + .addErrorContext( + () => + `${this.model.name} with '${primaryColumnName}' = '${id}' does not exist`, + ); + return fetchedData; + }, + { isolationLevel: this.isolationLevel }, + ); - const primaryColumnName = this.primaryKeyField.columnOptions.columnName; - await this.authorizeById(httpCtx, "show", { localId: id }); - const relations = await request.validateUsing(this.relationValidator); - const data = await this.model - .query() - .withScopes((scopes: ExtractScopes & ScopesWithoutFirstArg) => { - scopes.preloadRelations(relations); - }) - .where(primaryColumnName, id) - .firstOrFail() - .addErrorContext( - () => - `${this.model.name} with '${primaryColumnName}' = '${id}' does not exist`, - ); await this.authorizeRecord(httpCtx, "show", data); return { data }; } @@ -744,46 +770,52 @@ export default abstract class AutoCrudController< * Return type set to Promise to allow for method overrides */ async store(httpCtx: HttpContext): Promise { - const { request, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } - await this.authenticate(httpCtx, "store"); await this.selfValidate(); + let toStore!: PartialModel; + const result = await db.transaction( + async (trx) => { + const { auth } = httpCtx; + if (!auth.isAuthenticated) { + await auth.authenticate(); + } - let toStore = (await request.validateUsing( - this.storeValidator, - )) as PartialModel; - - toStore = - (await this.storeHook({ - http: httpCtx, - model: this.model, - request: toStore, - })) ?? toStore; - - const result = await this.model.create(toStore).addErrorContext({ - message: "Failed to store object", - code: "E_DB_ERROR", - status: 500, - }); - - await result.refresh().addErrorContext({ - message: "Failed to fetch updated object", - code: "E_DB_ERROR", - status: 500, - }); - - await this.postStoreHook({ - http: httpCtx, - model: this.model, - request: toStore, - record: result, - }).addErrorContext({ - message: "Controller's postStoreHook threw an error", - code: "E_INTERNAL_CONTROLLER_ERROR", - status: 500, - }); + await this.authenticate(httpCtx, "store"); + + toStore = (await httpCtx.request.validateUsing(this.storeValidator, { + meta: { trx }, + })) as PartialModel; + + toStore = + (await this.storeHook({ + http: httpCtx, + model: this.model, + request: toStore, + })) ?? toStore; + + const Model = this.model; + const createdModel = await Model.create(toStore, { client: trx }); + + await createdModel.refresh().addErrorContext({ + message: "Failed to fetch updated object", + code: "E_DB_ERROR", + status: 500, + }); + + await this.postStoreHook({ + http: httpCtx, + model: this.model, + request: toStore, + record: createdModel, + }).addErrorContext({ + message: "Controller's postStoreHook threw an error", + code: "E_INTERNAL_CONTROLLER_ERROR", + status: 500, + }); + return createdModel; + }, + + { isolationLevel: this.isolationLevel }, + ); return { success: true, @@ -791,20 +823,37 @@ export default abstract class AutoCrudController< }; } - protected async getFirstOrFail(id: string | number) { + /** + * Fetch a single model instance by its primary key or throw if it does not exist. + * + * Optionally accepts a transaction client to execute the query + * within an existing transaction context. + */ + protected async getFirstOrFail( + id: string | number, + trx?: TransactionClientContract, + ) { const primaryColumnName = this.primaryKeyField.columnOptions.columnName; - return await this.model - .query() + const data = await this.model + .query(trx !== undefined ? { client: trx } : undefined) .where(primaryColumnName, id) .firstOrFail() .addErrorContext( () => `${this.model.name} with '${primaryColumnName}' = '${id}' does not exist`, ); + return data; } - protected async saveOrFail(row: InstanceType) { - await row.save().addErrorContext({ + protected async saveOrFail( + row: InstanceType, + trx?: TransactionClientContract, + ) { + if (trx !== undefined) { + row.useTransaction(trx); + } + + return await row.save().addErrorContext({ message: "Failed to commit updates", code: "E_DB_ERROR", status: 500, @@ -816,48 +865,58 @@ export default abstract class AutoCrudController< * * Return type set to Promise to allow for method overrides */ + async update(httpCtx: HttpContext): Promise { const { request, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } - await this.authenticate(httpCtx, "update"); await this.selfValidate(); - - let id: string | number; - if (this.singletonId !== undefined) { - id = this.singletonId; - } else { - const { params } = (await request.validateUsing( - this.pathIdValidator, - )) as { - params: { id: string | number }; - }; - id = params.id; - } - let updates = (await request.validateUsing( - this.updateValidator, - )) as PartialModel; - - await this.authorizeById(httpCtx, "update", { localId: id }); - const row = await this.getFirstOrFail(id); - await this.authorizeRecord(httpCtx, "update", row); - updates = - (await this.updateHook({ - http: httpCtx, - model: this.model, - record: row, - request: updates, - })) ?? updates; - - row.merge(updates); - await this.saveOrFail(row); - await row.refresh().addErrorContext({ - message: "Failed to fetch updated object", - code: "E_DB_ERROR", - status: 500, - }); - + const row = await db.transaction( + async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } + await this.authenticate(httpCtx, "update"); + + let id: string | number; + if (this.singletonId !== undefined) { + id = this.singletonId; + } else { + const { params } = (await request.validateUsing( + this.pathIdValidator, + { meta: { trx } }, + )) as { + params: { id: string | number }; + }; + id = params.id; + } + await this.authorizeById(httpCtx, "update", { localId: id }); + + let updates = (await request.validateUsing(this.updateValidator, { + meta: { trx }, + })) as PartialModel; + + const searchedRow = await this.getFirstOrFail(id, trx); + await this.authorizeRecord(httpCtx, "update", searchedRow); + updates = + (await this.updateHook({ + http: httpCtx, + model: this.model, + record: searchedRow, + request: updates, + })) ?? updates; + + searchedRow.merge(updates); + + const updatedRow = await this.saveOrFail(searchedRow, trx); + + await updatedRow.refresh().addErrorContext({ + message: "Failed to fetch updated object", + code: "E_DB_ERROR", + status: 500, + }); + return updatedRow; + }, + { isolationLevel: this.isolationLevel }, + ); return { success: true, data: row, @@ -874,45 +933,49 @@ export default abstract class AutoCrudController< if (!auth.isAuthenticated) { await auth.authenticate(); } - await this.authenticate(httpCtx, "destroy"); - await this.selfValidate(); - - const { - params: { id }, - } = (await request - .validateUsing(this.pathIdValidator) - .addErrorContext(() => { - return { - message: `Attempt to delete non existent ${this.model.name}`, - code: "E_NOT_FOUND", - status: 404, + await db.transaction( + async (trx) => { + await this.authenticate(httpCtx, "destroy"); + await this.selfValidate(); + + const { + params: { id }, + } = (await request + .validateUsing(this.pathIdValidator, { meta: { trx } }) + .addErrorContext(() => { + return { + message: `Attempt to delete non existent ${this.model.name}`, + code: "E_NOT_FOUND", + status: 404, + }; + })) as { + params: { id: string | number }; }; - })) as { - params: { id: string | number }; - }; - await this.authorizeById(httpCtx, "destroy", { localId: id }); - - const record = await this.getFirstOrFail(id); - await this.authorizeRecord(httpCtx, "destroy", record); - - await this.destroyHook({ - http: httpCtx, - model: this.model, - record, - }); - - await record.delete().addErrorContext({ - message: "Failed to delete object", - code: "E_DB_ERROR", - status: 500, - }); - - // Clean up any permissions scoped to the deleted instance - const morphAlias = getMorphMapAlias(this.model); - if (morphAlias !== null) { - await deletePermissionsForEntity(morphAlias, id); - } + await this.authorizeById(httpCtx, "destroy", { localId: id }); + + const record = await this.getFirstOrFail(id, trx); + await this.authorizeRecord(httpCtx, "destroy", record); + + await this.destroyHook({ + http: httpCtx, + model: this.model, + record, + }); + + await record.delete().addErrorContext({ + message: "Failed to delete object", + code: "E_DB_ERROR", + status: 500, + }); + // Clean up any permissions scoped to the deleted instance + const morphAlias = getMorphMapAlias(this.model); + if (morphAlias !== null) { + await deletePermissionsForEntity(morphAlias, id); + } + }, + { isolationLevel: this.isolationLevel }, + ); return { success: true, @@ -928,55 +991,64 @@ export default abstract class AutoCrudController< const { request, route } = httpCtx; await this.selfValidate(); const relationName = this.relationNameFromRoute(route); - await this.authenticate(httpCtx, "relationIndex", relationName); - - const { - params: { id }, - } = (await request.validateUsing(this.pathIdValidator)) as { - params: { id: string | number }; - }; - const relations = await request.validateUsing( - this.subrelationValidator(relationName), - ); - const { page, limit } = await request.validateUsing(paginationValidator); - await this.authorizeById(httpCtx, "relationIndex", { - localId: id, - relationName, - }); - const mainInstance = await this.getFirstOrFail(id); - await this.authorizeRecord(httpCtx, "relationIndex", mainInstance); - const relatedQuery = mainInstance - .related(relationName as ExtractModelRelations>) - .query() - .withScopes((scopes: ScopesWithoutFirstArg) => { - try { - scopes.handleSearchQuery(request.qs()); - } catch { - logger.warn( - `handleSearchQuery query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, - ); - } - try { - scopes.preloadRelations(relations); - } catch { - logger.warn( - `preloadRelations query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, - ); - } - try { - scopes.handleSortQuery(request.input("sort")); - } catch { - logger.warn( - `handleSortQuery query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, - ); + const data = await db.transaction( + async (trx) => { + await this.authenticate(httpCtx, "relationIndex", relationName); + const { + params: { id }, + } = (await request.validateUsing(this.pathIdValidator, { + meta: { trx }, + })) as { + params: { id: string | number }; + }; + const relations = await request.validateUsing( + this.subrelationValidator(relationName), + ); + const { page, limit } = + await request.validateUsing(paginationValidator); + + await this.authorizeById(httpCtx, "relationIndex", { + localId: id, + relationName, + }); + + const mainInstance = await this.getFirstOrFail(id, trx); + await this.authorizeRecord(httpCtx, "relationIndex", mainInstance); + const relatedQuery = mainInstance + .related(relationName as ExtractModelRelations>) + .query() + .withScopes((scopes: ScopesWithoutFirstArg) => { + try { + scopes.handleSearchQuery(request.qs()); + } catch { + logger.warn( + `handleSearchQuery query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, + ); + } + try { + scopes.preloadRelations(relations); + } catch { + logger.warn( + `preloadRelations query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, + ); + } + try { + scopes.handleSortQuery(request.input("sort")); + } catch { + logger.warn( + `handleSortQuery query scope is not defined on ${this.model.name}'s '${relationName}' relation!`, + ); + } + }); + if (page === undefined && limit === undefined) { + return { data: await relatedQuery }; } - }); - - if (page === undefined && limit === undefined) { - return { data: await relatedQuery }; - } - return await relatedQuery.paginate(page ?? 1, limit ?? 10); + return await relatedQuery.paginate(page ?? 1, limit ?? 10); + }, + { isolationLevel: this.isolationLevel }, + ); + return data; } /** @@ -986,81 +1058,92 @@ export default abstract class AutoCrudController< */ async oneToOneRelationStore(httpCtx: HttpContext): Promise { const { request, route, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } await this.selfValidate(); - const relationName = this.relationNameFromRoute(route); - await this.authenticate(httpCtx, "oneToOneRelationStore", relationName); - const { - params: { id }, - } = (await request.validateUsing(this.pathIdValidator)) as { - params: { id: string | number }; - }; - await this.authorizeById(httpCtx, "oneToOneRelationStore", { - localId: id, - relationName, - }); - const toStore = (await request.validateUsing( - this.relatedStoreValidator(relationName), - )) as Partial>; - - const mainInstance = await this.getFirstOrFail(id); - await this.authorizeRecord( - { request, route, auth } as unknown as HttpContext, - "oneToOneRelationStore", - mainInstance, - ); + const res = await db.transaction( + async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } - const relationClient = mainInstance.related( - relationName as ExtractModelRelations>, - ); - if (relationClient.relation.type !== "hasOne") { - throw new InternalControllerError( - `Relation '${relationName}' of model '${this.model.name}' was passed into the 'oneToOneRelationStore' method, ` + - `which only supports 'hasOne' relations, but this relation is of type '${relationClient.relation.type}'!`, - ); - } + const relationName = this.relationNameFromRoute(route); + await this.authenticate(httpCtx, "oneToOneRelationStore", relationName); - // verify that the object doesn't exist already - const relatedCount = (await relationClient - .query() - .count({ total: "*" }) - .exec() - .addErrorContext({ - message: "Failed to count existing related objects", - code: "E_DB_ERROR", - status: 500, - })) as unknown as [{ $extras: { total: string } }]; - - if (Number.parseInt(relatedCount[0].$extras.total) > 0) { - throw new ConflictException( - `Related object for 1:1 relation '${relationName}' already exists!`, - { - code: "E_EXISTS", - }, - ); - } + const { + params: { id }, + } = (await request.validateUsing(this.pathIdValidator, { + meta: { trx }, + })) as { + params: { id: string | number }; + }; + await this.authorizeById(httpCtx, "oneToOneRelationStore", { + localId: id, + relationName, + }); + + const toStore = (await request.validateUsing( + this.relatedStoreValidator(relationName), + { meta: { trx } }, + )) as Partial>; + + const mainInstance = await this.getFirstOrFail(id, trx); + await this.authorizeRecord( + { request, route, auth } as unknown as HttpContext, + "oneToOneRelationStore", + mainInstance, + ); - const res = await ( - relationClient as HasManyClientContract< - HasOneRelationContract, - LucidModel - > - ) - .create(toStore) - .addErrorContext({ - message: "Failed to store object", - code: "E_DB_ERROR", - status: 500, - }); - - await res.refresh().addErrorContext({ - message: "Failed to fetch updated object", - code: "E_DB_ERROR", - status: 500, - }); + const relationClient = mainInstance.related( + relationName as ExtractModelRelations>, + ); + if (relationClient.relation.type !== "hasOne") { + throw new InternalControllerError( + `Relation '${relationName}' of model '${this.model.name}' was passed into the 'oneToOneRelationStore' method, ` + + `which only supports 'hasOne' relations, but this relation is of type '${relationClient.relation.type}'!`, + ); + } + + // verify that the object doesn't exist already + const relatedCount = (await relationClient + .query() + .count({ total: "*" }) + .exec() + .addErrorContext({ + message: "Failed to count existing related objects", + code: "E_DB_ERROR", + status: 500, + })) as unknown as [{ $extras: { total: string } }]; + + if (Number.parseInt(relatedCount[0].$extras.total) > 0) { + throw new ConflictException( + `Related object for 1:1 relation '${relationName}' already exists!`, + { + code: "E_EXISTS", + }, + ); + } + const fetchedData = await ( + relationClient as HasManyClientContract< + HasOneRelationContract, + LucidModel + > + ) + .create(toStore) + .addErrorContext({ + message: "Failed to store object", + code: "E_DB_ERROR", + status: 500, + }); + + await fetchedData.refresh().addErrorContext({ + message: "Failed to fetch updated object", + code: "E_DB_ERROR", + status: 500, + }); + return fetchedData; + }, + { isolationLevel: this.isolationLevel }, + ); return { success: true, @@ -1075,61 +1158,77 @@ export default abstract class AutoCrudController< */ async oneToManyRelationStore(httpCtx: HttpContext): Promise { const { request, route, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } await this.selfValidate(); - const relationName = this.relationNameFromRoute(route); - await this.authenticate(httpCtx, "oneToManyRelationStore", relationName); - const { - params: { id }, - } = (await request.validateUsing(this.pathIdValidator)) as { - params: { id: string | number }; - }; - await this.authorizeById(httpCtx, "oneToManyRelationStore", { - localId: id, - relationName, - }); - const toStore = (await request.validateUsing( - this.relatedStoreValidator(relationName), - )) as Partial>; - - const mainInstance = await this.getFirstOrFail(id); - await this.authorizeRecord( - { request, route, auth } as unknown as HttpContext, - "oneToManyRelationStore", - mainInstance, - ); + const res = await db.transaction( + async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } - const relationClient = mainInstance.related( - relationName as ExtractModelRelations>, - ); - if (relationClient.relation.type !== "hasMany") { - throw new InternalControllerError( - `Relation '${relationName}' of model '${this.model.name}' was passed into the 'oneToManyRelationStore' method, ` + - `which only supports 'hasMany' relations, but this relation is of type '${relationClient.relation.type}'!`, - ); - } - const res = await ( - relationClient as HasManyClientContract< - HasManyRelationContract, - LucidModel - > - ) - .create(toStore) - .addErrorContext({ - message: "Failed to store object", - code: "E_DB_ERROR", - status: 500, - }); - - await res.refresh().addErrorContext({ - message: "Failed to fetch updated object", - code: "E_DB_ERROR", - status: 500, - }); + const relationName = this.relationNameFromRoute(route); + await this.authenticate( + httpCtx, + "oneToManyRelationStore", + relationName, + ); + + const { + params: { id }, + } = (await request.validateUsing(this.pathIdValidator, { + meta: { trx }, + })) as { + params: { id: string | number }; + }; + await this.authorizeById(httpCtx, "oneToManyRelationStore", { + localId: id, + relationName, + }); + + const toStore = (await request.validateUsing( + this.relatedStoreValidator(relationName), + { meta: { trx } }, + )) as Partial>; + + const mainInstance = await this.getFirstOrFail(id, trx); + + await this.authorizeRecord( + { request, route, auth } as unknown as HttpContext, + "oneToManyRelationStore", + mainInstance, + ); + const relationClient = mainInstance.related( + relationName as ExtractModelRelations>, + ); + if (relationClient.relation.type !== "hasMany") { + throw new InternalControllerError( + `Relation '${relationName}' of model '${this.model.name}' was passed into the 'oneToManyRelationStore' method, ` + + `which only supports 'hasMany' relations, but this relation is of type '${relationClient.relation.type}'!`, + ); + } + const fetchedData = await ( + relationClient as HasManyClientContract< + HasManyRelationContract, + LucidModel + > + ) + .create(toStore) + .addErrorContext({ + message: "Failed to store object", + code: "E_DB_ERROR", + status: 500, + }); + + await fetchedData.refresh().addErrorContext({ + message: "Failed to fetch updated object", + code: "E_DB_ERROR", + status: 500, + }); + return fetchedData; + }, + { isolationLevel: this.isolationLevel }, + ); return { success: true, data: res, @@ -1137,136 +1236,177 @@ export default abstract class AutoCrudController< } async manyToManyRelationAttach(httpCtx: HttpContext): Promise { - const { request, route, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } + const { route, auth } = httpCtx; await this.selfValidate(); - const relationName = this.relationNameFromRoute(route); - await this.authenticate(httpCtx, "manyToManyRelationAttach", relationName); - - const { - params: { localId, relatedId }, - } = (await request.validateUsing( - this.manyToManyIdsValidator(relationName), - )) as { params: { localId: string | number; relatedId: string | number } }; - const pivotProps = (await request.validateUsing( - this.attachValidator(relationName), - )) as Record; - - await this.authorizeById(httpCtx, "manyToManyRelationAttach", { - localId, - relatedId, - relationName, - }); - const mainInstance = await this.getFirstOrFail(localId); - await this.authorizeRecord( - httpCtx, - "manyToManyRelationAttach", - mainInstance, - ); - const relationClient = mainInstance.related( - relationName as ExtractModelRelations>, + await db.transaction( + async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } + + const relationName = this.relationNameFromRoute(route); + await this.authenticate( + httpCtx, + "manyToManyRelationAttach", + relationName, + ); + + const { + params: { localId, relatedId }, + } = (await httpCtx.request.validateUsing( + this.manyToManyIdsValidator(relationName), + { meta: { trx } }, + )) as { + params: { localId: string | number; relatedId: string | number }; + }; + const pivotProps = (await httpCtx.request.validateUsing( + this.attachValidator(relationName), + { meta: { trx } }, + )) as Record; + + await this.authorizeById(httpCtx, "manyToManyRelationAttach", { + localId, + relatedId, + relationName, + }); + + const mainInstance = await this.getFirstOrFail(localId, trx); + + await this.authorizeRecord( + httpCtx, + "manyToManyRelationAttach", + mainInstance, + ); + const relationClient = mainInstance.related( + relationName as ExtractModelRelations>, + ); + if (relationClient.relation.type !== "manyToMany") { + throw new InternalControllerError( + `Relation '${relationName}' of model '${this.model.name}' was passed into the 'manyToManyRelationAttach' method, ` + + `which only supports 'manyToMany' relations, but this relation is of type '${relationClient.relation.type}'!`, + ); + } + await ( + relationClient as ManyToManyClientContract< + ManyToManyRelationContract, + LucidModel + > + ) + .attach({ + [relatedId]: pivotProps, + }) + .addErrorContext({ + message: "Failed to attach object", + code: "E_DB_ERROR", + status: 500, + }); + }, + { isolationLevel: this.isolationLevel }, ); - if (relationClient.relation.type !== "manyToMany") { - throw new InternalControllerError( - `Relation '${relationName}' of model '${this.model.name}' was passed into the 'manyToManyRelationAttach' method, ` + - `which only supports 'manyToMany' relations, but this relation is of type '${relationClient.relation.type}'!`, - ); - } - await ( - relationClient as ManyToManyClientContract< - ManyToManyRelationContract, - LucidModel - > - ) - .attach({ - [relatedId]: pivotProps, - }) - .addErrorContext({ - message: "Failed to attach object", - code: "E_DB_ERROR", - status: 500, - }); return { success: true }; } async manyToManyRelationDetach(httpCtx: HttpContext): Promise { const { request, route, auth } = httpCtx; - if (!auth.isAuthenticated) { - await auth.authenticate(); - } await this.selfValidate(); - const relationName = this.relationNameFromRoute(route); - await this.authenticate(httpCtx, "manyToManyRelationDetach", relationName); - - const { - params: { localId, relatedId }, - } = (await request.validateUsing( - this.manyToManyIdsValidator(relationName), - )) as { params: { localId: string | number; relatedId: string | number } }; - const detachFilters = (await request.validateUsing( - this.detachValidator(relationName), - )) as Record; - - await this.authorizeById(httpCtx, "manyToManyRelationDetach", { - localId, - relatedId, - relationName, - }); + const numDetached = await db.transaction( + async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } - const relation = this.model.$relationsDefinitions.get(relationName); - if (relation === undefined) { - throw new InternalControllerError( - `Relation '${relationName}' does not exist on model '${this.model.name}'`, - ); - } - if (relation.type !== "manyToMany") { - throw new InternalControllerError( - `Relation '${relationName}' of model '${this.model.name}' was passed into the 'manyToManyRelationDetach' method, ` + - `which only supports 'manyToMany' relations, but this relation is of type '${relation.type}'!`, - ); - } - if (!relation.booted) { - relation.boot(); - } + const relationName = this.relationNameFromRoute(route); + await this.authenticate( + httpCtx, + "manyToManyRelationDetach", + relationName, + ); - // We can avoid fetching the main instance here since authorization was done pre-DB, - // but still support row-level authorization hooks when overridden. - if (this.authorizeRecord !== AutoCrudController.prototype.authorizeRecord) { - const mainInstance = await this.getFirstOrFail(localId); - await this.authorizeRecord( - httpCtx, - "manyToManyRelationDetach", - mainInstance, - ); - } + const { + params: { localId, relatedId }, + } = (await request.validateUsing( + this.manyToManyIdsValidator(relationName), + { meta: { trx } }, + )) as { + params: { localId: string | number; relatedId: string | number }; + }; + const detachFilters = (await request.validateUsing( + this.detachValidator(relationName), + { meta: { trx } }, + )) as Record; + const definedDetachFilters = Object.fromEntries( + Object.entries(detachFilters).filter( + ([, value]) => value !== undefined, + ), + ); - let result; - try { - result = await db - .knexQuery() - .table(relation.pivotTable) - .where({ - ...detachFilters, - [relation.pivotForeignKey]: localId, - [relation.pivotRelatedForeignKey]: relatedId, - }) - .delete(); - } catch (err) { - throw new BaseError("Failed to detach objects", { - cause: err, - code: "E_DB_ERROR", - status: 500, - }); - } + await this.authorizeById(httpCtx, "manyToManyRelationDetach", { + localId, + relatedId, + relationName, + }); - if (result === 0) { - throw new NotFoundException("No relation attachments matched your query"); - } + const relation = this.model.$relationsDefinitions.get(relationName); + if (relation === undefined) { + throw new InternalControllerError( + `Relation '${relationName}' does not exist on model '${this.model.name}'`, + ); + } + if (relation.type !== "manyToMany") { + throw new InternalControllerError( + `Relation '${relationName}' of model '${this.model.name}' was passed into the 'manyToManyRelationDetach' method, ` + + `which only supports 'manyToMany' relations, but this relation is of type '${relation.type}'!`, + ); + } + if (!relation.booted) { + relation.boot(); + } + + // We can avoid fetching the main instance here since authorization was done pre-DB, + // but still support row-level authorization hooks when overridden. + if ( + this.authorizeRecord !== AutoCrudController.prototype.authorizeRecord + ) { + const mainInstance = await this.getFirstOrFail(localId, trx); + + await this.authorizeRecord( + httpCtx, + "manyToManyRelationDetach", + mainInstance, + ); + } + + let deletedRows: number; + try { + deletedRows = (await trx + .from(relation.pivotTable) + .where({ + ...definedDetachFilters, + [relation.pivotForeignKey]: localId, + [relation.pivotRelatedForeignKey]: relatedId, + }) + .delete()) as unknown as number; + } catch (err) { + throw new BaseError("Failed to detach objects", { + cause: err, + code: "E_DB_ERROR", + status: 500, + }); + } + + if (deletedRows === 0) { + throw new NotFoundException( + "No relation attachments matched your query", + ); + } + + return deletedRows; + }, + { isolationLevel: this.isolationLevel }, + ); - return { success: true, numDetached: result }; + return { success: true, numDetached }; } } diff --git a/app/controllers/v1/drafts.ts b/app/controllers/v1/drafts.ts index c407ea61..45a11d94 100644 --- a/app/controllers/v1/drafts.ts +++ b/app/controllers/v1/drafts.ts @@ -337,31 +337,33 @@ export abstract class GenericDraftController< } async approve({ request, auth }: HttpContext) { - if (!auth.isAuthenticated) { - await auth.authenticate(); - } - assert(auth.user !== undefined); - // Only solvro_admin can approve drafts - const isSolvroAdmin = await auth.user.hasRole("solvro_admin"); - if (!isSolvroAdmin) { - throw new ForbiddenException(); - } - - // Use autogenerated validator for ID - const { - params: { id: draftId }, - } = (await request.validateUsing(this.pathIdValidator)) as { - params: { id: number }; - }; + return await db.transaction(async (trx) => { + if (!auth.isAuthenticated) { + await auth.authenticate(); + } + assert(auth.user !== undefined); + // Only solvro_admin can approve drafts + const isSolvroAdmin = await auth.user.hasRole("solvro_admin"); + if (!isSolvroAdmin) { + throw new ForbiddenException(); + } - // Use findOrFail with error context - const draft = (await this.model - .findOrFail(draftId) - .addErrorContext( - () => `${this.model.name} with id ${draftId} not found`, - )) as DraftInstance & InstanceType; + // Use autogenerated validator for ID + const { + params: { id: draftId }, + } = (await request.validateUsing(this.pathIdValidator, { + meta: { trx }, + })) as { + params: { id: number }; + }; + + // Use findOrFail with error context + const draft = (await this.model + .findOrFail(draftId) + .addErrorContext( + () => `${this.model.name} with id ${draftId} not found`, + )) as DraftInstance & InstanceType; - return await db.transaction(async (trx) => { let approved: ApprovedInstance & InstanceType; if (draft.originalId !== null) { diff --git a/app/utils/model_autogen.ts b/app/utils/model_autogen.ts index a53f8d2d..1e9f7368 100644 --- a/app/utils/model_autogen.ts +++ b/app/utils/model_autogen.ts @@ -3,6 +3,7 @@ import type { VineBoolean, VineObject, VineValidator } from "@vinejs/vine"; import type { OptionalModifier } from "@vinejs/vine/schema/base/literal"; import type { SchemaTypes } from "@vinejs/vine/types"; +import type { TransactionClientContract } from "@adonisjs/lucid/types/database"; import type { LucidModel, ModelColumnOptions, @@ -16,6 +17,10 @@ import type { ValidatedColumnDef } from "#decorators/typed_model"; import { InvalidModelDefinition } from "#exceptions/model_autogen_errors"; import "#utils/maps"; +export interface ValidatorMetadata { + trx?: TransactionClientContract | undefined; +} + export type RelationValidator = VineValidator< VineObject< Record>, @@ -25,7 +30,8 @@ export type RelationValidator = VineValidator< >, [undefined] >; -export type AnyValidator = VineValidator; + +export type AnyValidator = VineValidator; export interface PrimaryKeyFieldDescriptor { fieldName: string; diff --git a/app/validators/db.ts b/app/validators/db.ts index 3fde0784..23afe2d9 100644 --- a/app/validators/db.ts +++ b/app/validators/db.ts @@ -3,6 +3,7 @@ import type { BaseType } from "@vinejs/vine"; import type { FieldContext, SchemaTypes } from "@vinejs/vine/types"; import db from "@adonisjs/lucid/services/db"; +import type { TransactionClientContract } from "@adonisjs/lucid/types/database"; import type { LucidModel } from "@adonisjs/lucid/types/model"; import { InvalidModelDefinition } from "#exceptions/model_autogen_errors"; @@ -23,13 +24,16 @@ async function foreignKeyRule( return; } + const conn = + "trx" in field.meta ? (field.meta.trx as TransactionClientContract) : db; + if (!(typeof value === "number" || typeof value === "string")) { return; } - const res = (await db + const res = (await conn .knexQuery() - .select(db.knexRawQuery("1")) + .select(conn.knexRawQuery("1")) .from(options.table) .where(options.column, value) .limit(1, { skipBinding: true })) as unknown[]; From 7ea90658d1badfff57773563b42180cfbdc48237 Mon Sep 17 00:00:00 2001 From: mini-bomba <55105495+mini-bomba@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:43:39 +0200 Subject: [PATCH 2/2] refactor: final minor changes --- app/controllers/auto_crud_controller.ts | 33 ++++++++++--------------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/app/controllers/auto_crud_controller.ts b/app/controllers/auto_crud_controller.ts index ce62b18c..d3605d9b 100644 --- a/app/controllers/auto_crud_controller.ts +++ b/app/controllers/auto_crud_controller.ts @@ -771,7 +771,6 @@ export default abstract class AutoCrudController< */ async store(httpCtx: HttpContext): Promise { await this.selfValidate(); - let toStore!: PartialModel; const result = await db.transaction( async (trx) => { const { auth } = httpCtx; @@ -781,6 +780,7 @@ export default abstract class AutoCrudController< await this.authenticate(httpCtx, "store"); + let toStore: PartialModel; toStore = (await httpCtx.request.validateUsing(this.storeValidator, { meta: { trx }, })) as PartialModel; @@ -845,15 +845,8 @@ export default abstract class AutoCrudController< return data; } - protected async saveOrFail( - row: InstanceType, - trx?: TransactionClientContract, - ) { - if (trx !== undefined) { - row.useTransaction(trx); - } - - return await row.save().addErrorContext({ + protected async saveOrFail(row: InstanceType) { + await row.save().addErrorContext({ message: "Failed to commit updates", code: "E_DB_ERROR", status: 500, @@ -869,7 +862,7 @@ export default abstract class AutoCrudController< async update(httpCtx: HttpContext): Promise { const { request, auth } = httpCtx; await this.selfValidate(); - const row = await db.transaction( + const result = await db.transaction( async (trx) => { if (!auth.isAuthenticated) { await auth.authenticate(); @@ -894,32 +887,32 @@ export default abstract class AutoCrudController< meta: { trx }, })) as PartialModel; - const searchedRow = await this.getFirstOrFail(id, trx); - await this.authorizeRecord(httpCtx, "update", searchedRow); + const row = await this.getFirstOrFail(id, trx); + await this.authorizeRecord(httpCtx, "update", row); updates = (await this.updateHook({ http: httpCtx, model: this.model, - record: searchedRow, + record: row, request: updates, })) ?? updates; - searchedRow.merge(updates); + row.merge(updates); - const updatedRow = await this.saveOrFail(searchedRow, trx); + await this.saveOrFail(row); - await updatedRow.refresh().addErrorContext({ + await row.refresh().addErrorContext({ message: "Failed to fetch updated object", code: "E_DB_ERROR", status: 500, }); - return updatedRow; + return row; }, { isolationLevel: this.isolationLevel }, ); return { success: true, - data: row, + data: result, }; } @@ -971,7 +964,7 @@ export default abstract class AutoCrudController< // Clean up any permissions scoped to the deleted instance const morphAlias = getMorphMapAlias(this.model); if (morphAlias !== null) { - await deletePermissionsForEntity(morphAlias, id); + await deletePermissionsForEntity(morphAlias, id, trx); } }, { isolationLevel: this.isolationLevel },