Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 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
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
23 changes: 23 additions & 0 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,29 @@ 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<string, { cascade: boolean; childTable: string }> = {};
for (const fk of fkResults as any[]) {
fkMap[String(fk.column_name)] = {
cascade: String(fk.delete_rule).toUpperCase() === 'CASCADE',
childTable: fk.child_table
};
if (fkMap[fk.column_name].cascade) {
afLogger.warn(`The database has ON DELETE CASCADE, which may conflict with adminForth cascade deletion and upload logic. Please remove it.`);
}
Comment on lines +79 to +100
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

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

The MySQL query uses WHERE kcu.REFERENCED_TABLE_NAME = ? and selects kcu.COLUMN_NAME, which returns column names from CHILD tables that reference the current table (i.e., columns in other tables that point to this table as a parent). This is the opposite perspective from the SQLite implementation, which uses PRAGMA foreign_key_list(tableName) to return FK columns defined ON the current table (the child side).

The practical effect is that in MySQL, fkMap keys are column names from child tables rather than columns of the current table being discovered. For the warning logic this is functionally harmless since the warning fires as long as any cascade is found. However, it is semantically inconsistent with the SQLite approach and with the intent of discoverFields (which is supposed to characterize the fields of the current table). If fkMap is later used to set field properties (similar to SQLite's field.cascade = fkMap[row.name] || false), this inverted mapping would cause incorrect results.

Copilot uses AI. Check for mistakes.
}
const fieldTypes = {};
results.forEach((row) => {
const field: any = {};
Expand Down
41 changes: 41 additions & 0 deletions adminforth/dataConnectors/postgres.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,50 @@ 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<Record<string, { cascade: boolean; targetTable: string }>> {
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<string, { cascade: boolean; targetTable: string }> = {};

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);
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';
});
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,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.
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.
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, body: null,});

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
40 changes: 39 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,42 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
}
}
}
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);

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));

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

registerEndpoints(server: IHttpServer) {
server.endpoint({
Expand Down Expand Up @@ -1481,6 +1517,8 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
return { error };
}

await this.deleteWithCascade(resource, body.primaryKey, {body, adminUser, response});

const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
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