Skip to content

Commit b64ed2f

Browse files
authored
Merge pull request #488 from devforth/feature/AdminForth/1256/cascading-deletion-
feat: implement cascading deletion for related records in delete
2 parents 1daa630 + ea5e92f commit b64ed2f

File tree

10 files changed

+190
-17
lines changed

10 files changed

+190
-17
lines changed

adminforth/dataConnectors/baseConnector.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {
22
AdminForthResource, IAdminForthDataSourceConnectorBase,
33
AdminForthResourceColumn,
4-
IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter
4+
IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter,
5+
AdminForthConfig
56
} from "../types/Back.js";
67

78

@@ -219,7 +220,7 @@ export default class AdminForthBaseConnector implements IAdminForthDataSourceCon
219220
throw new Error('Method not implemented.');
220221
}
221222

222-
discoverFields(resource: AdminForthResource): Promise<{ [key: string]: AdminForthResourceColumn; }> {
223+
discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{ [key: string]: AdminForthResourceColumn; }> {
223224
throw new Error('Method not implemented.');
224225
}
225226

adminforth/dataConnectors/mysql.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import dayjs from 'dayjs';
2-
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector } from '../types/Back.js';
2+
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig } from '../types/Back.js';
33
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js';
44
import AdminForthBaseConnector from './baseConnector.js';
55
import mysql from 'mysql2/promise';
@@ -74,8 +74,40 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
7474
}));
7575
}
7676

77-
async discoverFields(resource) {
77+
async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {
78+
79+
const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');
80+
if (!cascadeColumn) return false;
81+
82+
const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId);
83+
if (!parentResource) return false;
84+
85+
const [rows] = await this.client.execute(
86+
`
87+
SELECT 1
88+
FROM information_schema.REFERENTIAL_CONSTRAINTS
89+
WHERE CONSTRAINT_SCHEMA = DATABASE()
90+
AND TABLE_NAME = ?
91+
AND REFERENCED_TABLE_NAME = ?
92+
AND DELETE_RULE = 'CASCADE'
93+
LIMIT 1
94+
`,
95+
[resource.table, parentResource.table]
96+
);
97+
98+
const hasCascadeOnTable = (rows as any[]).length > 0;
99+
100+
const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin");
101+
102+
if (hasCascadeOnTable && isUploadPluginInstalled) {
103+
afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and UploadPlugin installed, which may conflict with adminForth cascade deletion`);
104+
}
105+
return hasCascadeOnTable;
106+
}
107+
108+
async discoverFields(resource: AdminForthResource, config: AdminForthConfig) {
78109
const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table);
110+
await this.hasMySQLCascadeFk(resource, config);
79111
const fieldTypes = {};
80112
results.forEach((row) => {
81113
const field: any = {};

adminforth/dataConnectors/postgres.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import dayjs from 'dayjs';
2-
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector } from '../types/Back.js';
2+
import { AdminForthResource, IAdminForthSingleFilter, IAdminForthAndOrFilter, IAdminForthDataSourceConnector, AdminForthConfig } from '../types/Back.js';
33
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections, } from '../types/Common.js';
44
import AdminForthBaseConnector from './baseConnector.js';
55
import pkg from 'pg';
@@ -68,8 +68,37 @@ class PostgresConnector extends AdminForthBaseConnector implements IAdminForthDa
6868
const sampleRow = sampleRowRes.rows[0] ?? {};
6969
return res.rows.map(row => ({ name: row.column_name, sampleValue: sampleRow[row.column_name] }));
7070
}
71-
72-
async discoverFields(resource) {
71+
72+
async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise<void> {
73+
const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');
74+
if (!cascadeColumn) return;
75+
76+
const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId);
77+
if (!parentResource) return;
78+
79+
const res = await this.client.query(
80+
`
81+
SELECT 1
82+
FROM pg_constraint
83+
WHERE contype = 'f'
84+
AND confrelid = ($2 || '.' || $1)::regclass
85+
AND conrelid = ($2 || '.' || $3)::regclass
86+
AND confdeltype = 'c'
87+
LIMIT 1
88+
`,
89+
[parentResource.table, schema, resource.table ]
90+
);
91+
92+
const hasCascadeOnTable = res.rowCount > 0;
93+
const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin");
94+
95+
if (hasCascadeOnTable && isUploadPluginInstalled) {
96+
afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and installed upload plugin, which may conflict with adminForth cascade deletion`);
97+
}
98+
}
99+
100+
async discoverFields(resource: AdminForthResource, config: AdminForthConfig) {
101+
await this.checkForeignResourceCascade(resource, config);
73102

74103
const tableName = resource.table;
75104
const stmt = await this.client.query(`

adminforth/dataConnectors/sqlite.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import betterSqlite3 from 'better-sqlite3';
2-
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn } from '../types/Back.js';
2+
import { IAdminForthDataSourceConnector, IAdminForthSingleFilter, IAdminForthAndOrFilter, AdminForthResource, AdminForthResourceColumn, AdminForthConfig } from '../types/Back.js';
33
import AdminForthBaseConnector from './baseConnector.js';
44
import dayjs from 'dayjs';
55
import { AdminForthDataTypes, AdminForthFilterOperators, AdminForthSortDirections } from '../types/Common.js';
@@ -37,11 +37,35 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
3737
sampleValue: sampleRow[col.name],
3838
}));
3939
}
40+
41+
async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {
42+
const cascadeColumn = resource.columns?.find(c => c.foreignResource?.onDelete === 'cascade');
43+
if (!cascadeColumn) return false;
44+
45+
const parentResource = config.resources.find(r => r.resourceId === cascadeColumn.foreignResource.resourceId);
46+
if (!parentResource) return false;
47+
48+
const fkStmt = this.client.prepare(`PRAGMA foreign_key_list(${resource.table})`);
49+
const fkRows = await fkStmt.all();
50+
const fkMap: { [colName: string]: boolean } = {};
51+
fkRows.forEach(fk => { fkMap[fk.from] = fk.on_delete?.toUpperCase() === 'CASCADE'; });
52+
53+
const hasCascadeOnTable = fkMap[cascadeColumn.name] || false;
54+
const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin");
55+
56+
if (hasCascadeOnTable && isUploadPluginInstalled) {
57+
afLogger.warn(`Table "${resource.table}" has ON DELETE CASCADE and UploadPlugin installed, which may conflict with adminForth cascade deletion`);
58+
}
59+
60+
return hasCascadeOnTable;
61+
}
62+
63+
async discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}> {
4064

41-
async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {
4265
const tableName = resource.table;
4366
const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`);
44-
const rows = await stmt.all();
67+
const rows = await stmt.all();
68+
await this.hasSQLiteCascadeFk(resource, config);
4569
const fieldTypes = {};
4670
rows.forEach((row) => {
4771
const field: any = {};
@@ -86,6 +110,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
86110
field._baseTypeDebug = baseType;
87111
field.required = row.notnull == 1;
88112
field.primaryKey = row.pk == 1;
113+
89114
field.default = row.dflt_value;
90115
fieldTypes[row.name] = field
91116
});

adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,4 +187,35 @@ plugins: [
187187

188188
```
189189

190-
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.
190+
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.
191+
192+
193+
## Cascade delete for foreign resources
194+
195+
There might be cases when you want to control what happens with child records when a parent record is deleted.
196+
You can configure this behavior in the `foreignResource` section using the `onDelete` option.
197+
198+
```ts title="./resources/apartments.ts"
199+
200+
export default {
201+
resourceId: 'aparts',
202+
...
203+
columns: [
204+
...
205+
{
206+
name: 'realtor_id',
207+
foreignResource: {
208+
resourceId: 'adminuser',
209+
//diff-add
210+
onDelete: 'cascade' // cascade or setNull
211+
}
212+
}
213+
],
214+
}
215+
216+
```
217+
218+
#### The onDelete option supports two modes:
219+
220+
- `cascade`: When a parent record is deleted, all related child records will be deleted automatically.
221+
- `setNull`: When a parent record is deleted, child records will remain, but their foreign key will be set to null.

adminforth/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,7 +419,7 @@ class AdminForth implements IAdminForth {
419419
}
420420
let fieldTypes = null;
421421
try {
422-
fieldTypes = await this.connectors[res.dataSource].discoverFields(res);
422+
fieldTypes = await this.connectors[res.dataSource].discoverFields(res, this.config);
423423
} catch (e) {
424424
afLogger.error(`Error discovering fields for resource '${res.table}' (In resource '${res.resourceId}') ${e}`);
425425
}

adminforth/modules/configValidator.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import {
3030
import AdminForth from "adminforth";
3131
import { AdminForthConfigMenuItem } from "adminforth";
3232
import { afLogger } from "./logger.js";
33-
33+
import {cascadeChildrenDelete} from './utils.js'
3434

3535
export default class ConfigValidator implements IConfigValidator {
3636

@@ -282,8 +282,9 @@ export default class ConfigValidator implements IConfigValidator {
282282
return;
283283
}
284284

285+
await cascadeChildrenDelete(res as AdminForthResource, recordId, { adminUser, response}, this.adminforth);
285286
await connector.deleteRecord({ resource: res as AdminForthResource, recordId });
286-
// call afterDelete hook
287+
287288
await Promise.all(
288289
(res.hooks.delete.afterSave).map(
289290
async (hook) => {
@@ -620,6 +621,12 @@ export default class ConfigValidator implements IConfigValidator {
620621
}
621622

622623
if (col.foreignResource) {
624+
if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){
625+
errors.push (`Resource "${res.resourceId}" column "${col.name}" has wrong delete strategy, you can use 'setNull' or 'cascade'`);
626+
}
627+
if (col.foreignResource.onDelete === 'setNull' && col.required) {
628+
errors.push(`Resource "${res.resourceId}" column "${col.name}" cannot use onDelete 'setNull' because column is required (non-nullable).`);
629+
}
623630
if (!col.foreignResource.resourceId) {
624631
// resourceId is absent or empty
625632
if (!col.foreignResource.polymorphicResources && !col.foreignResource.polymorphicOn) {

adminforth/modules/restApi.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
Filters,
1616
} from "../types/Back.js";
1717

18+
import {cascadeChildrenDelete} from './utils.js'
19+
1820
import { afLogger } from "./logger.js";
1921

2022
import { ADMINFORTH_VERSION, listify, md5hash, getLoginPromptHTML } from './utils.js';
@@ -126,7 +128,7 @@ export async function interpretResource(
126128
export default class AdminForthRestAPI implements IAdminForthRestAPI {
127129

128130
adminforth: IAdminForth;
129-
131+
130132
constructor(adminforth: IAdminForth) {
131133
this.adminforth = adminforth;
132134
}
@@ -1481,6 +1483,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
14811483
return { error };
14821484
}
14831485

1486+
const { error: cascadeError } = await cascadeChildrenDelete(resource, body.primaryKey, {adminUser, response}, this.adminforth);
1487+
if (cascadeError) {
1488+
return { error: cascadeError };
1489+
}
1490+
14841491
const { error: deleteError } = await this.adminforth.deleteResourceRecord({
14851492
resource, record, adminUser, recordId: body['primaryKey'], response,
14861493
extra: { body, query, headers, cookies, requestUrl, response }

adminforth/modules/utils.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { fileURLToPath } from 'url';
33
import fs from 'fs';
44
import Fuse from 'fuse.js';
55
import crypto from 'crypto';
6-
import AdminForth, { AdminForthConfig, AdminForthResourceColumnInputCommon, Predicate } from '../index.js';
6+
import { AdminForthConfig, AdminForthResource, AdminForthResourceColumnInputCommon,Filters, IAdminForth, Predicate } from '../index.js';
77
import { RateLimiterMemory, RateLimiterAbstract } from "rate-limiter-flexible";
88
// @ts-ignore-next-line
99

@@ -482,3 +482,43 @@ export function slugifyString(str: string): string {
482482
.replace(/\s+/g, '-')
483483
.replace(/[^a-z0-9-_]/g, '-');
484484
}
485+
486+
export async function cascadeChildrenDelete(resource: AdminForthResource, primaryKey: string, context: {adminUser: any, response: any}, adminforth: IAdminForth): Promise<{ error: string | null }> {
487+
const { adminUser, response } = context;
488+
489+
const childResources = adminforth.config.resources.filter(r =>r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId));
490+
491+
for (const childRes of childResources) {
492+
const foreignColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId);
493+
494+
if (!foreignColumn?.foreignResource?.onDelete) continue;
495+
496+
const strategy = foreignColumn.foreignResource.onDelete;
497+
498+
const childRecords = await adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignColumn.name, primaryKey));
499+
500+
const childPk = childRes.columns.find(c => c.primaryKey)?.name;
501+
502+
if (strategy === 'cascade') {
503+
for (const childRecord of childRecords) {
504+
const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth);
505+
if (childResult?.error) {
506+
return childResult;
507+
}
508+
const deleteChild = await adminforth.deleteResourceRecord({resource: childRes, record: childRecord, adminUser, recordId: childRecord[childPk], response});
509+
if (deleteChild.error) return { error: deleteChild.error };
510+
if (childResult?.error) {
511+
return childResult;
512+
}
513+
}
514+
}
515+
516+
if (strategy === 'setNull') {
517+
for (const childRecord of childRecords) {
518+
await adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null});
519+
}
520+
}
521+
}
522+
523+
return { error: null };
524+
}

adminforth/types/Back.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ export interface IAdminForthDataSourceConnector {
192192
*
193193
* @param resource
194194
*/
195-
discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}>;
195+
discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}>;
196196

197197

198198
/**
@@ -2056,6 +2056,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm
20562056
afterDatasourceResponse?: AfterDataSourceResponseFunction | Array<AfterDataSourceResponseFunction>,
20572057
},
20582058
},
2059+
onDelete?: 'cascade' | 'setNull'
20592060
}
20602061

20612062
export type ShowInModernInput = {

0 commit comments

Comments
 (0)