Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 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
1641242
feat: update discoverFields method to include config parameter across…
Mar 9, 2026
1cbb653
feat: implement cascading deletion checks for MySQL, Postgres, and SQ…
Mar 9, 2026
778a90b
feat: implement checkCascadeWhenUploadPlugin method in base connector…
Mar 9, 2026
ca5bf91
fix: change check for cascade and upload plugin
Mar 10, 2026
66b0377
feat: implement cascading deletion utility for child resources
Mar 10, 2026
1acfe3e
feat: enhance cascading deletion logic to prevent infinite loops and …
Mar 11, 2026
ea5e92f
fix: delete unused check
Mar 11, 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
5 changes: 3 additions & 2 deletions adminforth/dataConnectors/baseConnector.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
AdminForthResource, IAdminForthDataSourceConnectorBase,
AdminForthResourceColumn,
IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter
IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter,
AdminForthConfig
} from "../types/Back.js";


Expand Down Expand Up @@ -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.');
}

Expand Down
36 changes: 34 additions & 2 deletions adminforth/dataConnectors/mysql.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -74,8 +74,40 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS
}));
}

async discoverFields(resource) {
async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {

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 1
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE CONSTRAINT_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND REFERENCED_TABLE_NAME = ?
AND DELETE_RULE = 'CASCADE'
LIMIT 1
`,
[resource.table, parentResource.table]
);

const hasCascadeOnTable = (rows as any[]).length > 0;

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

async discoverFields(resource: AdminForthResource, config: AdminForthConfig) {
const [results] = await this.client.execute("SHOW COLUMNS FROM " + resource.table);
await this.hasMySQLCascadeFk(resource, config);
const fieldTypes = {};
results.forEach((row) => {
const field: any = {};
Expand Down
35 changes: 32 additions & 3 deletions adminforth/dataConnectors/postgres.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -68,8 +68,37 @@ 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] }));
}

async discoverFields(resource) {

async checkForeignResourceCascade(resource: AdminForthResource, config: AdminForthConfig, schema = 'public'): Promise<void> {
const cascadeColumn = resource.columns.find(c => c.foreignResource?.onDelete === 'cascade');
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
FROM pg_constraint
WHERE contype = 'f'
AND confrelid = ($2 || '.' || $1)::regclass
AND conrelid = ($2 || '.' || $3)::regclass
AND confdeltype = 'c'
LIMIT 1
`,
[parentResource.table, schema, resource.table ]
);

const hasCascadeOnTable = res.rowCount > 0;
const isUploadPluginInstalled = resource.plugins?.some(p => p.className === "UploadPlugin");

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

const tableName = resource.table;
const stmt = await this.client.query(`
Expand Down
31 changes: 28 additions & 3 deletions adminforth/dataConnectors/sqlite.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -37,11 +37,35 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
sampleValue: sampleRow[col.name],
}));
}

async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise<boolean> {
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 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'; });

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

async discoverFields(resource: AdminForthResource, config: AdminForthConfig): Promise<{[key: string]: AdminForthResourceColumn}> {

async discoverFields(resource: AdminForthResource): Promise<{[key: string]: AdminForthResourceColumn}> {
const tableName = resource.table;
const stmt = this.client.prepare(`PRAGMA table_info(${tableName})`);
const rows = await stmt.all();
const rows = await stmt.all();
await this.hasSQLiteCascadeFk(resource, config);
const fieldTypes = {};
rows.forEach((row) => {
const field: any = {};
Expand Down Expand Up @@ -86,6 +110,7 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData
field._baseTypeDebug = baseType;
field.required = row.notnull == 1;
field.primaryKey = row.pk == 1;

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.
2 changes: 1 addition & 1 deletion adminforth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down
11 changes: 9 additions & 2 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 {cascadeChildrenDelete} from './utils.js'

export default class ConfigValidator implements IConfigValidator {

Expand Down Expand Up @@ -282,8 +282,9 @@ export default class ConfigValidator implements IConfigValidator {
return;
}

await cascadeChildrenDelete(res as AdminForthResource, recordId, { adminUser, response}, this.adminforth);
await connector.deleteRecord({ resource: res as AdminForthResource, recordId });
// call afterDelete hook

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

adminforth: IAdminForth;

constructor(adminforth: IAdminForth) {
this.adminforth = adminforth;
}
Expand Down Expand Up @@ -1481,6 +1483,11 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI {
return { error };
}

const { error: cascadeError } = await cascadeChildrenDelete(resource, body.primaryKey, {adminUser, response}, this.adminforth);
if (cascadeError) {
return { error: cascadeError };
}

const { error: deleteError } = await this.adminforth.deleteResourceRecord({
resource, record, adminUser, recordId: body['primaryKey'], response,
extra: { body, query, headers, cookies, requestUrl, response }
Expand Down
42 changes: 41 additions & 1 deletion adminforth/modules/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -479,3 +479,43 @@ 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 (strategy === 'cascade') {
for (const childRecord of childRecords) {
const childResult = await cascadeChildrenDelete(childRes, childRecord[childPk], context, adminforth);
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) {
return childResult;
}
}
}

if (strategy === 'setNull') {
for (const childRecord of childRecords) {
await adminforth.resource(childRes.resourceId).update(childRecord[childPk], {[foreignColumn.name]: null});
}
}
}

return { error: null };
}
3 changes: 2 additions & 1 deletion adminforth/types/Back.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}>;


/**
Expand Down Expand Up @@ -2037,6 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm
afterDatasourceResponse?: AfterDataSourceResponseFunction | Array<AfterDataSourceResponseFunction>,
},
},
onDelete?: 'cascade' | 'setNull'
}

export type ShowInModernInput = {
Expand Down