From af2196870a7c5a7ca92c1907a9e96f53cf8543f7 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Fri, 6 Feb 2026 15:01:01 +0100 Subject: [PATCH 1/2] feat(datasource sql/sequelize): add options to add native action to handle paranoid --- .../datasource-sequelize/src/collection.ts | 67 ++++++++++++++++++- .../datasource-sequelize/src/datasource.ts | 10 ++- packages/datasource-sequelize/src/types.ts | 11 +++ packages/datasource-sql/src/index.ts | 1 + packages/datasource-sql/src/types.ts | 11 +++ 5 files changed, 96 insertions(+), 4 deletions(-) diff --git a/packages/datasource-sequelize/src/collection.ts b/packages/datasource-sequelize/src/collection.ts index a2d57e4d4d..ffd1c1f214 100644 --- a/packages/datasource-sequelize/src/collection.ts +++ b/packages/datasource-sequelize/src/collection.ts @@ -1,4 +1,6 @@ +import type { SequelizeDatasourceOptions } from './types'; import type { + ActionResult, AggregateResult, Aggregation, Caller, @@ -17,7 +19,12 @@ import type { Sequelize, } from 'sequelize'; -import { BaseCollection, CollectionUtils, Projection } from '@forestadmin/datasource-toolkit'; +import { + BaseCollection, + CollectionUtils, + Projection, + ValidationError, +} from '@forestadmin/datasource-toolkit'; import { DataTypes, QueryTypes } from 'sequelize'; import AggregationUtils from './utils/aggregation'; @@ -43,6 +50,7 @@ export default class SequelizeCollection extends BaseCollection { // eslint-disable-next-line @typescript-eslint/no-explicit-any model: ModelDefined, logger?: Logger, + options?: SequelizeDatasourceOptions, ) { if (!model) throw new Error('Invalid (null) model instance.'); @@ -89,6 +97,28 @@ export default class SequelizeCollection extends BaseCollection { this.enableCount(); this.addFields(modelSchema.fields); this.addSegments(modelSchema.segments); + + if (this.model.options.paranoid && options?.actions) { + const { hardDelete, restoreSoftDeleted } = options.actions; + if ((Array.isArray(hardDelete) && hardDelete.includes(name)) || hardDelete === true) { + this.addAction('Hard Delete', { + scope: 'Bulk', + description: 'Permanently deletes selected records', + staticForm: true, + }); + } + + if ( + (Array.isArray(restoreSoftDeleted) && restoreSoftDeleted.includes(name)) || + restoreSoftDeleted === true + ) { + this.addAction('Restore Soft Deleted', { + scope: 'Bulk', + description: 'Restores selected records', + staticForm: true, + }); + } + } } async create(caller: Caller, data: RecordData[]): Promise { @@ -226,4 +256,39 @@ export default class SequelizeCollection extends BaseCollection { !aggregationFieldSchema || aggregationFieldSchema?.columnType === 'Number', ); } + + override async execute( + caller: Caller, + name: string, + formValues: RecordData, + filter?: Filter, + ): Promise { + const options = { + where: await this.queryConverter.getWhereFromConditionTreeToByPassInclude( + filter.conditionTree, + ), + }; + + if (name === 'Hard Delete') { + try { + await handleErrors('delete', () => this.model.destroy({ ...options, force: true })); + } catch (error) { + return { message: error.message, type: 'Error' }; + } + + return { message: 'Records permanently deleted', type: 'Success', invalidated: new Set() }; + } + + if (name === 'Restore Soft Deleted') { + try { + await handleErrors('update', () => this.model.restore(options)); + } catch (error) { + return { message: error.message, type: 'Error' }; + } + + return { message: 'Records restored', type: 'Success', invalidated: new Set() }; + } + + return super.execute(caller, name, formValues, filter); + } } diff --git a/packages/datasource-sequelize/src/datasource.ts b/packages/datasource-sequelize/src/datasource.ts index 9e9a642875..0866b552a6 100644 --- a/packages/datasource-sequelize/src/datasource.ts +++ b/packages/datasource-sequelize/src/datasource.ts @@ -28,7 +28,7 @@ export default class SequelizeDataSource extends BaseDataSource (modelA.name > modelB.name ? 1 : -1)) .forEach(model => { - const collection = new SequelizeCollection(model.name, this, model, logger); + const collection = new SequelizeCollection(model.name, this, model, logger, options); this.addCollection(collection); }); } diff --git a/packages/datasource-sequelize/src/types.ts b/packages/datasource-sequelize/src/types.ts index 5179080d62..be741c94fc 100644 --- a/packages/datasource-sequelize/src/types.ts +++ b/packages/datasource-sequelize/src/types.ts @@ -1,3 +1,14 @@ export type SequelizeDatasourceOptions = { liveQueryConnections?: string; + actions?: { + /** + * If true, add an action to restore soft deleted records. + * If an array of strings is provided, add an action to restore soft deleted records only for the specified collections. + */ + restoreSoftDeleted?: boolean | string[]; + /** If true, add an action will permanently delete records. + * If an array of strings is provided, add an action to permanently delete records only for the specified collections. + */ + hardDelete?: boolean | string[]; + }; }; diff --git a/packages/datasource-sql/src/index.ts b/packages/datasource-sql/src/index.ts index 582f43e290..900afac22c 100644 --- a/packages/datasource-sql/src/index.ts +++ b/packages/datasource-sql/src/index.ts @@ -104,6 +104,7 @@ export function createSqlDataSource( return new SqlDatasource( new SequelizeDataSource(sequelize, logger, { liveQueryConnections: options?.liveQueryConnections, + actions: options?.actions, }), latestIntrospection.views, ); diff --git a/packages/datasource-sql/src/types.ts b/packages/datasource-sql/src/types.ts index b2b710d74a..fca2a0ce79 100644 --- a/packages/datasource-sql/src/types.ts +++ b/packages/datasource-sql/src/types.ts @@ -50,4 +50,15 @@ export type SqlDatasourceOptions = { introspection?: SupportedIntrospection; displaySoftDeleted?: string[] | true; liveQueryConnections?: string; + actions?: { + /** + * If true, add an action to restore soft deleted records. + * If an array of strings is provided, add an action to restore soft deleted records only for the specified collections. + */ + restoreSoftDeleted?: boolean | string[]; + /** If true, add an action will permanently delete records. + * If an array of strings is provided, add an action to permanently delete records only for the specified collections. + */ + hardDelete?: boolean | string[]; + }; }; From e90fccbab26433fa0f7bed11c9606307d5498f11 Mon Sep 17 00:00:00 2001 From: Arnaud Moncel Date: Fri, 6 Feb 2026 15:10:06 +0100 Subject: [PATCH 2/2] fix: lint --- packages/datasource-sequelize/src/collection.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/datasource-sequelize/src/collection.ts b/packages/datasource-sequelize/src/collection.ts index ffd1c1f214..d197084cf8 100644 --- a/packages/datasource-sequelize/src/collection.ts +++ b/packages/datasource-sequelize/src/collection.ts @@ -19,12 +19,7 @@ import type { Sequelize, } from 'sequelize'; -import { - BaseCollection, - CollectionUtils, - Projection, - ValidationError, -} from '@forestadmin/datasource-toolkit'; +import { BaseCollection, CollectionUtils, Projection } from '@forestadmin/datasource-toolkit'; import { DataTypes, QueryTypes } from 'sequelize'; import AggregationUtils from './utils/aggregation'; @@ -71,9 +66,9 @@ export default class SequelizeCollection extends BaseCollection { rawQuery: async ( sql: string, replacements: Replacements, - options?: { syntax?: 'bind' | 'replacements' }, + opts?: { syntax?: 'bind' | 'replacements' }, ) => { - const opt = { syntax: 'replacements', ...options }; + const opt = { syntax: 'replacements', ...opts }; const result = await model.sequelize.query(sql, { type: QueryTypes.RAW, plain: false, @@ -100,6 +95,7 @@ export default class SequelizeCollection extends BaseCollection { if (this.model.options.paranoid && options?.actions) { const { hardDelete, restoreSoftDeleted } = options.actions; + if ((Array.isArray(hardDelete) && hardDelete.includes(name)) || hardDelete === true) { this.addAction('Hard Delete', { scope: 'Bulk',