From c14c67f47658887821b0d020fa32b6877f46882c Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 25 Jun 2024 09:11:46 +0200 Subject: [PATCH] feat: lifecycle stage count (#7434) --- .../fake-feature-lifecycle-read-model.ts | 12 +++- .../fake-feature-lifecycle-store.ts | 4 ++ .../feature-lifecycle-read-model-type.ts | 13 +++- .../feature-lifecycle-read-model.test.ts | 64 +++++++++++++++++++ .../feature-lifecycle-read-model.ts | 61 +++++++++++++++++- .../feature-lifecycle-store-type.ts | 1 + .../feature-lifecycle-store.ts | 4 ++ .../feature-lifecycle.e2e.test.ts | 4 +- 8 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts index c01d0009b3f4..7c246dee04d1 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-read-model.ts @@ -1,9 +1,19 @@ -import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import type { + IFeatureLifecycleReadModel, + StageCount, + StageCountByProject, +} from './feature-lifecycle-read-model-type'; import type { IFeatureLifecycleStage } from '../../types'; export class FakeFeatureLifecycleReadModel implements IFeatureLifecycleReadModel { + getStageCount(): Promise { + return Promise.resolve([]); + } + getStageCountByProject(): Promise { + return Promise.resolve([]); + } findCurrentStage( feature: string, ): Promise { diff --git a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts index bf545e899f99..ff9a8b4685a4 100644 --- a/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/fake-feature-lifecycle-store.ts @@ -57,6 +57,10 @@ export class FakeFeatureLifecycleStore implements IFeatureLifecycleStore { this.lifecycles[feature] = []; } + async deleteAll(): Promise { + this.lifecycles = {}; + } + async stageExists(stage: FeatureLifecycleStage): Promise { const lifecycle = await this.get(stage.feature); return Boolean(lifecycle.find((s) => s.stage === stage.stage)); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts index ea827f053c8b..139807e650be 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model-type.ts @@ -1,7 +1,18 @@ -import type { IFeatureLifecycleStage } from '../../types'; +import type { IFeatureLifecycleStage, StageName } from '../../types'; + +export type StageCount = { + stage: StageName; + count: number; +}; + +export type StageCountByProject = StageCount & { + project: string; +}; export interface IFeatureLifecycleReadModel { findCurrentStage( feature: string, ): Promise; + getStageCount(): Promise; + getStageCountByProject(): Promise; } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts new file mode 100644 index 000000000000..7ea9fbd27e64 --- /dev/null +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.test.ts @@ -0,0 +1,64 @@ +import dbInit, { type ITestDb } from '../../../test/e2e/helpers/database-init'; +import getLogger from '../../../test/fixtures/no-logger'; +import { FeatureLifecycleReadModel } from './feature-lifecycle-read-model'; +import type { IFeatureLifecycleStore } from './feature-lifecycle-store-type'; +import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import type { IFeatureToggleStore } from '../feature-toggle/types/feature-toggle-store-type'; + +let db: ITestDb; +let featureLifeycycleReadModel: IFeatureLifecycleReadModel; +let featureLifecycleStore: IFeatureLifecycleStore; +let featureToggleStore: IFeatureToggleStore; + +beforeAll(async () => { + db = await dbInit('feature_lifecycle_read_model', getLogger); + featureLifeycycleReadModel = new FeatureLifecycleReadModel(db.rawDatabase); + featureLifecycleStore = db.stores.featureLifecycleStore; + featureToggleStore = db.stores.featureToggleStore; +}); + +afterAll(async () => { + if (db) { + await db.destroy(); + } +}); + +beforeEach(async () => { + await featureToggleStore.deleteAll(); +}); + +test('can return stage count', async () => { + await featureToggleStore.create('default', { + name: 'featureA', + createdByUserId: 9999, + }); + await featureToggleStore.create('default', { + name: 'featureB', + createdByUserId: 9999, + }); + await featureToggleStore.create('default', { + name: 'featureC', + createdByUserId: 9999, + }); + await featureLifecycleStore.insert([ + { feature: 'featureA', stage: 'initial' }, + { feature: 'featureB', stage: 'initial' }, + { feature: 'featureC', stage: 'initial' }, + ]); + await featureLifecycleStore.insert([ + { feature: 'featureA', stage: 'pre-live' }, + ]); + + const stageCount = await featureLifeycycleReadModel.getStageCount(); + expect(stageCount).toMatchObject([ + { stage: 'pre-live', count: 1 }, + { stage: 'initial', count: 2 }, + ]); + + const stageCountByProject = + await featureLifeycycleReadModel.getStageCountByProject(); + expect(stageCountByProject).toMatchObject([ + { project: 'default', stage: 'pre-live', count: 1 }, + { project: 'default', stage: 'initial', count: 2 }, + ]); +}); diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts index a0dff07d00c9..4cb14b4f2148 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-read-model.ts @@ -1,5 +1,9 @@ import type { Db } from '../../db/db'; -import type { IFeatureLifecycleReadModel } from './feature-lifecycle-read-model-type'; +import type { + IFeatureLifecycleReadModel, + StageCount, + StageCountByProject, +} from './feature-lifecycle-read-model-type'; import { getCurrentStage } from './get-current-stage'; import type { IFeatureLifecycleStage, StageName } from '../../types'; @@ -17,6 +21,61 @@ export class FeatureLifecycleReadModel implements IFeatureLifecycleReadModel { this.db = db; } + async getStageCount(): Promise { + const { rows } = await this.db.raw(` + SELECT + stage, + COUNT(*) AS feature_count + FROM ( + SELECT DISTINCT ON (feature) + feature, + stage, + created_at + FROM + feature_lifecycles + ORDER BY + feature, created_at DESC + ) AS LatestStages + GROUP BY + stage; + `); + + return rows.map((row) => ({ + stage: row.stage, + count: Number(row.feature_count), + })); + } + + async getStageCountByProject(): Promise { + const { rows } = await this.db.raw(` + SELECT + f.project, + ls.stage, + COUNT(*) AS feature_count + FROM ( + SELECT DISTINCT ON (fl.feature) + fl.feature, + fl.stage, + fl.created_at + FROM + feature_lifecycles fl + ORDER BY + fl.feature, fl.created_at DESC + ) AS ls + JOIN + features f ON f.name = ls.feature + GROUP BY + f.project, + ls.stage; + `); + + return rows.map((row) => ({ + stage: row.stage, + count: Number(row.feature_count), + project: row.project, + })); + } + async findCurrentStage( feature: string, ): Promise { diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts index d8ff0b88599c..637e0c5855d9 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store-type.ts @@ -20,6 +20,7 @@ export interface IFeatureLifecycleStore { getAll(): Promise; stageExists(stage: FeatureLifecycleStage): Promise; delete(feature: string): Promise; + deleteAll(): Promise; deleteStage(stage: FeatureLifecycleStage): Promise; backfill(): Promise; } diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts index 6e3586a72f1e..6ba80f90a31f 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts @@ -101,6 +101,10 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore { await this.db('feature_lifecycles').where({ feature }).del(); } + async deleteAll(): Promise { + await this.db('feature_lifecycles').del(); + } + async deleteStage(stage: FeatureLifecycleStage): Promise { await this.db('feature_lifecycles') .where({ diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index a5420b2f44b1..b3da7cf49db8 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -62,7 +62,9 @@ afterAll(async () => { await db.destroy(); }); -beforeEach(async () => {}); +beforeEach(async () => { + await featureLifecycleStore.deleteAll(); +}); const getFeatureLifecycle = async (featureName: string, expectedCode = 200) => { return app.request