diff --git a/api/apps/api/src/migrations/api/1628585506694-ScenarioSpecification.ts b/api/apps/api/src/migrations/api/1628585506694-ScenarioSpecification.ts new file mode 100644 index 0000000000..9a52755178 --- /dev/null +++ b/api/apps/api/src/migrations/api/1628585506694-ScenarioSpecification.ts @@ -0,0 +1,47 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ScenarioSpecification1628585506694 implements MigrationInterface { + name = 'ScenarioSpecification1628585506694'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "specification" ("id" uuid NOT NULL, "scenario_id" uuid NOT NULL, "draft" boolean NOT NULL, CONSTRAINT "PK_01b2d90197e187e3187b2d888be" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TABLE "specification_feature" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "specification_feature_config_id" uuid NOT NULL, "feature_id" uuid NOT NULL, "calculated" boolean NOT NULL, CONSTRAINT "PK_fe6096ea6f25997b62b8a5c5f99" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE TYPE "specification_feature_config_operation_enum" AS ENUM('split', 'stratification', 'copy')`, + ); + await queryRunner.query( + `CREATE TABLE "specification_feature_config" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "specification_id" uuid NOT NULL, "base_feature_id" uuid NOT NULL, "against_feature_id" uuid, "operation" "specification_feature_config_operation_enum" NOT NULL, "features_determined" boolean NOT NULL, CONSTRAINT "PK_df1e25cf3972f83b1ff5bcb0337" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `ALTER TABLE "specification" ADD CONSTRAINT "FK_2cecab1b81e5fa64f2fdbbaec76" FOREIGN KEY ("scenario_id") REFERENCES "scenarios"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "specification_feature" ADD CONSTRAINT "FK_ba3687896f22d3f57b3b570368e" FOREIGN KEY ("specification_feature_config_id") REFERENCES "specification_feature_config"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "specification_feature_config" ADD CONSTRAINT "FK_1f1f9830fbe8a94ffd5c86d0bca" FOREIGN KEY ("specification_id") REFERENCES "specification"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "specification_feature_config" DROP CONSTRAINT "FK_1f1f9830fbe8a94ffd5c86d0bca"`, + ); + await queryRunner.query( + `ALTER TABLE "specification_feature" DROP CONSTRAINT "FK_ba3687896f22d3f57b3b570368e"`, + ); + await queryRunner.query( + `ALTER TABLE "specification" DROP CONSTRAINT "FK_2cecab1b81e5fa64f2fdbbaec76"`, + ); + await queryRunner.query(`DROP TABLE "specification_feature_config"`); + await queryRunner.query( + `DROP TYPE "specification_feature_config_operation_enum"`, + ); + await queryRunner.query(`DROP TABLE "specification_feature"`); + await queryRunner.query(`DROP TABLE "specification"`); + } +} diff --git a/api/apps/api/src/modules/specification/adapters/specification-adapters.module.ts b/api/apps/api/src/modules/specification/adapters/specification-adapters.module.ts new file mode 100644 index 0000000000..3e42d21f19 --- /dev/null +++ b/api/apps/api/src/modules/specification/adapters/specification-adapters.module.ts @@ -0,0 +1,27 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { DbSpecificationRepository } from './specification.repository'; +import { SpecificationApiEntity } from './specification.api.entity'; +import { SpecificationFeatureConfigApiEntity } from './specification-feature-config.api.entity'; +import { SpecificationFeatureApiEntity } from './specification-feature.api.entity'; + +import { SpecificationRepository } from '../application/specification.repository'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SpecificationApiEntity, + SpecificationFeatureConfigApiEntity, + SpecificationFeatureApiEntity, + ]), + ], + providers: [ + { + provide: SpecificationRepository, + useClass: DbSpecificationRepository, + }, + ], + exports: [SpecificationRepository], +}) +export class SpecificationAdaptersModule {} diff --git a/api/apps/api/src/modules/specification/adapters/specification-feature-config.api.entity.ts b/api/apps/api/src/modules/specification/adapters/specification-feature-config.api.entity.ts new file mode 100644 index 0000000000..923b00b2f4 --- /dev/null +++ b/api/apps/api/src/modules/specification/adapters/specification-feature-config.api.entity.ts @@ -0,0 +1,67 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { SpecificationApiEntity } from './specification.api.entity'; +import { SpecificationOperation } from '../domain'; +import { SpecificationFeatureApiEntity } from '@marxan-api/modules/specification/adapters/specification-feature.api.entity'; + +@Entity(`specification_feature_config`) +export class SpecificationFeatureConfigApiEntity { + @PrimaryGeneratedColumn(`uuid`) + id!: string; + + @ManyToOne(() => SpecificationApiEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'specification_id', + referencedColumnName: 'id', + }) + specification?: SpecificationApiEntity; + + @Column({ + type: `uuid`, + name: `specification_id`, + }) + specificationId!: string; + + @OneToMany( + () => SpecificationFeatureApiEntity, + (specificationFeature) => specificationFeature.specificationFeatureConfig, + { + cascade: true, + eager: true, + }, + ) + features?: SpecificationFeatureApiEntity[]; + + @Column({ + type: `uuid`, + name: `base_feature_id`, + }) + baseFeatureId!: string; + + @Column({ + type: `uuid`, + name: `against_feature_id`, + nullable: true, + }) + againstFeatureId?: string | null; + + @Column({ + type: `enum`, + enum: SpecificationOperation, + }) + operation!: SpecificationOperation; + + @Column({ + type: `boolean`, + name: `features_determined`, + }) + featuresDetermined!: boolean; +} diff --git a/api/apps/api/src/modules/specification/adapters/specification-feature.api.entity.ts b/api/apps/api/src/modules/specification/adapters/specification-feature.api.entity.ts new file mode 100644 index 0000000000..04c36b7021 --- /dev/null +++ b/api/apps/api/src/modules/specification/adapters/specification-feature.api.entity.ts @@ -0,0 +1,40 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, +} from 'typeorm'; +import { SpecificationFeatureConfigApiEntity } from './specification-feature-config.api.entity'; + +@Entity(`specification_feature`) +export class SpecificationFeatureApiEntity { + @PrimaryGeneratedColumn(`uuid`) + id!: string; + + @ManyToOne(() => SpecificationFeatureConfigApiEntity, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'specification_feature_config_id', + referencedColumnName: 'id', + }) + specificationFeatureConfig?: SpecificationFeatureConfigApiEntity; + + @Column({ + type: `uuid`, + name: `specification_feature_config_id`, + }) + specificationFeatureConfigId!: string; + + @Column({ + type: `uuid`, + name: `feature_id`, + }) + featureId!: string; + + @Column({ + type: `boolean`, + }) + calculated!: boolean; +} diff --git a/api/apps/api/src/modules/specification/adapters/specification.api.entity.ts b/api/apps/api/src/modules/specification/adapters/specification.api.entity.ts new file mode 100644 index 0000000000..fea46798bb --- /dev/null +++ b/api/apps/api/src/modules/specification/adapters/specification.api.entity.ts @@ -0,0 +1,48 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryColumn, +} from 'typeorm'; +import { Scenario } from '@marxan-api/modules/scenarios/scenario.api.entity'; +import { SpecificationFeatureConfigApiEntity } from './specification-feature-config.api.entity'; + +@Entity(`specification`) +export class SpecificationApiEntity { + @PrimaryColumn({ + type: `uuid`, + }) + id!: string; + + @OneToMany( + () => SpecificationFeatureConfigApiEntity, + (specificationFeaturesConfig) => specificationFeaturesConfig.specification, + { + cascade: true, + eager: true, + }, + ) + specificationFeaturesConfiguration?: SpecificationFeatureConfigApiEntity[]; + + @ManyToOne(() => Scenario, { + onDelete: 'CASCADE', + }) + @JoinColumn({ + name: 'scenario_id', + referencedColumnName: 'id', + }) + scenario?: Scenario; + + @Column({ + type: `uuid`, + name: `scenario_id`, + }) + scenarioId!: string; + + @Column({ + type: `boolean`, + }) + draft!: boolean; +} diff --git a/api/apps/api/src/modules/specification/adapters/specification.repository.ts b/api/apps/api/src/modules/specification/adapters/specification.repository.ts new file mode 100644 index 0000000000..1e0df93676 --- /dev/null +++ b/api/apps/api/src/modules/specification/adapters/specification.repository.ts @@ -0,0 +1,178 @@ +import { Injectable } from '@nestjs/common'; +import { InjectEntityManager, InjectRepository } from '@nestjs/typeorm'; +import { EntityManager, Repository } from 'typeorm'; + +import { DbConnections } from '@marxan-api/ormconfig.connections'; + +import { FeatureConfigInput, Specification } from '../domain'; +import { SpecificationRepository } from '../application/specification.repository'; + +import { SpecificationApiEntity } from './specification.api.entity'; +import { SpecificationFeatureConfigApiEntity } from './specification-feature-config.api.entity'; +import { SpecificationFeatureApiEntity } from './specification-feature.api.entity'; + +@Injectable() +export class DbSpecificationRepository implements SpecificationRepository { + constructor( + @InjectRepository(SpecificationApiEntity) + private readonly specificationRepo: Repository, + @InjectRepository(SpecificationFeatureConfigApiEntity) + private readonly specificationFeatureConfigRepo: Repository, + @InjectRepository(SpecificationFeatureApiEntity) + private readonly specificationFeatureRepo: Repository, + @InjectEntityManager(DbConnections.default) + private readonly entityManager: EntityManager, + ) {} + + async findAllRelatedToFeatureConfig( + configuration: FeatureConfigInput, + ): Promise { + // seems like nested where does not fully work + // https://github.com/typeorm/typeorm/issues/2707 + let builder = this.specificationRepo + .createQueryBuilder('spec') + .select('spec.id') + .leftJoin( + 'specification_feature_config', + 'config', + 'config.specification_id = spec.id', + ) + .where(`config.base_feature_id = :baseFeatureId`, { + baseFeatureId: configuration.baseFeatureId, + }) + .andWhere(`config.operation = :operation`, { + operation: configuration.operation, + }); + + if (configuration.againstFeatureId) { + builder = builder.andWhere( + `config.against_feature_id = :againstFeatureId`, + { + againstFeatureId: configuration.againstFeatureId, + }, + ); + } else { + builder.andWhere(`config.against_feature_id is null`); + } + const specIds = await builder.getRawMany<{ + spec_id: string; + }>(); + const specifications = await this.specificationRepo.findByIds( + specIds.map((row) => row.spec_id), + ); + + return specifications.map((specification) => + this.#serialize(specification), + ); + } + + async findAllRelatedToFeatures(features: string[]): Promise { + const specIds = await this.specificationRepo + .createQueryBuilder('spec') + .select('spec.id') + .leftJoin( + `specification_feature_config`, + `config`, + `config.specification_id = spec.id`, + ) + .leftJoin( + `specification_feature`, + `features`, + `config.id = features.specification_feature_config_id`, + ) + .where(`features.feature_id::text IN(:featureIds)`, { + featureIds: features.join(','), + }) + .getRawMany<{ + spec_id: string; + }>(); + + const specifications = await this.specificationRepo.findByIds( + specIds.map((row) => row.spec_id), + ); + + return specifications.map((specification) => + this.#serialize(specification), + ); + } + + async getById(id: string): Promise { + const specification = await this.specificationRepo.findOne({ + where: { + id, + }, + loadEagerRelations: true, + }); + + if (specification) { + return this.#serialize(specification); + } + return; + } + + async save(specification: Specification): Promise { + const snapshot = specification.toSnapshot(); + await this.specificationRepo.save( + this.specificationRepo.create({ + id: snapshot.id, + draft: snapshot.draft, + scenarioId: snapshot.scenarioId, + specificationFeaturesConfiguration: this.specificationFeatureConfigRepo.create( + snapshot.config.map((configuration) => ({ + againstFeatureId: configuration.againstFeatureId, + baseFeatureId: configuration.baseFeatureId, + operation: configuration.operation, + features: configuration.resultFeatures.map((feature) => + this.specificationFeatureRepo.create({ + calculated: feature.calculated, + featureId: feature.id, + }), + ), + featuresDetermined: configuration.featuresDetermined, + specificationId: snapshot.id, + })), + ), + }), + ); + return; + } + + transaction( + code: (repo: SpecificationRepository) => Promise, + ): Promise { + return this.entityManager.transaction((transactionEntityManager) => { + const transactionalRepository = new DbSpecificationRepository( + transactionEntityManager.getRepository(SpecificationApiEntity), + transactionEntityManager.getRepository( + SpecificationFeatureConfigApiEntity, + ), + transactionEntityManager.getRepository(SpecificationFeatureApiEntity), + transactionEntityManager, + ); + return code(transactionalRepository); + }); + } + + #serialize = (specification: SpecificationApiEntity): Specification => { + return Specification.from({ + id: specification.id, + draft: specification.draft, + scenarioId: specification.scenarioId, + config: + specification.specificationFeaturesConfiguration?.map( + (specificationFeature) => ({ + againstFeatureId: + specificationFeature.againstFeatureId ?? undefined, + baseFeatureId: specificationFeature.baseFeatureId, + operation: specificationFeature.operation, + featuresDetermined: specificationFeature.featuresDetermined, + resultFeatures: + specificationFeature.features?.map((feature) => ({ + id: feature.featureId, + calculated: feature.calculated, + })) ?? [], + }), + ) ?? [], + }); + }; +} diff --git a/api/apps/api/src/modules/specification/application/specification.repository.ts b/api/apps/api/src/modules/specification/application/specification.repository.ts index c8c5f2f90c..8b8e7eb925 100644 --- a/api/apps/api/src/modules/specification/application/specification.repository.ts +++ b/api/apps/api/src/modules/specification/application/specification.repository.ts @@ -10,7 +10,7 @@ export abstract class SpecificationRepository { ): Promise; abstract findAllRelatedToFeatureConfig( - configurations: FeatureConfigInput, + configuration: FeatureConfigInput, ): Promise; abstract transaction( diff --git a/api/apps/api/test/integration/fixtures.ts b/api/apps/api/test/integration/fixtures.ts new file mode 100644 index 0000000000..9348bac54f --- /dev/null +++ b/api/apps/api/test/integration/fixtures.ts @@ -0,0 +1,131 @@ +import { v4 } from 'uuid'; +import { Test, TestingModule } from '@nestjs/testing'; +import { AppModule } from '@marxan-api/app.module'; +import { SpecificationRepository } from '@marxan-api/modules/specification/application/specification.repository'; +import { GivenUserIsLoggedIn } from '../steps/given-user-is-logged-in'; +import { GivenProjectExists } from '../steps/given-project'; +import { GivenScenarioExists } from '../steps/given-scenario-exists'; +import { ScenariosTestUtils } from '../utils/scenarios.test.utils'; + +import { SpecificationAdaptersModule } from '@marxan-api/modules/specification/adapters/specification-adapters.module'; +import { + Specification, + SpecificationOperation, +} from '@marxan-api/modules/specification/domain'; + +export const getFixtures = async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule, SpecificationAdaptersModule], + }).compile(); + const app = await moduleFixture.createNestApplication().init(); + const specificationRepository = app.get(SpecificationRepository); + + const jwtToken = await GivenUserIsLoggedIn(app); + const { projectId, cleanup } = await GivenProjectExists(app, jwtToken); + const scenario = await GivenScenarioExists(app, projectId, jwtToken, { + name: `Specifications ${Date.now()}`, + }); + + const splitBaseFeatureId = v4(); + const stratificationBaseFeatureId = v4(); + const stratificationAgainstFeatureId = v4(); + const calculatedFeatureId = v4(); + const calculatedForBothFeatureId = v4(); + const nonCalculatedFeatureId = v4(); + + return { + cleanup: async () => { + await ScenariosTestUtils.deleteScenario(app, jwtToken, scenario.id); + await cleanup(); + }, + GivenSpecificationWasCreated: async (): Promise => { + const specification = Specification.from({ + id: v4(), + draft: false, + scenarioId: scenario.id, + config: [ + { + operation: SpecificationOperation.Split, + featuresDetermined: true, + baseFeatureId: splitBaseFeatureId, + resultFeatures: [ + { + id: calculatedFeatureId, + calculated: true, + }, + { + id: calculatedForBothFeatureId, + calculated: true, + }, + ], + }, + { + operation: SpecificationOperation.Stratification, + featuresDetermined: true, + againstFeatureId: stratificationAgainstFeatureId, + baseFeatureId: stratificationBaseFeatureId, + resultFeatures: [ + { + id: nonCalculatedFeatureId, + calculated: false, + }, + { + id: calculatedForBothFeatureId, + calculated: true, + }, + ], + }, + ], + }); + await specificationRepository.save(specification); + return specification; + }, + WhenGettingSpecification: (id: string) => + specificationRepository.getById(id), + ThenTheyAreEqual: ( + specification: Specification, + restoredSpecification: Specification | undefined, + ) => { + expect(specification.toSnapshot()).toEqual( + restoredSpecification?.toSnapshot(), + ); + }, + ThenResultIncludesRelatedSpecification( + specification: Specification, + specifications: Specification[], + ) { + // as we create only one within tests.. + expect(specifications.length).toEqual(1); + this.ThenTheyAreEqual(specification, specifications[0]); + }, + WhenGettingSpecificationsForSplitConfig: async (): Promise< + Specification[] + > => + specificationRepository.findAllRelatedToFeatureConfig({ + operation: SpecificationOperation.Split, + baseFeatureId: splitBaseFeatureId, + againstFeatureId: undefined, + }), + WhenGettingSpecificationsForStratificationConfig: async (): Promise< + Specification[] + > => + specificationRepository.findAllRelatedToFeatureConfig({ + operation: SpecificationOperation.Stratification, + baseFeatureId: stratificationBaseFeatureId, + againstFeatureId: stratificationAgainstFeatureId, + }), + WhenGettingSpecificationsNonExistingConfig: async (): Promise< + Specification[] + > => + specificationRepository.findAllRelatedToFeatureConfig({ + operation: SpecificationOperation.Split, + baseFeatureId: v4(), + }), + WhenGettingSpecificationsRelatedToFeature: async (): Promise< + Specification[] + > => + specificationRepository.findAllRelatedToFeatures([ + nonCalculatedFeatureId, + ]), + }; +}; diff --git a/api/apps/api/test/integration/specification-adapters.integration.e2e-spec.ts b/api/apps/api/test/integration/specification-adapters.integration.e2e-spec.ts new file mode 100644 index 0000000000..b9f0efb7a8 --- /dev/null +++ b/api/apps/api/test/integration/specification-adapters.integration.e2e-spec.ts @@ -0,0 +1,53 @@ +import { FixtureType } from '@marxan/utils/tests/fixture-type'; +import { getFixtures } from './fixtures'; + +let fixtures: FixtureType; + +beforeEach(async () => { + fixtures = await getFixtures(); +}); + +test(`persisting specification`, async () => { + const specification = await fixtures.GivenSpecificationWasCreated(); + const restoredSpecification = await fixtures.WhenGettingSpecification( + specification.id, + ); + fixtures.ThenTheyAreEqual(specification, restoredSpecification); +}); + +test(`getting specifications related to split feature config`, async () => { + const specification = await fixtures.GivenSpecificationWasCreated(); + const specifications = await fixtures.WhenGettingSpecificationsForSplitConfig(); + fixtures.ThenResultIncludesRelatedSpecification( + specification, + specifications, + ); +}); + +test(`getting specifications related to stratification feature config`, async () => { + const specification = await fixtures.GivenSpecificationWasCreated(); + const specifications = await fixtures.WhenGettingSpecificationsForStratificationConfig(); + fixtures.ThenResultIncludesRelatedSpecification( + specification, + specifications, + ); +}); + +test(`getting specifications related to non-existing config`, async () => { + await fixtures.GivenSpecificationWasCreated(); + const specifications = await fixtures.WhenGettingSpecificationsNonExistingConfig(); + expect(specifications).toEqual([]); +}); + +test(`getting specifications related to particular feature ids`, async () => { + const specification = await fixtures.GivenSpecificationWasCreated(); + const specifications = await fixtures.WhenGettingSpecificationsRelatedToFeature(); + fixtures.ThenResultIncludesRelatedSpecification( + specification, + specifications, + ); +}); + +afterEach(async () => { + await fixtures?.cleanup(); +});