diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index ed97a856..40a5949c 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -76,6 +76,28 @@ 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 = {}; + 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.`); } + } const fieldTypes = {}; results.forEach((row) => { const field: any = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 082e3cad..925f7571 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -69,9 +69,51 @@ 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> { + 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 = {}; + + 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); + const cascadeWarningShownMap: Record = {}; + if (hasCascade && !cascadeWarningShownMap[tableName]) { + 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, diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 0846b65b..73486772 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -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 = {}; @@ -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 }); diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 4cefe723..5b585ee4 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -618,8 +618,11 @@ export default class ConfigValidator implements IConfigValidator { errors.push(`Resource "${res.resourceId}" column "${col.name}" isArray is enabled but suggestOnCreate is not an array`); } } - + if (col.foreignResource) { + if (col.foreignResource.onDelete && (col.foreignResource.onDelete !== 'cascade' && col.foreignResource.onDelete !== 'setNull')){ + errors.push (`Wrong delete strategy you can use 'setNull' or 'cascade'`); + } if (!col.foreignResource.resourceId) { // resourceId is absent or empty if (!col.foreignResource.polymorphicResources && !col.foreignResource.polymorphicOn) { diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 6b6f2f5d..4d757348 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -1464,8 +1464,26 @@ export default class AdminForthRestAPI implements IAdminForthRestAPI { if (!record){ return { error: `Record with ${body['primaryKey']} not found` }; } - if (resource.options.allowedActions.delete === false) { + if (!resource.options.allowedActions.delete) { return { error: `Resource '${resource.resourceId}' does not allow delete action` }; + } + const childResources = this.adminforth.config.resources.filter(r => r.columns.some(c => c.foreignResource?.resourceId === resource.resourceId)); + if (childResources.length){ + for (const childRes of childResources) { + const foreignResourceColumn = childRes.columns.find(c => c.foreignResource?.resourceId === resource.resourceId); + if (!foreignResourceColumn.foreignResource.onDelete) continue; + const onDeleteStrategy = foreignResourceColumn.foreignResource.onDelete; + const childRecords = await this.adminforth.resource(childRes.resourceId).list(Filters.EQ(foreignResourceColumn.name, body['primaryKey'])) + if (onDeleteStrategy === 'cascade') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).delete(childRecord.id); + } + } else if (onDeleteStrategy === 'setNull') { + for (const childRecord of childRecords) { + await this.adminforth.resource(childRes.resourceId).update(childRecord.id, {[foreignResourceColumn.name]: null}); + } + } + } } const { allowedActions } = await interpretResource( diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index ae566074..5631d5c4 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -2037,6 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm afterDatasourceResponse?: AfterDataSourceResponseFunction | Array, }, }, + onDelete: 'cascade' | 'setNull' } export type ShowInModernInput = {