From ca65f67c8d5f19385e899a8a642c5aab5dec35aa Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 25 Feb 2026 16:14:21 +0200 Subject: [PATCH 01/41] feat: implement cascading deletion for related records in delete endpoint --- adminforth/modules/restApi.ts | 41 ++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 6b6f2f5d..7be09f7b 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1452,6 +1452,30 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } }); + async function handleCascadeOnDelete(parentResource: AdminForthResource, parentId: any) { + const adminforth = this.adminforth; + for (const resource of adminforth.config.resources) { + if (resource.resourceId === parentResource.resourceId) continue; + const foreignKeyColumn = resource.columns.find(c => c.foreignResource?.resourceId === parentResource.resourceId + ); + if (!foreignKeyColumn) continue; + const deleteStrategy = foreignKeyColumn.foreignResource?.onDelete ?? 'null'; + const primaryKeyColumn = resource.columns.find(c => c.primaryKey); + if (!primaryKeyColumn) continue; + const childRecords = await adminforth.resource(resource.resourceId).list([Filters.EQ(foreignKeyColumn.name, parentId)]); + for (const record of childRecords) { + const childId = record[primaryKeyColumn.name]; + if (deleteStrategy === 'cascade') { + await handleCascadeOnDelete.call(this, resource, childId); + await adminforth.resource(resource.resourceId).delete(childId); + continue; + } + if (deleteStrategy === 'setNull') { + await adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); + } + } + } + } server.endpoint({ method: 'POST', path: '/delete_record', @@ -1464,23 +1488,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!record){ return { error: `Record with ${body['primaryKey']} not found` }; } - if (resource.options.allowedActions.delete === false) { + if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; } - - const { allowedActions } = await interpretResource( - adminUser, - resource, - { requestBody: body, record: record }, - ActionCheckSource.DeleteRequest, - this.adminforth - ); - - const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); - if (!allowed) { - return { error }; - } - + await handleCascadeOnDelete.call(this, resource, body['primaryKey']); const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, extra: { body, query, headers, cookies, requestUrl, response } From bda166a40306519f1617bae7f72fa677154d5621 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 11:46:20 +0200 Subject: [PATCH 02/41] add alowwedAction check --- adminforth/modules/restApi.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 7be09f7b..4e4644eb 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1490,6 +1490,17 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; + } + const { allowedActions } = await interpretResource( + adminUser, + resource, + { requestBody: body, record: record }, + ActionCheckSource.DeleteRequest, + this.adminforth + ); + const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); + if (!allowed) { + return { error }; } await handleCascadeOnDelete.call(this, resource, body['primaryKey']); const { error: deleteError } = await this.adminforth.deleteResourceRecord({ From 12d2ba683b81e5b779d69f322d5e352f1d710db2 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 11:47:05 +0200 Subject: [PATCH 03/41] add missing spaces --- adminforth/modules/restApi.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 4e4644eb..5180f43a 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1498,10 +1498,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { ActionCheckSource.DeleteRequest, this.adminforth ); + const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); if (!allowed) { return { error }; } + await handleCascadeOnDelete.call(this, resource, body['primaryKey']); const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, From 02e216ba6176a2b2b1ea7df890d5a2406c270a50 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 12:46:01 +0200 Subject: [PATCH 04/41] feat: implement cascading deletion logic in delete endpoint --- adminforth/modules/restApi.ts | 73 ++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 5180f43a..7505944e 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1452,30 +1452,31 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } }); - async function handleCascadeOnDelete(parentResource: AdminForthResource, parentId: any) { - const adminforth = this.adminforth; - for (const resource of adminforth.config.resources) { - if (resource.resourceId === parentResource.resourceId) continue; - const foreignKeyColumn = resource.columns.find(c => c.foreignResource?.resourceId === parentResource.resourceId - ); - if (!foreignKeyColumn) continue; - const deleteStrategy = foreignKeyColumn.foreignResource?.onDelete ?? 'null'; - const primaryKeyColumn = resource.columns.find(c => c.primaryKey); - if (!primaryKeyColumn) continue; - const childRecords = await adminforth.resource(resource.resourceId).list([Filters.EQ(foreignKeyColumn.name, parentId)]); - for (const record of childRecords) { - const childId = record[primaryKeyColumn.name]; - if (deleteStrategy === 'cascade') { - await handleCascadeOnDelete.call(this, resource, childId); - await adminforth.resource(resource.resourceId).delete(childId); - continue; - } - if (deleteStrategy === 'setNull') { - await adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); - } - } - } - } + // async function handleCascadeOnDelete(parentResource: AdminForthResource, parentId: any) { + // const adminforth = this.adminforth; + // for (const resource of adminforth.config.resources) { + // if (resource.resourceId === parentResource.resourceId) continue; + // const foreignKeyColumn = resource.columns.find(c => c.foreignResource?.resourceId === parentResource.resourceId + // ); + // if (!foreignKeyColumn) continue; + // const deleteStrategy = foreignKeyColumn.foreignResource?.onDelete ?? 'null'; + // const primaryKeyColumn = resource.columns.find(c => c.primaryKey); + // if (!primaryKeyColumn) continue; + // const childRecords = await adminforth.resource(resource.resourceId).list([Filters.EQ(foreignKeyColumn.name, parentId)]); + // for (const record of childRecords) + + // const childId = record[primaryKeyColumn.name]; + // if (deleteStrategy === 'cascade') { + // await handleCascadeOnDelete.call(this, resource, childId); + // await adminforth.resource(resource.resourceId).delete(childId); + // continue; + // } + // if (deleteStrategy === 'setNull') { + // await adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); + // } + // } + // } + // } server.endpoint({ method: 'POST', path: '/delete_record', @@ -1491,6 +1492,27 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; } + + for (const childRes of this.adminforth.config.resources) { + for (const foreignKeyColumn of childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId)) { + const onDelete = (foreignKeyColumn.foreignResource as any).onDelete ?? 'setNull'; + const primaryKeyColumn = childRes.columns.find(c => c.primaryKey); + if (!primaryKeyColumn) continue; + + const childRecords = await this.adminforth.resource(childRes.resourceId).list([Filters.EQ(foreignKeyColumn.name, body['primaryKey'])]); + + for (const childRecord of childRecords) { + const childId = childRecord[primaryKeyColumn.name]; + if (onDelete === 'cascade') { + await this.adminforth.resource(childRes.resourceId).delete(childId); + } + if (onDelete === 'setNull') { + await this.adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); + } + } + } + } + const { allowedActions } = await interpretResource( adminUser, resource, @@ -1503,8 +1525,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!allowed) { return { error }; } - - await handleCascadeOnDelete.call(this, resource, body['primaryKey']); + const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, extra: { body, query, headers, cookies, requestUrl, response } From 23d178ba51bd8c43ca8551057b31adfc87287845 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 13:25:37 +0200 Subject: [PATCH 05/41] fix: update check strategy --- adminforth/modules/restApi.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 7505944e..ce2405e9 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1494,20 +1494,26 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } for (const childRes of this.adminforth.config.resources) { - for (const foreignKeyColumn of childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId)) { - const onDelete = (foreignKeyColumn.foreignResource as any).onDelete ?? 'setNull'; - const primaryKeyColumn = childRes.columns.find(c => c.primaryKey); - if (!primaryKeyColumn) continue; + const foreignKeyColumns = childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId); + if (!foreignKeyColumns.length) continue; + const primaryKeyColumn = childRes.columns.find(c => c.primaryKey); + if (!primaryKeyColumn) continue; + + for (const foreignKeyColumn of foreignKeyColumns) { const childRecords = await this.adminforth.resource(childRes.resourceId).list([Filters.EQ(foreignKeyColumn.name, body['primaryKey'])]); - for (const childRecord of childRecords) { - const childId = childRecord[primaryKeyColumn.name]; + const onDelete = (foreignKeyColumn.foreignResource as any).onDelete; + + for (const child of childRecords) { + const childId = child[primaryKeyColumn.name]; + if (onDelete === 'cascade') { await this.adminforth.resource(childRes.resourceId).delete(childId); - } - if (onDelete === 'setNull') { - await this.adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); + } else if (onDelete === 'setNull') { + await this.adminforth.resource(childRes.resourceId).update(childId, {[foreignKeyColumn.name]: null}); + } else { + return { error: `Wrong onDelete strategy ${onDelete} in resource '${childRes.resourceId}'` }; } } } From 8b5b7b5e03f4246d885edf158a614ee6c1aec9fd Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 16:19:25 +0200 Subject: [PATCH 06/41] feat: refine cascading deletion logic in delete endpoint --- adminforth/modules/restApi.ts | 63 +++++++++-------------------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index ce2405e9..7a5b7cad 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1452,31 +1452,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } }); - // async function handleCascadeOnDelete(parentResource: AdminForthResource, parentId: any) { - // const adminforth = this.adminforth; - // for (const resource of adminforth.config.resources) { - // if (resource.resourceId === parentResource.resourceId) continue; - // const foreignKeyColumn = resource.columns.find(c => c.foreignResource?.resourceId === parentResource.resourceId - // ); - // if (!foreignKeyColumn) continue; - // const deleteStrategy = foreignKeyColumn.foreignResource?.onDelete ?? 'null'; - // const primaryKeyColumn = resource.columns.find(c => c.primaryKey); - // if (!primaryKeyColumn) continue; - // const childRecords = await adminforth.resource(resource.resourceId).list([Filters.EQ(foreignKeyColumn.name, parentId)]); - // for (const record of childRecords) - - // const childId = record[primaryKeyColumn.name]; - // if (deleteStrategy === 'cascade') { - // await handleCascadeOnDelete.call(this, resource, childId); - // await adminforth.resource(resource.resourceId).delete(childId); - // continue; - // } - // if (deleteStrategy === 'setNull') { - // await adminforth.resource(resource.resourceId).update(childId, {[foreignKeyColumn.name]: null,}); - // } - // } - // } - // } server.endpoint({ method: 'POST', path: '/delete_record', @@ -1492,30 +1467,22 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; } - - for (const childRes of this.adminforth.config.resources) { - const foreignKeyColumns = childRes.columns.filter(c => c.foreignResource?.resourceId === resource.resourceId); - if (!foreignKeyColumns.length) continue; - - const primaryKeyColumn = childRes.columns.find(c => c.primaryKey); - if (!primaryKeyColumn) continue; - - for (const foreignKeyColumn of foreignKeyColumns) { - const childRecords = await this.adminforth.resource(childRes.resourceId).list([Filters.EQ(foreignKeyColumn.name, body['primaryKey'])]); - - const onDelete = (foreignKeyColumn.foreignResource as any).onDelete; - - for (const child of childRecords) { - const childId = child[primaryKeyColumn.name]; - - if (onDelete === 'cascade') { - await this.adminforth.resource(childRes.resourceId).delete(childId); - } else if (onDelete === 'setNull') { - await this.adminforth.resource(childRes.resourceId).update(childId, {[foreignKeyColumn.name]: null}); - } else { - return { error: `Wrong onDelete strategy ${onDelete} in resource '${childRes.resourceId}'` }; - } + const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); + if (!childResources.length) return; + for (const childRes of childResources) { + const foreignKeyColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + const onDeleteStrategy = foreignKeyColumn.foreignResource.onDelete; + const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignKeyColumn.name, body['primaryKey'])) + if (onDeleteStrategy === 'cascade') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).delete(childRecord.id); } + } else if (onDeleteStrategy === 'setNull') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignKeyColumn.name]: null}); + } + } else { + return { error: `Wrong onDelete strategy: ${onDeleteStrategy}` }; } } From 01dfcfd6c45bce35273dcaddf635839766f05053 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 16:26:49 +0200 Subject: [PATCH 07/41] fix: update condition --- adminforth/modules/restApi.ts | 29 +++++++++++++++-------------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 7a5b7cad..bcae598e 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1468,21 +1468,22 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; } const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); - if (!childResources.length) return; - for (const childRes of childResources) { - const foreignKeyColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); - const onDeleteStrategy = foreignKeyColumn.foreignResource.onDelete; - const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignKeyColumn.name, body['primaryKey'])) - if (onDeleteStrategy === 'cascade') { - for (const childRecord of childRecords) { - await this.adminforth.resource(childRes.resourceId).delete(childRecord.id); - } - } else if (onDeleteStrategy === 'setNull') { - for (const childRecord of childRecords) { - await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignKeyColumn.name]: null}); + if (childResources.length){ + for (const childRes of childResources) { + const foreignKeyColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + const onDeleteStrategy = foreignKeyColumn.foreignResource.onDelete; + const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignKeyColumn.name, body['primaryKey'])) + if (onDeleteStrategy === 'cascade') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).delete(childRecord.id); + } + } else if (onDeleteStrategy === 'setNull') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignKeyColumn.name]: null}); + } + } else { + return { error: `Wrong onDelete strategy: ${onDeleteStrategy}` }; } - } else { - return { error: `Wrong onDelete strategy: ${onDeleteStrategy}` }; } } From ee04911621c57b36fb3013eeb730166726c125bf Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 26 Feb 2026 16:37:10 +0200 Subject: [PATCH 08/41] fix: change variable name foreignKeyColumn to foreignResourceColumn --- adminforth/modules/restApi.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index bcae598e..67eee34f 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1470,16 +1470,16 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); if (childResources.length){ for (const childRes of childResources) { - const foreignKeyColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); - const onDeleteStrategy = foreignKeyColumn.foreignResource.onDelete; - const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignKeyColumn.name, body['primaryKey'])) + const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + 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.id); } } else if (onDeleteStrategy === 'setNull') { for (const childRecord of childRecords) { - await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignKeyColumn.name]: null}); + await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignResourceColumn.name]: null}); } } else { return { error: `Wrong onDelete strategy: ${onDeleteStrategy}` }; From 96c2c8f4f031f1d2aea5eaf9e052c797f2da0da2 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 27 Feb 2026 09:50:37 +0200 Subject: [PATCH 09/41] fix: add check for foreign resource onDelete strategy --- adminforth/modules/restApi.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 67eee34f..a6df347b 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1471,6 +1471,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (childResources.length){ for (const childRes of childResources) { 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'])) if (onDeleteStrategy === 'cascade') { From a300f83709f6c60ae55e1bd6d46aaaba9a45448d Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 27 Feb 2026 10:04:10 +0200 Subject: [PATCH 10/41] feat: add onDelete type --- adminforth/types/Back.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index ae566074..5631d5c4 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -2037,6 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm afterDatasourceResponse?: AfterDataSourceResponseFunction | Array, }, }, + onDelete: 'cascade' | 'setNull' } export type ShowInModernInput = { From 8ef2973fc909fe82e63f7e33168dfab4832aa27a Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 27 Feb 2026 10:27:24 +0200 Subject: [PATCH 11/41] fix: delete strategy check --- adminforth/modules/restApi.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index a6df347b..4d757348 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1482,8 +1482,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { for (const childRecord of childRecords) { await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignResourceColumn.name]: null}); } - } else { - return { error: `Wrong onDelete strategy: ${onDeleteStrategy}` }; } } } From 552ecdce3c05766a18798c3c46014b7e95b190de Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 27 Feb 2026 12:00:36 +0200 Subject: [PATCH 12/41] fix: add check for cascade strategy --- adminforth/modules/configValidator.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 4cefe723..710535be 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -618,7 +618,13 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray is enabled but suggestOnCreate is not an array`); } } - + if (col.foreignResource){ + if (col.foreignResource.onDelete){ + if (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull'){ + errors.push (`Wrong delete strategy you can use 'onDelete' or 'cascade'`); + } + } + } if (col.foreignResource) { if (!col.foreignResource.resourceId) { // resourceId is absent or empty From 423d6a0028a4893949a52f0e3faa33dd5746bdec Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 2 Mar 2026 13:44:23 +0200 Subject: [PATCH 13/41] fix: delete mistake in error message --- adminforth/modules/configValidator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 710535be..9e29962e 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -621,7 +621,7 @@ export default class ConfigValidator implements IConfigValidator { if (col.foreignResource){ if (col.foreignResource.onDelete){ if (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull'){ - errors.push (`Wrong delete strategy you can use 'onDelete' or 'cascade'`); + errors.push (`Wrong delete strategy you can use 'setNull' or 'cascade'`); } } } From ff63b6ce664a9e3026b0c3988f63f9f2b377298d Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 2 Mar 2026 15:16:00 +0200 Subject: [PATCH 14/41] fix: streamline foreign resource onDelete strategy validation --- adminforth/modules/configValidator.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 9e29962e..d49ec2ec 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -618,14 +618,10 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray is enabled but suggestOnCreate is not an array`); } } - if (col.foreignResource){ - if (col.foreignResource.onDelete){ - if (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull'){ + if (col.foreignResource) { + if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){ errors.push (`Wrong delete strategy you can use 'setNull' or 'cascade'`); - } } - } - if (col.foreignResource) { if (!col.foreignResource.resourceId) { // resourceId is absent or empty if (!col.foreignResource.polymorphicResources && !col.foreignResource.polymorphicOn) { From 9520f80a77a4949c70397a985145eac0a276fd5b Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 2 Mar 2026 15:17:02 +0200 Subject: [PATCH 15/41] add missing space --- adminforth/modules/configValidator.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index d49ec2ec..5b585ee4 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -618,6 +618,7 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray is enabled but suggestOnCreate is not an array`); } } + if (col.foreignResource) { if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){ errors.push (`Wrong delete strategy you can use 'setNull' or 'cascade'`); From d6502d3f8ca58f9364f0ca3f169ff46006d75d29 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 2 Mar 2026 15:29:11 +0200 Subject: [PATCH 16/41] fix: implement cascading deletion checks for MySQL, PostgreSQL, and SQLite connectors --- adminforth/dataConnectors/mysql.ts | 22 ++++++++++++++ adminforth/dataConnectors/postgres.ts | 42 +++++++++++++++++++++++++++ adminforth/dataConnectors/sqlite.ts | 11 +++++++ 3 files changed, 75 insertions(+) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index ed97a856..40a5949c 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -76,6 +76,28 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS async discoverFields(resource) { const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); + 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 = {}; + 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.`); } + } const fieldTypes = {}; results.forEach((row) => { const field: any = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 082e3cad..925f7571 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -69,9 +69,51 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } + private async getPgFkCascadeMap( + tableName: string, + schema = 'public' + ): Promise> { + const res = await this.client.query( + ` + SELECT + att.attname AS column_name, + rel.relname AS child_table, + p.relname AS parent_table, + con.confdeltype AS confdeltype + FROM pg_constraint con + JOIN pg_class rel ON rel.oid = con.conrelid + JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace + JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE + JOIN pg_attribute att + ON att.attrelid = con.conrelid AND att.attnum = k.attnum + JOIN pg_class p ON p.oid = con.confrelid + WHERE con.contype = 'f' + AND nsp.nspname = $2 + AND p.relname = $1 + `, + [tableName, schema] + ); + + const fkMap: Record = {}; + + for (const row of res.rows) { + fkMap[row.column_name.toLowerCase()] = { + cascade: row.confdeltype === 'c', + targetTable: row.parent_table, + }; + } + return fkMap; + } + async discoverFields(resource) { const tableName = resource.table; + const fkMap = await this.getPgFkCascadeMap(tableName); + const hasCascade = Object.values(fkMap).some(fk => fk.cascade); + const cascadeWarningShownMap: Record = {}; + if (hasCascade && !cascadeWarningShownMap[tableName]) { + afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + } const stmt = await this.client.query(` SELECT a.attname AS name, diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 0846b65b..73486772 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -42,6 +42,12 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData const tableName = resource.table; const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`); const rows = await stmt.all(); + const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${tableName})`); + const fkRows = await fkStmt.all(); + const fkMap: { [colName: string]: boolean } = {}; + fkRows.forEach(fk => { + fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE'; + }); const fieldTypes = {}; rows.forEach((row) => { const field: any = {}; @@ -86,6 +92,11 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData field._baseTypeDebug = baseType; field.required = row.notnull == 1; field.primaryKey = row.pk == 1; + + field.cascade = fkMap[row.name] || false; + if (field.cascade) { + afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + } field.default = row.dflt_value; fieldTypes[row.name] = field }); From 1843641769c778c04dbf914c5e29e0b225b92ac4 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Tue, 3 Mar 2026 12:32:51 +0200 Subject: [PATCH 17/41] fix: resolve copilot comment --- adminforth/dataConnectors/mysql.ts | 3 ++- adminforth/dataConnectors/postgres.ts | 3 +-- adminforth/modules/configValidator.ts | 2 +- adminforth/modules/restApi.ts | 34 +++++++++++++++------------ adminforth/types/Back.ts | 2 +- 5 files changed, 24 insertions(+), 20 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 40a5949c..96f49f93 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -96,8 +96,9 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS 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.`); } + afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); } + } const fieldTypes = {}; results.forEach((row) => { const field: any = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 925f7571..745bb660 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -110,8 +110,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const tableName = resource.table; const fkMap = await this.getPgFkCascadeMap(tableName); const hasCascade = Object.values(fkMap).some(fk => fk.cascade); - const cascadeWarningShownMap: Record = {}; - if (hasCascade && !cascadeWarningShownMap[tableName]) { + if (hasCascade) { afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); } const stmt = await this.client.query(` diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 5b585ee4..e53cbec3 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -621,7 +621,7 @@ export default class ConfigValidator implements IConfigValidator { if (col.foreignResource) { if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){ - errors.push (`Wrong delete strategy you can use 'setNull' or 'cascade'`); + errors.push (`Resource "${res.resourceId}" column "${col.name}" has wrong delete strategy, you can use 'setNull' or 'cascade'`); } if (!col.foreignResource.resourceId) { // resourceId is absent or empty diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 4d757348..9d8f386c 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1467,6 +1467,20 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; } + + const { allowedActions } = await interpretResource( + adminUser, + resource, + { requestBody: body, record: record }, + ActionCheckSource.DeleteRequest, + this.adminforth + ); + + const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); + if (!allowed) { + return { error }; + } + const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); if (childResources.length){ for (const childRes of childResources) { @@ -1474,31 +1488,21 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { 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.id); + 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.id, {[foreignResourceColumn.name]: null}); + await this.adminforth.resource(childRes.resourceId).update(childRecord[childPkFieldName], {[foreignResourceColumn.name]: null}); } } } } - const { allowedActions } = await interpretResource( - adminUser, - resource, - { requestBody: body, record: record }, - ActionCheckSource.DeleteRequest, - this.adminforth - ); - - const { allowed, error } = checkAccess(AllowedActionsEnum.delete, allowedActions); - if (!allowed) { - return { error }; - } - const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, extra: { body, query, headers, cookies, requestUrl, response } diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index 5631d5c4..b67acb7b 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -2037,7 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm afterDatasourceResponse?: AfterDataSourceResponseFunction | Array, }, }, - onDelete: 'cascade' | 'setNull' + onDelete?: 'cascade' | 'setNull' } export type ShowInModernInput = { From 21cc9a41f63c15153a0943fc6a7257a4bfb78ed9 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Tue, 3 Mar 2026 15:27:04 +0200 Subject: [PATCH 18/41] fix: add required check for setNull deletion --- adminforth/modules/configValidator.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index e53cbec3..98508a9f 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -618,11 +618,14 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray is enabled but suggestOnCreate is not an array`); } } - + if (col.foreignResource) { if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){ errors.push (`Resource "${res.resourceId}" column "${col.name}" has wrong delete strategy, you can use 'setNull' or 'cascade'`); } + if (col.foreignResource.onDelete === 'setNull' && col.required) { + errors.push(`Resource "${res.resourceId}" column "${col.name}" cannot use onDelete 'setNull' because column is required (non-nullable).`); + } if (!col.foreignResource.resourceId) { // resourceId is absent or empty if (!col.foreignResource.polymorphicResources && !col.foreignResource.polymorphicOn) { From 81fce83786fc9abcf4e0ffb4e74e2a77b51c3429 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Tue, 3 Mar 2026 15:31:35 +0200 Subject: [PATCH 19/41] fix: change resource.options.allowedActions.delete check --- adminforth/modules/restApi.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 9d8f386c..5e5f0b0f 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1464,10 +1464,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!record){ return { error: `Record with ${body['primaryKey']} not found` }; } - if (!resource.options.allowedActions.delete) { + if (resource.options.allowedActions.delete === false) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; - } - + } + const { allowedActions } = await interpretResource( adminUser, resource, From ec17bd54a2d9b75acfe15de35128a3c9f81117f6 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 4 Mar 2026 13:15:06 +0200 Subject: [PATCH 20/41] feat: implement cascading deletion logic in AdminForthRestAPI --- adminforth/modules/configValidator.ts | 7 +-- adminforth/modules/restApi.ts | 61 ++++++++++++++++++--------- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 98508a9f..39e84aab 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -30,7 +30,7 @@ import { import AdminForth from "adminforth"; import { AdminForthConfigMenuItem } from "adminforth"; import { afLogger } from "./logger.js"; - +import AdminForthRestAPI from './restApi.js'; export default class ConfigValidator implements IConfigValidator { @@ -282,8 +282,9 @@ export default class ConfigValidator implements IConfigValidator { return; } - await connector.deleteRecord({ resource: res as AdminForthResource, recordId }); - // call afterDelete hook + 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) => { diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 5e5f0b0f..56afe240 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -126,6 +126,7 @@ export async function interpretResource( export default class AdminForthRestAPI implements IAdminForthRestAPI { adminforth: IAdminForth; + static deleteWithCascade: any; constructor(adminforth: IAdminForth) { this.adminforth = adminforth; @@ -152,6 +153,44 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } + async deleteWithCascade(resource: AdminForthResource, primaryKey: any, context: {body: any, adminUser: any, query: any, headers: any, cookies: any, requestUrl: any, response: any}) { + const { adminUser, response } = context; + + const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); + + if (!record) return; + + const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); + + for (const childRes of childResources) { + const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + + if (!foreignColumn?.foreignResource?.onDelete) continue; + + const strategy = foreignColumn.foreignResource.onDelete; + + const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); + + console.log("childRecords", childRecords) + + const childPk = childRes.columns.find(c => c.primaryKey)?.name; + if (!childPk) continue; + + if (strategy === 'cascade') { + for (const childRecord of childRecords) { + await this.deleteWithCascade(childRes, childRecord[childPk], context); + } + } + + if (strategy === 'setNull') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null}); + } + } + } + + await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response, extra: context}); + } registerEndpoints(server: IHttpServer) { server.endpoint({ @@ -1481,27 +1520,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error }; } - const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); - if (childResources.length){ - for (const childRes of childResources) { - 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}); - } - } - } - } + 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, From a48d0a748e4091e374d6b65dd7a4d2e47451ac07 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 4 Mar 2026 13:16:53 +0200 Subject: [PATCH 21/41] fix: delete unused console.log --- adminforth/modules/restApi.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 56afe240..f0deed6f 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -171,8 +171,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); - console.log("childRecords", childRecords) - const childPk = childRes.columns.find(c => c.primaryKey)?.name; if (!childPk) continue; From c3a7a73664845ab7212243ecfe1445d6d8d21719 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 4 Mar 2026 15:29:44 +0200 Subject: [PATCH 22/41] fix: delete unused arguments from function --- adminforth/modules/configValidator.ts | 2 +- adminforth/modules/restApi.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 39e84aab..092e0049 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -283,7 +283,7 @@ export default class ConfigValidator implements IConfigValidator { } const restApi = new AdminForthRestAPI (this.adminforth) - restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: undefined, query: undefined, headers: undefined, cookies: undefined, requestUrl: undefined}); + restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: null,}); await Promise.all( (res.hooks.delete.afterSave).map( diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index f0deed6f..1e397a0b 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -153,7 +153,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } - async deleteWithCascade(resource: AdminForthResource, primaryKey: any, context: {body: any, adminUser: any, query: any, headers: any, cookies: any, requestUrl: any, response: any}) { + async deleteWithCascade(resource: AdminForthResource, primaryKey: any, context: {body: any, adminUser: any, response: any}) { const { adminUser, response } = context; const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); @@ -187,7 +187,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } - await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response, extra: context}); + await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); } registerEndpoints(server: IHttpServer) { @@ -1518,7 +1518,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error }; } - await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, query, headers, cookies, requestUrl, response}); + await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, response}); const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, From b4d8aa1f7a13096dbada8d521dad4b2003ed81d4 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 4 Mar 2026 15:39:20 +0200 Subject: [PATCH 23/41] fix: resolve copilot comment --- adminforth/modules/configValidator.ts | 2 +- adminforth/modules/restApi.ts | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 092e0049..f66fb102 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -283,7 +283,7 @@ export default class ConfigValidator implements IConfigValidator { } const restApi = new AdminForthRestAPI (this.adminforth) - restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: null,}); + await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: null,}); await Promise.all( (res.hooks.delete.afterSave).map( diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 1e397a0b..c0741631 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -126,8 +126,7 @@ export async function interpretResource( export default class AdminForthRestAPI implements IAdminForthRestAPI { adminforth: IAdminForth; - static deleteWithCascade: any; - + constructor(adminforth: IAdminForth) { this.adminforth = adminforth; } From 604c0b32405ef1c80923a1c75c1dc088eb17b5a3 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 4 Mar 2026 16:21:53 +0200 Subject: [PATCH 24/41] docs: add documentation for cascade deletion --- .../08-Plugins/03-ForeignInlineList.md | 34 ++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md index 60f6bc83..152a5c91 100644 --- a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md +++ b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md @@ -187,4 +187,36 @@ plugins: [ ``` -This setup will show, in the show view for each record, the `aparts` resource without any filters. And you don’t have to modify the `aparts` resource. \ No newline at end of file +This setup will show, in the show view for each record, the `aparts` resource without any filters. And you don’t have to modify the `aparts` resource. + + +## Cascade delete for foreign resources + +There might be cases when you want to control what happens with child records when a parent record is deleted. +You can configure this behavior in the `foreignResource` section using the `onDelete` option. + +```ts title="./resources/apartments.ts" + +export default { + resourceId: 'aparts', + ... + columns: [ + ... + { + name: 'realtor_id', + foreignResource: { + resourceId: 'adminuser', + //diff-add + onDelete: 'cascade' // cascade or setNull + //diff-add + } + } + ], +} + +``` + +#### The onDelete option supports two modes: + +- `cascade`: When a parent record is deleted, all related child records will be deleted automatically. +- `setNull`: When a parent record is deleted, child records will remain, but their foreign key will be set to null. From b3d3f244a59d59f5cb3868dcbe6c734796713a19 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 11:28:51 +0200 Subject: [PATCH 25/41] fix: resolve comment --- .../docs/tutorial/08-Plugins/03-ForeignInlineList.md | 1 - adminforth/modules/configValidator.ts | 2 +- adminforth/modules/restApi.ts | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md index 152a5c91..ef56c7fc 100644 --- a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md +++ b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md @@ -208,7 +208,6 @@ export default { resourceId: 'adminuser', //diff-add onDelete: 'cascade' // cascade or setNull - //diff-add } } ], diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index f66fb102..31bae606 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -283,7 +283,7 @@ export default class ConfigValidator implements IConfigValidator { } const restApi = new AdminForthRestAPI (this.adminforth) - await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: null,}); + await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response}); await Promise.all( (res.hooks.delete.afterSave).map( diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index c0741631..101b66f9 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -152,7 +152,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } - async deleteWithCascade(resource: AdminForthResource, primaryKey: any, context: {body: any, adminUser: any, response: any}) { + async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) { const { adminUser, response } = context; const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); @@ -1517,7 +1517,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error }; } - await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, response}); + await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response}); const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, From 25c00072c281f1b1e60cf3987b179cacacb9bdd1 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 12:32:12 +0200 Subject: [PATCH 26/41] fix: resolve copilot comment --- adminforth/modules/restApi.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 101b66f9..adac3e74 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -152,12 +152,12 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } - async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}) { + async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}): Promise<{ error: string | null }> { const { adminUser, response } = context; const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); - if (!record) return; + if (!record){ return {error: null};} const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); @@ -175,7 +175,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (strategy === 'cascade') { for (const childRecord of childRecords) { - await this.deleteWithCascade(childRes, childRecord[childPk], context); + const childResult = await this.deleteWithCascade(childRes, childRecord[childPk], context); + if (childResult?.error) { + return childResult; + } } } @@ -185,8 +188,8 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } - - await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); + const deleteResult = await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); + return { error: deleteResult.error}; } registerEndpoints(server: IHttpServer) { From 2ad14c6c5c1f313769a84da901cef41623fce8fd Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 13:23:55 +0200 Subject: [PATCH 27/41] fix: add errror message --- adminforth/modules/restApi.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index adac3e74..87880a27 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -157,7 +157,9 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); - if (!record){ return {error: null};} + if (!record){ + return {error: `Record with id ${primaryKey} not found`}; + } const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); From 2ed72a3042126bd602cee1f045a5c2738cf731b0 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 13:44:43 +0200 Subject: [PATCH 28/41] fix: update errors copilot comment --- adminforth/modules/restApi.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 87880a27..d4bcde66 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1522,7 +1522,10 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error }; } - await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response}); + const { error: cascadeError } = await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response}); + if (cascadeError) { + return { error: cascadeError }; + } const { error: deleteError } = await this.adminforth.deleteResourceRecord({ resource, record, adminUser, recordId: body['primaryKey'], response, From aa3b223c99d51b30ddedca5c9fac03f5546f1558 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 14:27:20 +0200 Subject: [PATCH 29/41] fix: update query for mysql --- adminforth/dataConnectors/mysql.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 96f49f93..ad4fba78 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -79,7 +79,6 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS 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 From 067b87c541b1b53efd48b9dfc1cf8d464089484f Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 15:11:17 +0200 Subject: [PATCH 30/41] fix: change query for check pg database cascade --- adminforth/dataConnectors/postgres.ts | 31 ++++++--------------------- 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 745bb660..4e8fb4c5 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -69,47 +69,30 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } - private async getPgFkCascadeMap( - tableName: string, - schema = 'public' - ): Promise> { + private async hasPgCascadeFk(tableName: string, schema = 'public'): Promise { const res = await this.client.query( ` - SELECT - att.attname AS column_name, - rel.relname AS child_table, - p.relname AS parent_table, - con.confdeltype AS confdeltype + SELECT 1 FROM pg_constraint con JOIN pg_class rel ON rel.oid = con.conrelid JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace - JOIN LATERAL unnest(con.conkey) WITH ORDINALITY AS k(attnum, ord) ON TRUE - JOIN pg_attribute att - ON att.attrelid = con.conrelid AND att.attnum = k.attnum JOIN pg_class p ON p.oid = con.confrelid WHERE con.contype = 'f' AND nsp.nspname = $2 AND p.relname = $1 + AND con.confdeltype = 'c' + LIMIT 1 `, [tableName, schema] ); - - const fkMap: Record = {}; - - for (const row of res.rows) { - fkMap[row.column_name.toLowerCase()] = { - cascade: row.confdeltype === 'c', - targetTable: row.parent_table, - }; - } - return fkMap; + return res.rowCount > 0; } async discoverFields(resource) { const tableName = resource.table; - const fkMap = await this.getPgFkCascadeMap(tableName); - const hasCascade = Object.values(fkMap).some(fk => fk.cascade); + const hasCascade = await this.hasPgCascadeFk(tableName); + if (hasCascade) { afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); } From 058283cc9fc3f52d33347e66373be6bc949505cf Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 15:55:20 +0200 Subject: [PATCH 31/41] fix: cange requests for check cascade --- adminforth/dataConnectors/mysql.ts | 14 +++++--------- adminforth/dataConnectors/postgres.ts | 15 ++++++--------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index ad4fba78..7d872355 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -78,23 +78,19 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); const [fkResults] = await this.client.execute(` SELECT + kcu.COLUMN_NAME, kcu.TABLE_NAME AS child_table, - rc.DELETE_RULE AS delete_rule - FROM information_schema.KEY_COLUMN_USAGE kcu - JOIN information_schema.REFERENTIAL_CONSTRAINTS rc + rc.DELETE_RULE + FROM information_schema.KEY_COLUMN_USAGE AS kcu + JOIN information_schema.REFERENTIAL_CONSTRAINTS AS 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 = {}; 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) { + if (fk.DELETE_RULE?.toUpperCase() === 'CASCADE') { afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); } } diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 4e8fb4c5..fb9e589e 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -68,23 +68,20 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa const sampleRow = sampleRowRes.rows[0] ?? {}; return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } - + private async hasPgCascadeFk(tableName: string, schema = 'public'): Promise { const res = await this.client.query( ` SELECT 1 - FROM pg_constraint con - JOIN pg_class rel ON rel.oid = con.conrelid - JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace - JOIN pg_class p ON p.oid = con.confrelid - WHERE con.contype = 'f' - AND nsp.nspname = $2 - AND p.relname = $1 - AND con.confdeltype = 'c' + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = ($2 || '.' || $1)::regclass + AND confdeltype = 'c' LIMIT 1 `, [tableName, schema] ); + return res.rowCount > 0; } From b0acb775c32c5eeb1479628b72d55799cc959949 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Thu, 5 Mar 2026 18:27:33 +0200 Subject: [PATCH 32/41] fix: change query for check cascade --- adminforth/dataConnectors/mysql.ts | 38 ++++++++++++++------------- adminforth/dataConnectors/postgres.ts | 28 ++++++++++---------- 2 files changed, 34 insertions(+), 32 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 7d872355..7d204580 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -74,26 +74,28 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } + private async hasPgCascadeFk(tableName: string): Promise { + const [fkResults] = await this.client.execute( + ` + SELECT + TABLE_NAME AS child_table, + CONSTRAINT_NAME + FROM information_schema.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME = ? + AND DELETE_RULE = 'CASCADE' + `, + [tableName] + ); + + for (const fk of fkResults as any[]) { + afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + } +} + async discoverFields(resource) { const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); - const [fkResults] = await this.client.execute(` - SELECT - kcu.COLUMN_NAME, - kcu.TABLE_NAME AS child_table, - rc.DELETE_RULE - FROM information_schema.KEY_COLUMN_USAGE AS kcu - JOIN information_schema.REFERENTIAL_CONSTRAINTS AS 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]); - - for (const fk of fkResults as any[]) { - if (fk.DELETE_RULE?.toUpperCase() === 'CASCADE') { - afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); - } - } + await this.hasPgCascadeFk(resource.table); const fieldTypes = {}; results.forEach((row) => { const field: any = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index fb9e589e..ffceed00 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -70,20 +70,20 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa } private async hasPgCascadeFk(tableName: string, schema = 'public'): Promise { - const res = await this.client.query( - ` - SELECT 1 - FROM pg_constraint - WHERE contype = 'f' - AND confrelid = ($2 || '.' || $1)::regclass - AND confdeltype = 'c' - LIMIT 1 - `, - [tableName, schema] - ); - - return res.rowCount > 0; - } + const res = await this.client.query( + ` + SELECT 1 + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = ($2 || '.' || $1)::regclass + AND confdeltype = 'c' + LIMIT 1 + `, + [tableName, schema] + ); + + return res.rowCount > 0; +} async discoverFields(resource) { From 0c28370b469b68f28349160b6bace891eeab539a Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 6 Mar 2026 09:49:52 +0200 Subject: [PATCH 33/41] style: add missing alignment --- adminforth/dataConnectors/mysql.ts | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 7d204580..00ba689f 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -75,23 +75,23 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS } private async hasPgCascadeFk(tableName: string): Promise { - const [fkResults] = await this.client.execute( - ` - SELECT - TABLE_NAME AS child_table, - CONSTRAINT_NAME - FROM information_schema.REFERENTIAL_CONSTRAINTS - WHERE CONSTRAINT_SCHEMA = DATABASE() - AND REFERENCED_TABLE_NAME = ? - AND DELETE_RULE = 'CASCADE' - `, - [tableName] - ); + const [fkResults] = await this.client.execute( + ` + SELECT + TABLE_NAME AS child_table, + CONSTRAINT_NAME + FROM information_schema.REFERENTIAL_CONSTRAINTS + WHERE CONSTRAINT_SCHEMA = DATABASE() + AND REFERENCED_TABLE_NAME = ? + AND DELETE_RULE = 'CASCADE' + `, + [tableName] + ); - for (const fk of fkResults as any[]) { - afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + for (const fk of fkResults as any[]) { + afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + } } -} async discoverFields(resource) { const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); From 17ac6d4acfaada402369f5b41c482ea367d6a2bb Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Fri, 6 Mar 2026 10:04:09 +0200 Subject: [PATCH 34/41] style: add missing alignment --- adminforth/dataConnectors/postgres.ts | 28 +++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index ffceed00..fb9e589e 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -70,20 +70,20 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa } private async hasPgCascadeFk(tableName: string, schema = 'public'): Promise { - const res = await this.client.query( - ` - SELECT 1 - FROM pg_constraint - WHERE contype = 'f' - AND confrelid = ($2 || '.' || $1)::regclass - AND confdeltype = 'c' - LIMIT 1 - `, - [tableName, schema] - ); - - return res.rowCount > 0; -} + const res = await this.client.query( + ` + SELECT 1 + FROM pg_constraint + WHERE contype = 'f' + AND confrelid = ($2 || '.' || $1)::regclass + AND confdeltype = 'c' + LIMIT 1 + `, + [tableName, schema] + ); + + return res.rowCount > 0; + } async discoverFields(resource) { From 16412424f1a2a4d838c93b3560fb9830a2e2fa5f Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 9 Mar 2026 09:56:28 +0200 Subject: [PATCH 35/41] feat: update discoverFields method to include config parameter across data connectors --- adminforth/dataConnectors/baseConnector.ts | 5 +++-- adminforth/index.ts | 2 +- adminforth/types/Back.ts | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 9e61957b..fa22dcb6 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -1,7 +1,8 @@ import { AdminForthResource, IAdminForthDataSourceConnectorBase, AdminForthResourceColumn, - IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter + IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter, + AdminForthConfig } from "../types/Back.js"; @@ -219,7 +220,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon throw new Error('Method not implemented.'); } - discoverFields(resource: AdminForthResource): Promise<{ [key: string]: AdminForthResourceColumn; }> { + discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{ [key: string]: AdminForthResourceColumn; }> { throw new Error('Method not implemented.'); } diff --git a/adminforth/index.ts b/adminforth/index.ts index 02f052d7..8878f2fd 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -419,7 +419,7 @@ class AdminForth implements IAdminForth { } let fieldTypes = null; try { - fieldTypes = await this.connectors[res.dataSource].discoverFields(res); + fieldTypes = await this.connectors[res.dataSource].discoverFields(res, this.config); } catch (e) { afLogger.error(`Error discovering fields for resource '${res.table}' (In resource '${res.resourceId}') ${e}`); } diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index b67acb7b..07c37112 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -192,7 +192,7 @@ export interface IAdminForthDataSourceConnector { * * @param resource */ - discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}>; + discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}>; /** From 1cbb6533e1ce78cbbf308f8ac22f2d3fddd26623 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 9 Mar 2026 09:57:00 +0200 Subject: [PATCH 36/41] feat: implement cascading deletion checks for MySQL, Postgres, and SQLite connectors --- adminforth/dataConnectors/mysql.ts | 46 ++++++++++++++++++++------- adminforth/dataConnectors/postgres.ts | 41 ++++++++++++++++++------ adminforth/dataConnectors/sqlite.ts | 44 ++++++++++++++++++++----- 3 files changed, 102 insertions(+), 29 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 00ba689f..76de5192 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import mysql from 'mysql2/promise'; @@ -74,28 +74,52 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } - private async hasPgCascadeFk(tableName: string): Promise { - const [fkResults] = await this.client.execute( + async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { + const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); + if (!currentResource) return; + const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); + + if (hasUploadPlugin) { + const tableName = (resource.table); + afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); + } + } + + async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { + + const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); + if (!cascadeColumn) return false; + + const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId); + + if (!parentResource) return false; + + const [rows] = await this.client.execute( ` - SELECT - TABLE_NAME AS child_table, - CONSTRAINT_NAME + SELECT 1 FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND REFERENCED_TABLE_NAME = ? AND DELETE_RULE = 'CASCADE' + LIMIT 1 `, - [tableName] + [parentResource.table] ); - for (const fk of fkResults as any[]) { - afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + const hasCascade = (rows as any[]).length > 0; + + if (hasCascade) { + afLogger.warn(`Table "${parentResource.table}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion.`); } + + return hasCascade; } - async discoverFields(resource) { + async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { + await this.checkCascadeWhenUploadPlugin(resource, config); + const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); - await this.hasPgCascadeFk(resource.table); + await this.hasMySQLCascadeFk(resource, config); const fieldTypes = {}; results.forEach((row) => { const field: any = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index fb9e589e..5f056ba5 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -1,5 +1,5 @@ import dayjs from 'dayjs'; -import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector } from '../types/Back.js'; +import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig } from '../types/Back.js'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js'; import AdminForthBaseConnector from './baseConnector.js'; import pkg from 'pg'; @@ -69,7 +69,11 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } - private async hasPgCascadeFk(tableName: string, schema = 'public'): Promise { + async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { + const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); + if (!cascadeColumn) return; + + const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId); const res = await this.client.query( ` SELECT 1 @@ -79,20 +83,37 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa AND confdeltype = 'c' LIMIT 1 `, - [tableName, schema] + [parentResource.table, schema] ); - return res.rowCount > 0; - } + const hasCascade = res.rowCount > 0; + if (hasCascade) { + afLogger.warn( + `Table "${parentResource.table}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion` + ); + } - async discoverFields(resource) { + return hasCascade; + } - const tableName = resource.table; - const hasCascade = await this.hasPgCascadeFk(tableName); + async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { + const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); + if (!currentResource) return; + const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); - if (hasCascade) { - afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); + if (hasUploadPlugin) { + const tableName = (resource.table); + afLogger.warn( + `Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.` + ); } + } + + async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { + await this.checkForeignResourceCascade(resource, config); + await this.checkCascadeWhenUploadPlugin(resource, config); + + const tableName = resource.table; const stmt = await this.client.query(` SELECT a.attname AS name, diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 73486772..696236c9 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -1,5 +1,5 @@ import betterSqlite3 from 'better-sqlite3'; -import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn } from '../types/Back.js'; +import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig } from '../types/Back.js'; import AdminForthBaseConnector from './baseConnector.js'; import dayjs from 'dayjs'; import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js'; @@ -38,16 +38,47 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData })); } - async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> { + async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { + const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); + if (!currentResource) return; + const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); + + if (hasUploadPlugin) { + const tableName = (resource.table); + afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); + } + } + + async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig, fkMap: { [colName: string]: boolean }): Promise { + + const hasAdminCascade = resource.columns?.some(c => c.foreignResource?.onDelete === 'cascade'); + if (!hasAdminCascade) return false; + + const hasDbCascade = Object.values(fkMap).some(v => v); + console.log("resource.resourceId" , resource.resourceId); + + if (hasDbCascade) { + const tableName = (resource.table); + afLogger.warn( + + `Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion` + ); + } + + return hasDbCascade; + } + + async discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}> { + await this.checkCascadeWhenUploadPlugin(resource, config); + const tableName = resource.table; const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`); const rows = await stmt.all(); const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${tableName})`); const fkRows = await fkStmt.all(); const fkMap: { [colName: string]: boolean } = {}; - fkRows.forEach(fk => { - fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE'; - }); + fkRows.forEach(fk => {fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE';}) + await this.hasSQLiteCascadeFk(resource, config, fkMap); const fieldTypes = {}; rows.forEach((row) => { const field: any = {}; @@ -94,9 +125,6 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData field.primaryKey = row.pk == 1; field.cascade = fkMap[row.name] || false; - if (field.cascade) { - afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`); - } field.default = row.dflt_value; fieldTypes[row.name] = field }); From 778a90b00b1cf01c792954e0c8c62814a6ddb394 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Mon, 9 Mar 2026 11:57:07 +0200 Subject: [PATCH 37/41] feat: implement checkCascadeWhenUploadPlugin method in base connector and remove from specific connectors --- adminforth/dataConnectors/baseConnector.ts | 11 +++++++++++ adminforth/dataConnectors/mysql.ts | 11 ----------- adminforth/dataConnectors/postgres.ts | 13 ------------- adminforth/dataConnectors/sqlite.ts | 14 +------------- 4 files changed, 12 insertions(+), 37 deletions(-) diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index fa22dcb6..a75e3ad4 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -507,6 +507,17 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon return result; } + async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { + const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); + if (!currentResource) return; + const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); + + if (hasUploadPlugin) { + const tableName = (resource.table); + afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); + } + } + getRecordByPrimaryKey(resource: AdminForthResource, recordId: string): Promise { return this.getRecordByPrimaryKeyWithOriginalTypes(resource, recordId).then((record) => { const newRecord = {}; diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 76de5192..1ed9d315 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -74,17 +74,6 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } - async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { - const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); - if (!currentResource) return; - const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); - - if (hasUploadPlugin) { - const tableName = (resource.table); - afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); - } - } - async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 5f056ba5..76319623 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -96,19 +96,6 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return hasCascade; } - async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { - const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); - if (!currentResource) return; - const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); - - if (hasUploadPlugin) { - const tableName = (resource.table); - afLogger.warn( - `Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.` - ); - } - } - async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { await this.checkForeignResourceCascade(resource, config); await this.checkCascadeWhenUploadPlugin(resource, config); diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 696236c9..489075ef 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -37,25 +37,13 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData sampleValue: sampleRow[col.name], })); } - - async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { - const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); - if (!currentResource) return; - const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); - - if (hasUploadPlugin) { - const tableName = (resource.table); - afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); - } - } - + async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig, fkMap: { [colName: string]: boolean }): Promise { const hasAdminCascade = resource.columns?.some(c => c.foreignResource?.onDelete === 'cascade'); if (!hasAdminCascade) return false; const hasDbCascade = Object.values(fkMap).some(v => v); - console.log("resource.resourceId" , resource.resourceId); if (hasDbCascade) { const tableName = (resource.table); From ca5bf912e80be7ee5eb97429a4340738b3b9401a Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Tue, 10 Mar 2026 15:11:49 +0200 Subject: [PATCH 38/41] fix: change check for cascade and upload plugin --- adminforth/dataConnectors/baseConnector.ts | 11 ------- adminforth/dataConnectors/mysql.ts | 13 +++----- adminforth/dataConnectors/postgres.ts | 17 +++++----- adminforth/dataConnectors/sqlite.ts | 36 ++++++++++------------ 4 files changed, 30 insertions(+), 47 deletions(-) diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index a75e3ad4..fa22dcb6 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -507,17 +507,6 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon return result; } - async checkCascadeWhenUploadPlugin(resource: AdminForthResource, config: AdminForthConfig) { - const currentResource = config.resources.find(r => r.resourceId === resource.resourceId); - if (!currentResource) return; - const hasUploadPlugin = currentResource.plugins?.some(p => p.className === "UploadPlugin"); - - if (hasUploadPlugin) { - const tableName = (resource.table); - afLogger.warn(`Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth UploadPlugin.`); - } - } - getRecordByPrimaryKey(resource: AdminForthResource, recordId: string): Promise { return this.getRecordByPrimaryKeyWithOriginalTypes(resource, recordId).then((record) => { const newRecord = {}; diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 1ed9d315..6ba0119f 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -80,7 +80,6 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS if (!cascadeColumn) return false; const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId); - if (!parentResource) return false; const [rows] = await this.client.execute( @@ -95,18 +94,16 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS [parentResource.table] ); - const hasCascade = (rows as any[]).length > 0; + const hasCascadeOnTable = (rows as any[]).length > 0; + + const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin"); - if (hasCascade) { - afLogger.warn(`Table "${parentResource.table}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion.`); + if (hasCascadeOnTable && isUploadPluginInstalled) { + afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and UploadPlugin installed, which may conflict with adminForth cascade deletion`); } - - return hasCascade; } async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { - await this.checkCascadeWhenUploadPlugin(resource, config); - const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table); await this.hasMySQLCascadeFk(resource, config); const fieldTypes = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 76319623..99a20f93 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -71,9 +71,11 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); - if (!cascadeColumn) return; + if (!cascadeColumn) return; const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId); + if (!parentResource) return; + const res = await this.client.query( ` SELECT 1 @@ -86,19 +88,16 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa [parentResource.table, schema] ); - const hasCascade = res.rowCount > 0; - if (hasCascade) { - afLogger.warn( - `Table "${parentResource.table}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion` - ); - } + const hasCascadeOnTable = res.rowCount > 0; + const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin"); - return hasCascade; + if (hasCascadeOnTable && isUploadPluginInstalled) { + afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and installed upload plugin, which may conflict with adminForth cascade deletion`); + } } async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { await this.checkForeignResourceCascade(resource, config); - await this.checkCascadeWhenUploadPlugin(resource, config); const tableName = resource.table; const stmt = await this.client.query(` diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 489075ef..e3407b29 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -38,35 +38,34 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData })); } - async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig, fkMap: { [colName: string]: boolean }): Promise { + async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { + const cascadeColumn = resource.columns?.find(c => c.foreignResource?.onDelete === 'cascade'); + if (!cascadeColumn) return false; - const hasAdminCascade = resource.columns?.some(c => c.foreignResource?.onDelete === 'cascade'); - if (!hasAdminCascade) return false; + const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId); + if (!parentResource) return false; - const hasDbCascade = Object.values(fkMap).some(v => v); + const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${resource.table})`); + const fkRows = await fkStmt.all(); + const fkMap: { [colName: string]: boolean } = {}; + fkRows.forEach(fk => { fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE'; }); - if (hasDbCascade) { - const tableName = (resource.table); - afLogger.warn( - - `Table "${tableName}" has ON DELETE CASCADE, which may conflict with adminForth cascade deletion` - ); + const hasCascadeOnTable = fkMap[cascadeColumn.name] || false; + const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin"); + + if (hasCascadeOnTable && isUploadPluginInstalled) { + afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and UploadPlugin installed, which may conflict with adminForth cascade deletion`); } - return hasDbCascade; + return hasCascadeOnTable; } async discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}> { - await this.checkCascadeWhenUploadPlugin(resource, config); const tableName = resource.table; const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`); - const rows = await stmt.all(); - const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${tableName})`); - const fkRows = await fkStmt.all(); - const fkMap: { [colName: string]: boolean } = {}; - fkRows.forEach(fk => {fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE';}) - await this.hasSQLiteCascadeFk(resource, config, fkMap); + const rows = await stmt.all(); + await this.hasSQLiteCascadeFk(resource, config); const fieldTypes = {}; rows.forEach((row) => { const field: any = {}; @@ -112,7 +111,6 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData field.required = row.notnull == 1; field.primaryKey = row.pk == 1; - field.cascade = fkMap[row.name] || false; field.default = row.dflt_value; fieldTypes[row.name] = field }); From 66b03771db8d0cc97d480f26522f0fa12f45c4c9 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Tue, 10 Mar 2026 16:30:53 +0200 Subject: [PATCH 39/41] feat: implement cascading deletion utility for child resources --- adminforth/modules/configValidator.ts | 7 +++-- adminforth/modules/restApi.ts | 45 ++------------------------- adminforth/modules/utils.ts | 40 +++++++++++++++++++++++- 3 files changed, 46 insertions(+), 46 deletions(-) diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 31bae606..598403a9 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -30,6 +30,7 @@ import { import AdminForth from "adminforth"; import { AdminForthConfigMenuItem } from "adminforth"; import { afLogger } from "./logger.js"; +import {cascadeChildrenDelete} from './utils.js' import AdminForthRestAPI from './restApi.js'; export default class ConfigValidator implements IConfigValidator { @@ -282,9 +283,9 @@ export default class ConfigValidator implements IConfigValidator { return; } - const restApi = new AdminForthRestAPI (this.adminforth) - await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response}); - + await connector.deleteRecord({ resource: res as AdminForthResource, recordId }); + await cascadeChildrenDelete(res as AdminForthResource, recordId, { adminUser, response}, this.adminforth); + await Promise.all( (res.hooks.delete.afterSave).map( async (hook) => { diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index d4bcde66..52b26b60 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -15,6 +15,8 @@ import { Filters, } from "../types/Back.js"; +import {cascadeChildrenDelete} from './utils.js' + import { afLogger } from "./logger.js"; import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML } from './utils.js'; @@ -152,47 +154,6 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { } } } - async deleteWithCascade(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}): Promise<{ error: string | null }> { - const { adminUser, response } = context; - - const record = await this.adminforth.connectors[resource.dataSource].getRecordByPrimaryKey(resource, primaryKey); - - if (!record){ - return {error: `Record with id ${primaryKey} not found`}; - } - - const childResources = this.adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); - - for (const childRes of childResources) { - const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); - - if (!foreignColumn?.foreignResource?.onDelete) continue; - - const strategy = foreignColumn.foreignResource.onDelete; - - const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); - - const childPk = childRes.columns.find(c => c.primaryKey)?.name; - if (!childPk) continue; - - if (strategy === 'cascade') { - for (const childRecord of childRecords) { - const childResult = await this.deleteWithCascade(childRes, childRecord[childPk], context); - if (childResult?.error) { - return childResult; - } - } - } - - if (strategy === 'setNull') { - for (const childRecord of childRecords) { - await this.adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null}); - } - } - } - const deleteResult = await this.adminforth.deleteResourceRecord({resource, record, adminUser, recordId: primaryKey, response}); - return { error: deleteResult.error}; - } registerEndpoints(server: IHttpServer) { server.endpoint({ @@ -1522,7 +1483,7 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { return { error }; } - const { error: cascadeError } = await this.deleteWithCascade(resource, body.primaryKey, {adminUser, response}); + const { error: cascadeError } = await cascadeChildrenDelete(resource, body.primaryKey, {adminUser, response}, this.adminforth); if (cascadeError) { return { error: cascadeError }; } diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index 3deaf2c7..a93d7287 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -3,7 +3,7 @@ import { fileURLToPath } from 'url'; import fs from 'fs'; import Fuse from 'fuse.js'; import crypto from 'crypto'; -import AdminForth, { AdminForthConfig, AdminForthResourceColumnInputCommon, Predicate } from '../index.js'; +import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js'; import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible"; // @ts-ignore-next-line @@ -479,3 +479,41 @@ export function slugifyString(str: string): string { .replace(/\s+/g, '-') .replace(/[^a-z0-9-_]/g, '-'); } + +export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth): Promise<{ error: string | null }> { + const { adminUser, response } = context; + + const childResources = adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); + + for (const childRes of childResources) { + const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + + if (!foreignColumn?.foreignResource?.onDelete) continue; + + const strategy = foreignColumn.foreignResource.onDelete; + + const childRecords = await adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); + + const childPk = childRes.columns.find(c => c.primaryKey)?.name; + if (!childPk) continue; + + if (strategy === 'cascade') { + for (const childRecord of childRecords) { + const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth); + const deleteChild = await adminforth.deleteResourceRecord({resource: childRes, record: childRecord, adminUser, recordId: childRecord[childPk], response}); + if (deleteChild.error) return { error: deleteChild.error }; + if (childResult?.error) { + return childResult; + } + } + } + + if (strategy === 'setNull') { + for (const childRecord of childRecords) { + await adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null}); + } + } + } + + return { error: null }; + } \ No newline at end of file From 1acfe3e42864ea51268911daf06fb1533ce518db Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 11 Mar 2026 10:15:49 +0200 Subject: [PATCH 40/41] feat: enhance cascading deletion logic to prevent infinite loops and improve foreign key checks --- adminforth/dataConnectors/mysql.ts | 4 +++- adminforth/dataConnectors/postgres.ts | 11 ++++++----- adminforth/modules/configValidator.ts | 3 +-- adminforth/modules/utils.ts | 14 +++++++++++--- 4 files changed, 21 insertions(+), 11 deletions(-) diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index 6ba0119f..54df749d 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -87,11 +87,12 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS SELECT 1 FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() + AND TABLE_NAME = ? AND REFERENCED_TABLE_NAME = ? AND DELETE_RULE = 'CASCADE' LIMIT 1 `, - [parentResource.table] + [resource.table, parentResource.table] ); const hasCascadeOnTable = (rows as any[]).length > 0; @@ -101,6 +102,7 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS if (hasCascadeOnTable && isUploadPluginInstalled) { afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and UploadPlugin installed, which may conflict with adminForth cascade deletion`); } + return hasCascadeOnTable; } async discoverFields(resource: AdminForthResource, config: AdminForthConfig) { diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 99a20f93..cf37610a 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -69,7 +69,7 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] })); } - async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { + async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise { const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade'); if (!cascadeColumn) return; @@ -81,11 +81,12 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa SELECT 1 FROM pg_constraint WHERE contype = 'f' - AND confrelid = ($2 || '.' || $1)::regclass - AND confdeltype = 'c' + AND confrelid = ($2 || '.' || $1)::regclass + AND conrelid = ($2 || '.' || $3)::regclass + AND confdeltype = 'c' LIMIT 1 - `, - [parentResource.table, schema] + `, + [parentResource.table, schema, resource.table ] ); const hasCascadeOnTable = res.rowCount > 0; diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 598403a9..9a768015 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -31,7 +31,6 @@ import AdminForth from "adminforth"; import { AdminForthConfigMenuItem } from "adminforth"; import { afLogger } from "./logger.js"; import {cascadeChildrenDelete} from './utils.js' -import AdminForthRestAPI from './restApi.js'; export default class ConfigValidator implements IConfigValidator { @@ -283,8 +282,8 @@ export default class ConfigValidator implements IConfigValidator { return; } - await connector.deleteRecord({ resource: res as AdminForthResource, recordId }); await cascadeChildrenDelete(res as AdminForthResource, recordId, { adminUser, response}, this.adminforth); + await connector.deleteRecord({ resource: res as AdminForthResource, recordId }); await Promise.all( (res.hooks.delete.afterSave).map( diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index a93d7287..c5dea8cf 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -480,9 +480,15 @@ export function slugifyString(str: string): string { .replace(/[^a-z0-9-_]/g, '-'); } -export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth): Promise<{ error: string | null }> { +export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth, visitedResources: Set = new Set()): Promise<{ error: string | null }> { const { adminUser, response } = context; + if (visitedResources.has(resource.resourceId)) { + return { error: null }; + } + + visitedResources.add(resource.resourceId); + const childResources = adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); for (const childRes of childResources) { @@ -495,11 +501,13 @@ export async function cascadeChildrenDelete(resource: AdminForthResource, primar const childRecords = await adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey)); const childPk = childRes.columns.find(c => c.primaryKey)?.name; - if (!childPk) continue; if (strategy === 'cascade') { for (const childRecord of childRecords) { - const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth); + const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth, visitedResources); + if (childResult?.error) { + return childResult; + } const deleteChild = await adminforth.deleteResourceRecord({resource: childRes, record: childRecord, adminUser, recordId: childRecord[childPk], response}); if (deleteChild.error) return { error: deleteChild.error }; if (childResult?.error) { From ea5e92f45947790b6d812bb3031a754c72cdb5c4 Mon Sep 17 00:00:00 2001 From: Pavlo Kulyk Date: Wed, 11 Mar 2026 11:02:12 +0200 Subject: [PATCH 41/41] fix: delete unused check --- adminforth/modules/utils.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index c5dea8cf..f7f2c27d 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -480,14 +480,8 @@ export function slugifyString(str: string): string { .replace(/[^a-z0-9-_]/g, '-'); } -export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth, visitedResources: Set = new Set()): Promise<{ error: string | null }> { +export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth): Promise<{ error: string | null }> { const { adminUser, response } = context; - - if (visitedResources.has(resource.resourceId)) { - return { error: null }; - } - - visitedResources.add(resource.resourceId); const childResources = adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); @@ -504,7 +498,7 @@ export async function cascadeChildrenDelete(resource: AdminForthResource, primar if (strategy === 'cascade') { for (const childRecord of childRecords) { - const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth, visitedResources); + const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth); if (childResult?.error) { return childResult; }