Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ca65f67
feat: implement cascading deletion for related records in delete endp…
Feb 25, 2026
bda166a
add alowwedAction check
Feb 26, 2026
12d2ba6
add missing spaces
Feb 26, 2026
02e216b
feat: implement cascading deletion logic in delete endpoint
Feb 26, 2026
23d178b
fix: update check strategy
Feb 26, 2026
8b5b7b5
feat: refine cascading deletion logic in delete endpoint
Feb 26, 2026
01dfcfd
fix: update condition
Feb 26, 2026
ee04911
fix: change variable name foreignKeyColumn to foreignResourceColumn
Feb 26, 2026
96c2c8f
fix: add check for foreign resource onDelete strategy
Feb 27, 2026
a300f83
feat: add onDelete type
Feb 27, 2026
8ef2973
fix: delete strategy check
Feb 27, 2026
552ecdc
fix: add check for cascade strategy
Feb 27, 2026
423d6a0
fix: delete mistake in error message
Mar 2, 2026
ff63b6c
fix: streamline foreign resource onDelete strategy validation
Mar 2, 2026
9520f80
add missing space
Mar 2, 2026
d6502d3
fix: implement cascading deletion checks for MySQL, PostgreSQL, and S…
Mar 2, 2026
1843641
fix: resolve copilot comment
Mar 3, 2026
21cc9a4
fix: add required check for setNull deletion
Mar 3, 2026
81fce83
fix: change resource.options.allowedActions.delete check
Mar 3, 2026
ec17bd5
feat: implement cascading deletion logic in AdminForthRestAPI
Mar 4, 2026
a48d0a7
fix: delete unused console.log
Mar 4, 2026
c3a7a73
fix: delete unused arguments from function
Mar 4, 2026
b4d8aa1
fix: resolve copilot comment
Mar 4, 2026
604c0b3
docs: add documentation for cascade deletion
Mar 4, 2026
b3d3f24
fix: resolve comment
Mar 5, 2026
25c0007
fix: resolve copilot comment
Mar 5, 2026
2ad14c6
fix: add errror message
Mar 5, 2026
2ed72a3
fix: update errors copilot comment
Mar 5, 2026
aa3b223
fix: update query for mysql
Mar 5, 2026
067b87c
fix: change query for check pg database cascade
Mar 5, 2026
058283c
fix: cange requests for check cascade
Mar 5, 2026
b0acb77
fix: change query for check cascade
Mar 5, 2026
0c28370
style: add missing alignment
Mar 6, 2026
17ac6d4
style: add missing alignment
Mar 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,28 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
}));
}

private async hasPgCascadeFk(tableName: string): Promise<void> {
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);
await this.hasPgCascadeFk(resource.table);
const fieldTypes = {};
results.forEach((row) => {
const field: any = {};
Expand Down
23 changes: 22 additions & 1 deletion adminforth/dataConnectors/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,31 @@ 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<boolean> {
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) {

const tableName = resource.table;
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.`);
}
const stmt = await this.client.query(`
SELECT
a.attname AS name,
Expand Down
11 changes: 11 additions & 0 deletions adminforth/dataConnectors/sqlite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Comment on lines +45 to +49
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

SQLite's PRAGMA foreign_key_list(tableName) returns the FK constraints defined in tableName — i.e., where tableName is the child/dependent table. So fkMap[fk.from] maps columns in the current table that reference other (parent) tables, and checks if they have ON DELETE CASCADE.

However, the warning aims to detect conflicts where a child table targeting the current (parent) resource already has a DB-level ON DELETE CASCADE — which would double-delete when adminForth's cascade deletion also runs. To detect that conflict correctly, you need to check tables that reference the current table as their parent (the reverse direction).

In contrast, the PostgreSQL and MySQL queries correctly filter by the current tableName as the referenced/parent table and find child tables that cascade-delete on the current table's row deletion.

The SQLite check should use a different approach to detect child tables that reference the current table with ON DELETE CASCADE (e.g., check PRAGMA foreign_key_list on all other discovered tables and look for references to the current table).

Copilot uses AI. Check for mistakes.
});
const fieldTypes = {};
rows.forEach((row) => {
const field: any = {};
Expand Down Expand Up @@ -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
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -187,4 +187,35 @@ 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.
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
}
}
],
}

```

#### 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.
13 changes: 10 additions & 3 deletions adminforth/modules/configValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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)
await restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response});

await Promise.all(
(res.hooks.delete.afterSave).map(
async (hook) => {
Expand Down Expand Up @@ -620,6 +621,12 @@ export default class ConfigValidator implements IConfigValidator {
}

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) {
Expand Down
48 changes: 47 additions & 1 deletion adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export async function interpretResource(
export default class AdminForthRestAPI implements IAdminForthRestAPI {

adminforth: IAdminForth;

constructor(adminforth: IAdminForth) {
this.adminforth = adminforth;
}
Expand All @@ -152,6 +152,47 @@ 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({
Expand Down Expand Up @@ -1481,6 +1522,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
return { error };
}

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,
Comment on lines 1523 to 1531
Copy link

Copilot AI Mar 5, 2026

Choose a reason for hiding this comment

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

The deleteWithCascade method (line 191) already calls this.adminforth.deleteResourceRecord(...) internally, which performs the actual DB deletion and runs both beforeSave and afterSave delete hooks. Then, immediately after calling deleteWithCascade at line 1523, deleteResourceRecord is called again at lines 1525–1528 for the same parent record.

This causes two problems:

  1. The parent record is deleted twice — the second deletion either silently no-ops or throws a "record not found" error.
  2. Both beforeSave and afterSave delete hooks fire twice for the parent resource.

The fix is to remove the redundant deleteResourceRecord call at lines 1525–1528, since the deletion and hook execution is already handled inside deleteWithCascade.

Copilot uses AI. Check for mistakes.
extra: { body, query, headers, cookies, requestUrl, response }
Expand Down
1 change: 1 addition & 0 deletions adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2037,6 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm
afterDatasourceResponse?: AfterDataSourceResponseFunction | Array<AfterDataSourceResponseFunction>,
},
},
onDelete?: 'cascade' | 'setNull'
}

export type ShowInModernInput = {
Expand Down