Skip to content

feat: implement cascading deletion for related records in delete #488

Open
kulikp1 wants to merge 24 commits intomainfrom
feature/AdminForth/1256/cascading-deletion-
Open

feat: implement cascading deletion for related records in delete #488
kulikp1 wants to merge 24 commits intomainfrom
feature/AdminForth/1256/cascading-deletion-

Conversation

@kulikp1
Copy link
Collaborator

@kulikp1 kulikp1 commented Feb 25, 2026

…oint

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 onDelete field ('cascade' | 'setNull') added to AdminForthForeignResource type, with validation in configValidator.ts
  • Core cascade deletion/null-out logic added to the /delete_record REST endpoint in restApi.ts
  • Database-level ON DELETE CASCADE detection 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.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +1496 to +1500
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});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cascade deletion at line 1496 uses this.adminforth.resource(childRes.resourceId).delete(childRecord[childPkFieldName]), which calls OperationalResource.delete()connector.deleteRecord() directly, completely bypassing:

  1. The resource's delete hooks (hooks.delete.beforeSave and hooks.delete.afterSave)
  2. 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.

Suggested change
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 };
}

Copilot uses AI. Check for mistakes.
Comment on lines +1487 to +1500
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});
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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 }
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +79 to +100
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.`);
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 deleteWithCascade method (line 190) already calls this.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, deleteWithCascade is called first, and immediately after, deleteResourceRecord is 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 beforeSave hooks (lines 263-279) and afterSave hooks (lines 288-301) around the call to deleteWithCascade. However, deleteWithCascade internally calls this.adminforth.deleteResourceRecord(...) (restApi.ts line 190), which itself runs all beforeSave and afterSave hooks 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 inside deleteWithCascade/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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants