feat: implement cascading deletion for related records in delete #488
feat: implement cascading deletion for related records in delete #488
Conversation
There was a problem hiding this comment.
Pull request overview
This PR adds application-level cascading deletion support for related records in AdminForth's delete endpoint. When onDelete: 'cascade' or onDelete: 'setNull' is configured on a foreignResource column, deleting a parent record will either recursively delete child records or null-out the foreign key column in child records. The PR also adds detection of database-level ON DELETE CASCADE constraints in SQLite, PostgreSQL, and MySQL, with warnings when such constraints exist (to avoid conflicts with the new application-level cascade logic).
Changes:
- New optional
onDeletefield ('cascade' | 'setNull') added toAdminForthForeignResourcetype, with validation inconfigValidator.ts - Core cascade deletion/null-out logic added to the
/delete_recordREST endpoint inrestApi.ts - Database-level
ON DELETE CASCADEdetection and warning added to all three data connectors (SQLite, PostgreSQL, MySQL)
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
adminforth/types/Back.ts |
Adds onDelete field to AdminForthForeignResource interface |
adminforth/modules/restApi.ts |
Implements cascade delete / setNull logic before deleting the parent record |
adminforth/modules/configValidator.ts |
Validates the onDelete strategy value |
adminforth/dataConnectors/sqlite.ts |
Detects DB-level ON DELETE CASCADE via PRAGMA foreign_key_list |
adminforth/dataConnectors/postgres.ts |
Detects DB-level ON DELETE CASCADE via pg_constraint system tables |
adminforth/dataConnectors/mysql.ts |
Detects DB-level ON DELETE CASCADE via information_schema |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
adminforth/modules/restApi.ts
Outdated
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | ||
| } | ||
| } else if (onDeleteStrategy === 'setNull') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); |
There was a problem hiding this comment.
The cascade deletion at line 1496 uses this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]), which calls OperationalResource.delete() → connector.deleteRecord() directly, completely bypassing:
- The resource's
deletehooks (hooks.delete.beforeSaveandhooks.delete.afterSave) - Permission checks (
allowedActions.delete) for the child resource
This is a critical concern because these hooks are often used for cleanup of related artifacts (e.g., file storage cleanup in upload plugins, as referenced by the warning in the connector code). Additionally, the setNull path at line 1500 has the same issue — this.adminforth.resource(childRes.resourceId).update() similarly bypasses edit hooks for child records.
To trigger hooks and permissions, the implementation should use this.adminforth.deleteResourceRecord(...) for cascade deletions and this.adminforth.updateResourceRecord(...) for setNull operations, similar to how the parent record deletion is handled at line 1506.
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); | |
| const { error: childDeleteError } = await this.adminforth.deleteResourceRecord({ | |
| resource: childRes, | |
| record: childRecord, | |
| adminUser, | |
| recordId: childRecord[childPkFieldName], | |
| response, | |
| extra: { body, query, headers, cookies, requestUrl, response } | |
| }); | |
| if (childDeleteError) { | |
| return { error: childDeleteError }; | |
| } | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| const { error: childUpdateError } = await this.adminforth.updateResourceRecord({ | |
| resource: childRes, | |
| record: childRecord, | |
| adminUser, | |
| recordId: childRecord[childPkFieldName], | |
| updatedRecord: { [foreignResourceColumn.name]: null }, | |
| response, | |
| extra: { body, query, headers, cookies, requestUrl, response } | |
| }); | |
| if (childUpdateError) { | |
| return { error: childUpdateError }; | |
| } |
adminforth/modules/restApi.ts
Outdated
| const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | ||
| if (!foreignResourceColumn.foreignResource.onDelete) continue; | ||
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | ||
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey'])) | ||
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | ||
| if (!childPkColumn) continue; | ||
| const childPkFieldName = childPkColumn.name; | ||
| if (onDeleteStrategy === 'cascade') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | ||
| } | ||
| } else if (onDeleteStrategy === 'setNull') { | ||
| for (const childRecord of childRecords) { | ||
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); |
There was a problem hiding this comment.
At line 1487, childRes.columns.find(...) only returns the FIRST column in the child resource that has a foreignResource.resourceId matching the parent. If a child resource has multiple columns referencing the same parent resource (e.g., created_by and updated_by both pointing to users), only the first matching column will have its cascade/setNull strategy applied. The second FK column will be silently ignored.
Consider using childRes.columns.filter(...) to get all FK columns pointing to the parent resource, and then iterating over each one.
| const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); | |
| if (!foreignResourceColumn.foreignResource.onDelete) continue; | |
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey'])) | |
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | |
| if (!childPkColumn) continue; | |
| const childPkFieldName = childPkColumn.name; | |
| if (onDeleteStrategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); | |
| const foreignResourceColumns = childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId); | |
| if (!foreignResourceColumns.length) continue; | |
| const childPkColumn = childRes.columns.find(col => col.primaryKey); | |
| if (!childPkColumn) continue; | |
| const childPkFieldName = childPkColumn.name; | |
| for (const foreignResourceColumn of foreignResourceColumns) { | |
| if (!foreignResourceColumn.foreignResource || !foreignResourceColumn.foreignResource.onDelete) { | |
| continue; | |
| } | |
| const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; | |
| const childRecords = await this.adminforth.resource(childRes.resourceId).list( | |
| Filters.EQ(foreignResourceColumn.name, body['primaryKey']) | |
| ); | |
| if (onDeleteStrategy === 'cascade') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]); | |
| } | |
| } else if (onDeleteStrategy === 'setNull') { | |
| for (const childRecord of childRecords) { | |
| await this.adminforth.resource(childRes.resourceId).update( | |
| childRecord[childPkFieldName], | |
| { [foreignResourceColumn.name]: null } | |
| ); | |
| } |
| const [fkResults] = await this.client.execute(` | ||
| SELECT | ||
| kcu.TABLE_NAME AS child_table, | ||
| kcu.COLUMN_NAME AS column_name, | ||
| rc.DELETE_RULE AS delete_rule | ||
| FROM information_schema.KEY_COLUMN_USAGE kcu | ||
| JOIN information_schema.REFERENTIAL_CONSTRAINTS rc | ||
| ON kcu.CONSTRAINT_NAME = rc.CONSTRAINT_NAME | ||
| AND kcu.CONSTRAINT_SCHEMA = rc.CONSTRAINT_SCHEMA | ||
| WHERE kcu.REFERENCED_TABLE_NAME = ? | ||
| AND kcu.TABLE_SCHEMA = DATABASE() | ||
| `, [resource.table]); | ||
|
|
||
| const fkMap: Record<string, { cascade: boolean; childTable: string }> = {}; | ||
| for (const fk of fkResults as any[]) { | ||
| fkMap[String(fk.column_name)] = { | ||
| cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE', | ||
| childTable: fk.child_table | ||
| }; | ||
| if (fkMap[fk.column_name].cascade) { | ||
| afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); | ||
| } |
There was a problem hiding this comment.
The MySQL query uses WHERE kcu.REFERENCED_TABLE_NAME = ? and selects kcu.COLUMN_NAME, which returns column names from CHILD tables that reference the current table (i.e., columns in other tables that point to this table as a parent). This is the opposite perspective from the SQLite implementation, which uses PRAGMA foreign_key_list(tableName) to return FK columns defined ON the current table (the child side).
The practical effect is that in MySQL, fkMap keys are column names from child tables rather than columns of the current table being discovered. For the warning logic this is functionally harmless since the warning fires as long as any cascade is found. However, it is semantically inconsistent with the SQLite approach and with the intent of discoverFields (which is supposed to characterize the fields of the current table). If fkMap is later used to set field properties (similar to SQLite's field.cascade = fkMap[row.name] || false), this inverted mapping would cause incorrect results.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Comments suppressed due to low confidence (2)
adminforth/modules/restApi.ts:1526
- The
deleteWithCascademethod (line 190) already callsthis.adminforth.deleteResourceRecord(...)for the top-level resource at the end, which performs the actual delete and fires before/after hooks. Then at lines 1521-1526,deleteWithCascadeis called first, and immediately after,deleteResourceRecordis called again for the same record. This results in the record being deleted twice (which will likely cause a "record not found" error or silent no-op on the second call) and before/after delete hooks being fired twice for the top-level record.
The second call to deleteResourceRecord at lines 1523-1526 should be removed; deleteWithCascade already handles deleting the record and firing its hooks.
await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, query, headers, cookies, requestUrl, response});
const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
extra: { body, query, headers, cookies, requestUrl, response }
});
adminforth/modules/configValidator.ts:301
- The bulk delete action manually invokes
beforeSavehooks (lines 263-279) andafterSavehooks (lines 288-301) around the call todeleteWithCascade. However,deleteWithCascadeinternally callsthis.adminforth.deleteResourceRecord(...)(restApi.ts line 190), which itself runs allbeforeSaveandafterSavehooks for the resource (index.ts lines 758 and 781). This causes all delete hooks to fire twice for each record deleted via the bulk action: once explicitly in the bulk action and once insidedeleteWithCascade/deleteResourceRecord.
The manual hook invocations in lines 263-279 and 288-301 should be removed from the bulk delete action, as deleteWithCascade (via deleteResourceRecord) already handles them.
await Promise.all(
(res.hooks.delete.beforeSave).map(
async (hook) => {
const resp = await hook({
recordId: recordId,
resource: res as AdminForthResource,
record,
adminUser,
response,
adminforth: this.adminforth
});
if (!error && resp.error) {
error = resp.error;
}
}
)
)
if (error) {
return;
}
const restApi = new AdminForthRestAPI (this.adminforth)
restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: undefined, query: undefined, headers: undefined, cookies: undefined, requestUrl: undefined});
await Promise.all(
(res.hooks.delete.afterSave).map(
async (hook) => {
await hook({
resource: res as AdminForthResource,
record,
adminUser,
recordId: recordId,
response,
adminforth: this.adminforth,
});
}
)
)
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
…oint