From 2d2639c4eb506fab4db54a94778e3ee9196180e2 Mon Sep 17 00:00:00 2001 From: Arnaud MONCEL Date: Wed, 20 Nov 2024 09:27:18 +0100 Subject: [PATCH] feat(datasource sql): add option to see paranoid (#1210) --- packages/datasource-sql/src/index.ts | 13 ++- .../datasource-sql/src/orm-builder/model.ts | 16 +++- packages/datasource-sql/src/types.ts | 7 ++ .../test/_helpers/setup-soft-deleted.ts | 44 ++++++++++ .../datasource-factory.integration.test.ts | 86 ++++++++++++++++++- 5 files changed, 161 insertions(+), 5 deletions(-) create mode 100644 packages/datasource-sql/test/_helpers/setup-soft-deleted.ts diff --git a/packages/datasource-sql/src/index.ts b/packages/datasource-sql/src/index.ts index cf1acc65f0..a7dc2708e8 100644 --- a/packages/datasource-sql/src/index.ts +++ b/packages/datasource-sql/src/index.ts @@ -1,4 +1,9 @@ -import type { PlainConnectionOptions, PlainConnectionOptionsOrUri, SslMode } from './types'; +import type { + PlainConnectionOptions, + PlainConnectionOptionsOrUri, + SqlDatasourceOptions, + SslMode, +} from './types'; import type { DataSourceFactory, Logger } from '@forestadmin/datasource-toolkit'; import { SequelizeDataSource } from '@forestadmin/datasource-sequelize'; @@ -39,6 +44,7 @@ async function buildModelsAndRelations( sequelize: Sequelize, logger: Logger, introspection: SupportedIntrospection, + displaySoftDeleted?: SqlDatasourceOptions['displaySoftDeleted'], ): Promise { try { const latestIntrospection = await Introspector.migrateOrIntrospect( @@ -46,7 +52,7 @@ async function buildModelsAndRelations( logger, introspection, ); - ModelBuilder.defineModels(sequelize, logger, latestIntrospection); + ModelBuilder.defineModels(sequelize, logger, latestIntrospection, displaySoftDeleted); RelationBuilder.defineRelations(sequelize, logger, latestIntrospection); return latestIntrospection; @@ -84,7 +90,7 @@ export async function buildSequelizeInstance( export function createSqlDataSource( uriOrOptions: PlainConnectionOptionsOrUri, - options?: { introspection?: SupportedIntrospection }, + options?: SqlDatasourceOptions, ): DataSourceFactory { return async (logger: Logger) => { const sequelize = await connect(new ConnectionOptions(uriOrOptions, logger)); @@ -92,6 +98,7 @@ export function createSqlDataSource( sequelize, logger, options?.introspection, + options?.displaySoftDeleted, ); return new SqlDatasource(new SequelizeDataSource(sequelize, logger), latestIntrospection.views); diff --git a/packages/datasource-sql/src/orm-builder/model.ts b/packages/datasource-sql/src/orm-builder/model.ts index f20dc66e2d..a987fac9ef 100644 --- a/packages/datasource-sql/src/orm-builder/model.ts +++ b/packages/datasource-sql/src/orm-builder/model.ts @@ -5,6 +5,7 @@ import { Literal } from 'sequelize/types/utils'; import SequelizeTypeFactory from './helpers/sequelize-type'; import { LatestIntrospection, Table } from '../introspection/types'; +import { SqlDatasourceOptions } from '../types'; type TableOrView = Table & { view?: boolean }; @@ -24,9 +25,15 @@ export default class ModelBuilder { sequelize: Sequelize, logger: Logger, introspection: LatestIntrospection, + displaySoftDeleted?: SqlDatasourceOptions['displaySoftDeleted'], ): void { for (const table of introspection.tables) { - this.defineModelFromTable(sequelize, logger, table); + const shouldDisplaySoftDeleted = + displaySoftDeleted === true || + (Array.isArray(displaySoftDeleted) && + displaySoftDeleted.some(tableName => table.name === tableName)); + + this.defineModelFromTable(sequelize, logger, table, shouldDisplaySoftDeleted); } for (const table of introspection.views) { @@ -38,6 +45,7 @@ export default class ModelBuilder { sequelize: Sequelize, logger: Logger, table: TableOrView, + displaySoftDeleted?: boolean, ): void { const hasTimestamps = this.hasTimestamps(table); const isParanoid = this.isParanoid(table); @@ -53,6 +61,12 @@ export default class ModelBuilder { ...this.getAutoTimestampFieldsOverride(table), }); + if (displaySoftDeleted) { + model.beforeFind(option => { + option.paranoid = false; + }); + } + // @see https://sequelize.org/docs/v6/other-topics/legacy/#primary-keys // Tell sequelize NOT to invent primary keys when we don't provide them. // (Note that this does not seem to work) diff --git a/packages/datasource-sql/src/types.ts b/packages/datasource-sql/src/types.ts index 37351be189..ed6a4beb43 100644 --- a/packages/datasource-sql/src/types.ts +++ b/packages/datasource-sql/src/types.ts @@ -1,6 +1,8 @@ import { Options } from 'sequelize/types'; import { ConnectConfig } from 'ssh2'; +import { SupportedIntrospection } from './introspection/types'; + type SupportedSequelizeOptions = Pick< Options, | 'database' @@ -44,3 +46,8 @@ export type PlainConnectionOptions = SupportedSequelizeOptions & { export type PlainConnectionOptionsOrUri = PlainConnectionOptions | string; export type SslMode = 'preferred' | 'disabled' | 'required' | 'verify' | 'manual'; + +export type SqlDatasourceOptions = { + introspection?: SupportedIntrospection; + displaySoftDeleted?: string[] | true; +}; diff --git a/packages/datasource-sql/test/_helpers/setup-soft-deleted.ts b/packages/datasource-sql/test/_helpers/setup-soft-deleted.ts new file mode 100644 index 0000000000..8c08b1cb61 --- /dev/null +++ b/packages/datasource-sql/test/_helpers/setup-soft-deleted.ts @@ -0,0 +1,44 @@ +import { DataTypes, Sequelize } from 'sequelize'; + +import { ConnectionDetails } from './connection-details'; +import setupEmptyDatabase from './setup-empty-database'; + +export default async function setupSoftDeleted( + connectionDetails: ConnectionDetails, + database: string, + schema?: string, +): Promise { + let sequelize: Sequelize | null = null; + + try { + const optionalSchemaOption = schema ? { schema } : {}; + + sequelize = await setupEmptyDatabase(connectionDetails, database, optionalSchemaOption); + + if (schema) { + await sequelize.getQueryInterface().dropSchema(schema); + await sequelize.getQueryInterface().createSchema(schema); + } + + sequelize.define( + 'softDeleted', + { name: DataTypes.STRING }, + { tableName: 'softDeleted', ...optionalSchemaOption, timestamps: true, paranoid: true }, + ); + + sequelize.define( + 'softDeleted2', + { name: DataTypes.STRING }, + { tableName: 'softDeleted2', ...optionalSchemaOption, timestamps: true, paranoid: true }, + ); + + await sequelize.sync({ force: true, ...optionalSchemaOption }); + + return sequelize; + } catch (e) { + console.error('Error', e); + throw e; + } finally { + await sequelize?.close(); + } +} diff --git a/packages/datasource-sql/test/datasource/datasource-factory.integration.test.ts b/packages/datasource-sql/test/datasource/datasource-factory.integration.test.ts index 775c31959a..a78b5dd4a0 100644 --- a/packages/datasource-sql/test/datasource/datasource-factory.integration.test.ts +++ b/packages/datasource-sql/test/datasource/datasource-factory.integration.test.ts @@ -1,12 +1,16 @@ +import { SequelizeDataSource } from '@forestadmin/datasource-sequelize'; +import { Projection } from '@forestadmin/datasource-toolkit'; +import { caller, filter } from '@forestadmin/datasource-toolkit/dist/test/__factories__'; import { stringify } from 'querystring'; import { DataTypes, Dialect, Model, ModelStatic, Op, Sequelize } from 'sequelize'; -import { buildSequelizeInstance, introspect } from '../../src'; +import { buildSequelizeInstance, createSqlDataSource, introspect } from '../../src'; import Introspector from '../../src/introspection/introspector'; import { CONNECTION_DETAILS } from '../_helpers/connection-details'; import setupEmptyDatabase from '../_helpers/setup-empty-database'; import setupDatabaseWithIdNotPrimary from '../_helpers/setup-id-is-not-a-pk'; import setupSimpleTable from '../_helpers/setup-simple-table'; +import setupSoftDeleted from '../_helpers/setup-soft-deleted'; import setupDatabaseWithTypes, { getAttributeMapping } from '../_helpers/setup-using-all-types'; import setupDatabaseWithRelations, { RELATION_MAPPING } from '../_helpers/setup-using-relations'; @@ -439,6 +443,86 @@ describe('SqlDataSourceFactory > Integration', () => { expect(modelAssociations).toMatchObject(RELATION_MAPPING); }); }); + + describe('with soft deleted record', () => { + const databaseName = 'datasource-sql-softdeleted-test'; + + describe('when display soft deleted only on one table', () => { + it('should only display records of that table', async () => { + const logger = jest.fn(); + await setupSoftDeleted(connectionDetails, databaseName, schema); + + const sqlDs = await createSqlDataSource( + `${connectionDetails.url(databaseName)}?${queryString}`, + { displaySoftDeleted: ['softDeleted'] }, + )(logger); + + const collection = sqlDs.getCollection('softDeleted'); + const collection2 = sqlDs.getCollection('softDeleted2'); + + await collection.create(caller.build(), [ + { name: 'shouldDisplay', deletedAt: Date.now() }, + ]); + await collection2.create(caller.build(), [ + { name: 'shouldNotDisplay', deletedAt: Date.now() }, + ]); + + const records = await collection.list( + caller.build(), + filter.build(), + new Projection('name', 'deletedAt'), + ); + const records2 = await collection2.list( + caller.build(), + filter.build(), + new Projection('name', 'deletedAt'), + ); + + await (sqlDs as SequelizeDataSource).close(); + + expect(records).toHaveLength(1); + expect(records2).toHaveLength(0); + }); + }); + + describe('when display soft deleted for all tables', () => { + it('should display records on all tables', async () => { + const logger = jest.fn(); + await setupSoftDeleted(connectionDetails, databaseName, schema); + + const sqlDs = await createSqlDataSource( + `${connectionDetails.url(databaseName)}?${queryString}`, + { displaySoftDeleted: true }, + )(logger); + + const collection = sqlDs.getCollection('softDeleted'); + const collection2 = sqlDs.getCollection('softDeleted2'); + + await collection.create(caller.build(), [ + { name: 'shouldDisplay', deletedAt: Date.now() }, + ]); + await collection2.create(caller.build(), [ + { name: 'shouldNotDisplay', deletedAt: Date.now() }, + ]); + + const records = await collection.list( + caller.build(), + filter.build(), + new Projection('name', 'deletedAt'), + ); + const records2 = await collection2.list( + caller.build(), + filter.build(), + new Projection('name', 'deletedAt'), + ); + + await (sqlDs as SequelizeDataSource).close(); + + expect(records).toHaveLength(1); + expect(records2).toHaveLength(1); + }); + }); + }); }); });