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-feature-config.api.entity.ts b/api/apps/api/src/modules/specification/adapters/specification-feature-config.api.entity.ts index fac8e61d84..923b00b2f4 100644 --- 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 @@ -32,17 +32,23 @@ export class SpecificationFeatureConfigApiEntity { @OneToMany( () => SpecificationFeatureApiEntity, - (specificationFeature) => specificationFeature.specificationFeatureConfigId, + (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; @@ -55,6 +61,7 @@ export class SpecificationFeatureConfigApiEntity { @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 index 8e4a8adbde..04c36b7021 100644 --- 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 @@ -29,6 +29,7 @@ export class SpecificationFeatureApiEntity { @Column({ type: `uuid`, + name: `feature_id`, }) featureId!: string; 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 index 70148d9512..fea46798bb 100644 --- a/api/apps/api/src/modules/specification/adapters/specification.api.entity.ts +++ b/api/apps/api/src/modules/specification/adapters/specification.api.entity.ts @@ -4,19 +4,25 @@ import { JoinColumn, ManyToOne, OneToMany, - PrimaryGeneratedColumn, + 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 { - @PrimaryGeneratedColumn(`uuid`) + @PrimaryColumn({ + type: `uuid`, + }) id!: string; @OneToMany( () => SpecificationFeatureConfigApiEntity, - (specificationFeatures) => specificationFeatures.specification, + (specificationFeaturesConfig) => specificationFeaturesConfig.specification, + { + cascade: true, + eager: true, + }, ) specificationFeaturesConfiguration?: SpecificationFeatureConfigApiEntity[]; diff --git a/api/apps/api/src/modules/specification/adapters/specification.repository.ts b/api/apps/api/src/modules/specification/adapters/specification.repository.ts index 337496d5e0..1e0df93676 100644 --- a/api/apps/api/src/modules/specification/adapters/specification.repository.ts +++ b/api/apps/api/src/modules/specification/adapters/specification.repository.ts @@ -1,12 +1,15 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; -import { IsNull, Repository } from 'typeorm'; +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 { @@ -14,29 +17,83 @@ export class DbSpecificationRepository implements SpecificationRepository { @InjectRepository(SpecificationApiEntity) private readonly specificationRepo: Repository, @InjectRepository(SpecificationFeatureConfigApiEntity) - private readonly specificationFeaturesRepo: Repository, + private readonly specificationFeatureConfigRepo: Repository, + @InjectRepository(SpecificationFeatureApiEntity) + private readonly specificationFeatureRepo: Repository, + @InjectEntityManager(DbConnections.default) + private readonly entityManager: EntityManager, ) {} async findAllRelatedToFeatureConfig( configuration: FeatureConfigInput, ): Promise { - const specifications = await this.specificationRepo.find({ - where: { + // 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, - againstFeatureId: configuration.againstFeatureId - ? configuration.againstFeatureId - : IsNull(), + }) + .andWhere(`config.operation = :operation`, { operation: configuration.operation, - }, - loadEagerRelations: true, - }); + }); + + 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), ); } - findAllRelatedToFeatures(features: string[]): Promise { - return Promise.resolve([]); + 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 { @@ -53,14 +110,47 @@ export class DbSpecificationRepository implements SpecificationRepository { return; } - save(specification: Specification): Promise { - return Promise.resolve(undefined); + 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 Promise.resolve([]); + 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 => { @@ -76,7 +166,11 @@ export class DbSpecificationRepository implements SpecificationRepository { baseFeatureId: specificationFeature.baseFeatureId, operation: specificationFeature.operation, featuresDetermined: specificationFeature.featuresDetermined, - resultFeatures: [], + 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(); +});