diff --git a/adminforth/dataConnectors/baseConnector.ts b/adminforth/dataConnectors/baseConnector.ts index 9e61957b..fa22dcb6 100644 --- a/adminforth/dataConnectors/baseConnector.ts +++ b/adminforth/dataConnectors/baseConnector.ts @@ -1,7 +1,8 @@ import { AdminForthResource, IAdminForthDataSourceConnectorBase, AdminForthResourceColumn, - IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter + IAdminForthSort, IAdminForthSingleFilter, IAdminForthAndOrFilter, + AdminForthConfig } from "../types/Back.js"; @@ -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.'); } diff --git a/adminforth/dataConnectors/mysql.ts b/adminforth/dataConnectors/mysql.ts index ed97a856..54df749d 100644 --- a/adminforth/dataConnectors/mysql.ts +++ b/adminforth/dataConnectors/mysql.ts @@ -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'; @@ -74,8 +74,40 @@ class MysqlConnector extends AdminForthBaseConnector implements IAdminForthDataS })); } - async discoverFields(resource) { + async hasMySQLCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { + + 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 = {}; diff --git a/adminforth/dataConnectors/postgres.ts b/adminforth/dataConnectors/postgres.ts index 082e3cad..cf37610a 100644 --- a/adminforth/dataConnectors/postgres.ts +++ b/adminforth/dataConnectors/postgres.ts @@ -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'; @@ -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 { + 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(` diff --git a/adminforth/dataConnectors/sqlite.ts b/adminforth/dataConnectors/sqlite.ts index 0846b65b..e3407b29 100644 --- a/adminforth/dataConnectors/sqlite.ts +++ b/adminforth/dataConnectors/sqlite.ts @@ -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'; @@ -37,11 +37,35 @@ class SQLiteConnector extends AdminForthBaseConnector implements IAdminForthData sampleValue: sampleRow[col.name], })); } + + async hasSQLiteCascadeFk(resource: AdminForthResource, config: AdminForthConfig): Promise { + 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 = {}; @@ -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 }); diff --git a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md index 60f6bc83..ef56c7fc 100644 --- a/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md +++ b/adminforth/documentation/docs/tutorial/08-Plugins/03-ForeignInlineList.md @@ -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. \ No newline at end of file +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. diff --git a/adminforth/index.ts b/adminforth/index.ts index 02f052d7..8878f2fd 100644 --- a/adminforth/index.ts +++ b/adminforth/index.ts @@ -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}`); } diff --git a/adminforth/modules/configValidator.ts b/adminforth/modules/configValidator.ts index 4cefe723..9a768015 100644 --- a/adminforth/modules/configValidator.ts +++ b/adminforth/modules/configValidator.ts @@ -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 { @@ -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) => { @@ -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) { diff --git a/adminforth/modules/restApi.ts b/adminforth/modules/restApi.ts index 6b6f2f5d..52b26b60 100644 --- a/adminforth/modules/restApi.ts +++ b/adminforth/modules/restApi.ts @@ -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'; @@ -126,7 +128,7 @@ export async function interpretResource( export default class AdminForthRestAPI implements IAdminForthRestAPI { adminforth: IAdminForth; - + constructor(adminforth: IAdminForth) { this.adminforth = adminforth; } @@ -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 } diff --git a/adminforth/modules/utils.ts b/adminforth/modules/utils.ts index 3deaf2c7..f7f2c27d 100644 --- a/adminforth/modules/utils.ts +++ b/adminforth/modules/utils.ts @@ -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 @@ -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 }; + } \ No newline at end of file diff --git a/adminforth/types/Back.ts b/adminforth/types/Back.ts index ae566074..07c37112 100644 --- a/adminforth/types/Back.ts +++ b/adminforth/types/Back.ts @@ -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}>; /** @@ -2037,6 +2037,7 @@ export interface AdminForthForeignResource extends AdminForthForeignResourceComm afterDatasourceResponse?: AfterDataSourceResponseFunction | Array, }, }, + onDelete?: 'cascade' | 'setNull' } export type ShowInModernInput = {