Skip to content
Merged
Show file tree
Hide file tree
Changes from 21 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…
kulikp1 Feb 25, 2026
bda166a
add alowwedAction check
kulikp1 Feb 26, 2026
12d2ba6
add missing spaces
kulikp1 Feb 26, 2026
02e216b
feat: implement cascading deletion logic in delete endpoint
kulikp1 Feb 26, 2026
23d178b
fix: update check strategy
kulikp1 Feb 26, 2026
8b5b7b5
feat: refine cascading deletion logic in delete endpoint
kulikp1 Feb 26, 2026
01dfcfd
fix: update condition
kulikp1 Feb 26, 2026
ee04911
fix: change variable name foreignKeyColumn to foreignResourceColumn
kulikp1 Feb 26, 2026
96c2c8f
fix: add check for foreign resource onDelete strategy
kulikp1 Feb 27, 2026
a300f83
feat: add onDelete type
kulikp1 Feb 27, 2026
8ef2973
fix: delete strategy check
kulikp1 Feb 27, 2026
552ecdc
fix: add check for cascade strategy
kulikp1 Feb 27, 2026
423d6a0
fix: delete mistake in error message
kulikp1 Mar 2, 2026
ff63b6c
fix: streamline foreign resource onDelete strategy validation
kulikp1 Mar 2, 2026
9520f80
add missing space
kulikp1 Mar 2, 2026
d6502d3
fix: implement cascading deletion checks for MySQL, PostgreSQL, and S…
kulikp1 Mar 2, 2026
1843641
fix: resolve copilot comment
kulikp1 Mar 3, 2026
21cc9a4
fix: add required check for setNull deletion
kulikp1 Mar 3, 2026
81fce83
fix: change resource.options.allowedActions.delete check
kulikp1 Mar 3, 2026
ec17bd5
feat: implement cascading deletion logic in AdminForthRestAPI
kulikp1 Mar 4, 2026
a48d0a7
fix: delete unused console.log
kulikp1 Mar 4, 2026
c3a7a73
fix: delete unused arguments from function
kulikp1 Mar 4, 2026
b4d8aa1
fix: resolve copilot comment
kulikp1 Mar 4, 2026
604c0b3
docs: add documentation for cascade deletion
kulikp1 Mar 4, 2026
b3d3f24
fix: resolve comment
kulikp1 Mar 5, 2026
25c0007
fix: resolve copilot comment
kulikp1 Mar 5, 2026
2ad14c6
fix: add errror message
kulikp1 Mar 5, 2026
2ed72a3
fix: update errors copilot comment
kulikp1 Mar 5, 2026
aa3b223
fix: update query for mysql
kulikp1 Mar 5, 2026
067b87c
fix: change query for check pg database cascade
kulikp1 Mar 5, 2026
058283c
fix: cange requests for check cascade
kulikp1 Mar 5, 2026
b0acb77
fix: change query for check cascade
kulikp1 Mar 5, 2026
0c28370
style: add missing alignment
kulikp1 Mar 6, 2026
17ac6d4
style: add missing alignment
kulikp1 Mar 6, 2026
1641242
feat: update discoverFields method to include config parameter across…
kulikp1 Mar 9, 2026
1cbb653
feat: implement cascading deletion checks for MySQL, Postgres, and SQ…
kulikp1 Mar 9, 2026
778a90b
feat: implement checkCascadeWhenUploadPlugin method in base connector…
kulikp1 Mar 9, 2026
ca5bf91
fix: change check for cascade and upload plugin
kulikp1 Mar 10, 2026
66b0377
feat: implement cascading deletion utility for child resources
kulikp1 Mar 10, 2026
1acfe3e
feat: enhance cascading deletion logic to prevent infinite loops and …
kulikp1 Mar 11, 2026
ea5e92f
fix: delete unused check
kulikp1 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
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 thread
kulikp1 marked this conversation as resolved.
Outdated
}
Comment thread
kulikp1 marked this conversation as resolved.
Outdated
Comment thread
kulikp1 marked this conversation as resolved.
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;
Comment thread
kulikp1 marked this conversation as resolved.
Outdated
}
Comment thread
kulikp1 marked this conversation as resolved.

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.`);
}
Comment thread
kulikp1 marked this conversation as resolved.
Outdated
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 thread
kulikp1 marked this conversation as resolved.
Outdated
});
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
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';
Comment thread
kulikp1 marked this conversation as resolved.
Outdated

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)
restApi.deleteWithCascade(res as AdminForthResource, recordId, { adminUser, response, body: undefined, query: undefined, headers: undefined, cookies: undefined, requestUrl: undefined});
Comment thread
kulikp1 marked this conversation as resolved.
Outdated

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')){
Comment thread
SerVitasik marked this conversation as resolved.
errors.push (`Resource "${res.resourceId}" column "${col.name}" has wrong delete strategy, you can use 'setNull' or 'cascade'`);
}
Comment thread
kulikp1 marked this conversation as resolved.
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
39 changes: 39 additions & 0 deletions adminforth/modules/restApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export async function interpretResource(
export default class AdminForthRestAPI implements IAdminForthRestAPI {

adminforth: IAdminForth;
static deleteWithCascade: any;
Comment thread
kulikp1 marked this conversation as resolved.
Outdated

constructor(adminforth: IAdminForth) {
this.adminforth = adminforth;
Expand All @@ -152,6 +153,42 @@ 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}) {
Comment thread
kulikp1 marked this conversation as resolved.
Outdated
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, extra: context});
}

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

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,
Comment thread
kulikp1 marked this conversation as resolved.
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