Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 42 additions & 20 deletions packages/backend/src/api/repositories/FeatureFlagRepository.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Repository, EntityManager } from 'typeorm';
import { Repository, EntityManager, SelectQueryBuilder } from 'typeorm';
import { EntityRepository } from '../../typeorm-typedi-extensions';
import { FeatureFlag } from '../models/FeatureFlag';
import repositoryError from './utils/repositoryError';
Expand Down Expand Up @@ -91,26 +91,48 @@ export class FeatureFlagRepository extends Repository<FeatureFlag> {
}

public async getFlagsFromContext(context: string): Promise<FeatureFlag[]> {
const result = await this.createQueryBuilder('feature_flag')
.leftJoinAndSelect('feature_flag.featureFlagSegmentInclusion', 'featureFlagSegmentInclusion')
.leftJoinAndSelect('featureFlagSegmentInclusion.segment', 'segmentInclusion')
.leftJoinAndSelect('segmentInclusion.individualForSegment', 'individualForSegment')
.leftJoinAndSelect('segmentInclusion.groupForSegment', 'groupForSegment')
.leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment')
.leftJoinAndSelect('feature_flag.featureFlagSegmentExclusion', 'featureFlagSegmentExclusion')
.leftJoinAndSelect('featureFlagSegmentExclusion.segment', 'segmentExclusion')
.leftJoinAndSelect('segmentExclusion.individualForSegment', 'individualForSegmentExclusion')
.leftJoinAndSelect('segmentExclusion.groupForSegment', 'groupForSegmentExclusion')
.leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion')
.where('feature_flag.context @> :searchContext', { searchContext: [context] })
.andWhere('feature_flag.status = :status', { status: FEATURE_FLAG_STATUS.ENABLED })
.getMany()
.catch((errorMsg: any) => {
const errorMsgString = repositoryError('FeatureFlagRepository', 'getFlagsFromContext', { context }, errorMsg);
throw errorMsgString;
});
const addExclusionJoins = (qb: SelectQueryBuilder<FeatureFlag>) =>
qb
.leftJoinAndSelect('feature_flag.featureFlagSegmentExclusion', 'featureFlagSegmentExclusion')
.leftJoinAndSelect('featureFlagSegmentExclusion.segment', 'segmentExclusion')
.leftJoinAndSelect('segmentExclusion.individualForSegment', 'individualForSegmentExclusion')
.leftJoinAndSelect('segmentExclusion.groupForSegment', 'groupForSegmentExclusion')
.leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion');

return result;
const addInclusionJoins = (qb: SelectQueryBuilder<FeatureFlag>) =>
qb
.leftJoinAndSelect('feature_flag.featureFlagSegmentInclusion', 'featureFlagSegmentInclusion')
.leftJoinAndSelect('featureFlagSegmentInclusion.segment', 'segmentInclusion')
.leftJoinAndSelect('segmentInclusion.individualForSegment', 'individualForSegment')
.leftJoinAndSelect('segmentInclusion.groupForSegment', 'groupForSegment')
.leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment');

const addBaseConditions = (qb: SelectQueryBuilder<FeatureFlag>) =>
qb
.where('feature_flag.context @> :searchContext', { searchContext: [context] })
.andWhere('feature_flag.status = :status', { status: FEATURE_FLAG_STATUS.ENABLED });

// INCLUDE_ALL flags: inclusion segments are irrelevant — skip all inclusion joins
const includeAllQuery = addBaseConditions(addExclusionJoins(this.createQueryBuilder('feature_flag'))).andWhere(
'feature_flag.filterMode = :includeAll',
{ includeAll: FILTER_MODE.INCLUDE_ALL }
);

// EXCLUDE_ALL flags: both inclusion and exclusion segments are needed
const excludeAllQuery = addBaseConditions(
addInclusionJoins(addExclusionJoins(this.createQueryBuilder('feature_flag')))
).andWhere('feature_flag.filterMode = :excludeAll', { excludeAll: FILTER_MODE.EXCLUDE_ALL });

const [includeAllFlags, excludeAllFlags] = await Promise.all([
includeAllQuery.getMany().catch((errorMsg: any) => {
throw repositoryError('FeatureFlagRepository', 'getFlagsFromContext', { context }, errorMsg);
}),
excludeAllQuery.getMany().catch((errorMsg: any) => {
throw repositoryError('FeatureFlagRepository', 'getFlagsFromContext', { context }, errorMsg);
}),
]);

return [...includeAllFlags, ...excludeAllFlags];
}

public async validateUniqueKey(flagDTO: FeatureFlagValidation) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,89 @@ describe('FeatureFlagRepository Testing', () => {
expect(mock.returning).toHaveBeenCalledWith('*');
expect(mock.execute).toHaveBeenCalledTimes(1);
});

describe('getFlagsFromContext', () => {
const context = 'test-context';

beforeEach(() => {
mock.getMany.mockResolvedValue([]);
});
Comment on lines +183 to +185

it('should run two separate queries in parallel, one per filterMode', async () => {
await repo.getFlagsFromContext(context);

expect(repo.createQueryBuilder).toHaveBeenCalledTimes(2);
expect(mock.getMany).toHaveBeenCalledTimes(2);
expect(mock.andWhere).toHaveBeenCalledWith('feature_flag.filterMode = :includeAll', {
includeAll: FILTER_MODE.INCLUDE_ALL,
});
expect(mock.andWhere).toHaveBeenCalledWith('feature_flag.filterMode = :excludeAll', {
excludeAll: FILTER_MODE.EXCLUDE_ALL,
});
});

it('should apply context and status conditions to both queries', async () => {
await repo.getFlagsFromContext(context);

expect(mock.where).toHaveBeenCalledTimes(2);
expect(mock.where).toHaveBeenCalledWith('feature_flag.context @> :searchContext', {
searchContext: [context],
});
expect(mock.andWhere).toHaveBeenCalledWith('feature_flag.status = :status', {
status: FEATURE_FLAG_STATUS.ENABLED,
});
});

it('should not join inclusion segment member data for INCLUDE_ALL flags', async () => {
await repo.getFlagsFromContext(context);

// Collect all first-args passed to leftJoinAndSelect across both queries
const joinedRelations = mock.leftJoinAndSelect.mock.calls.map(([relation]) => relation);

// Inclusion-side member joins should appear exactly once (only from the EXCLUDE_ALL query)
expect(joinedRelations.filter((r) => r === 'segmentInclusion.individualForSegment')).toHaveLength(1);
expect(joinedRelations.filter((r) => r === 'segmentInclusion.groupForSegment')).toHaveLength(1);
expect(joinedRelations.filter((r) => r === 'segmentInclusion.subSegments')).toHaveLength(1);

// Exclusion-side member joins should appear twice (once per query)
expect(joinedRelations.filter((r) => r === 'segmentExclusion.individualForSegment')).toHaveLength(2);
expect(joinedRelations.filter((r) => r === 'segmentExclusion.groupForSegment')).toHaveLength(2);
expect(joinedRelations.filter((r) => r === 'segmentExclusion.subSegments')).toHaveLength(2);
});

it('should join inclusion segment member data for EXCLUDE_ALL flags', async () => {
await repo.getFlagsFromContext(context);

expect(mock.leftJoinAndSelect).toHaveBeenCalledWith(
'feature_flag.featureFlagSegmentInclusion',
'featureFlagSegmentInclusion'
);
expect(mock.leftJoinAndSelect).toHaveBeenCalledWith('featureFlagSegmentInclusion.segment', 'segmentInclusion');
expect(mock.leftJoinAndSelect).toHaveBeenCalledWith(
'segmentInclusion.individualForSegment',
'individualForSegment'
);
expect(mock.leftJoinAndSelect).toHaveBeenCalledWith('segmentInclusion.groupForSegment', 'groupForSegment');
expect(mock.leftJoinAndSelect).toHaveBeenCalledWith('segmentInclusion.subSegments', 'subSegment');
});

it('should combine results from both queries', async () => {
const includeAllFlag = new FeatureFlag();
includeAllFlag.id = 'include-all-flag';
const excludeAllFlag = new FeatureFlag();
excludeAllFlag.id = 'exclude-all-flag';

mock.getMany.mockResolvedValueOnce([includeAllFlag]).mockResolvedValueOnce([excludeAllFlag]);

const results = await repo.getFlagsFromContext(context);

expect(results).toEqual([includeAllFlag, excludeAllFlag]);
});

it('should throw when either query fails', async () => {
mock.getMany.mockRejectedValueOnce(err);

await expect(repo.getFlagsFromContext(context)).rejects.toThrow();
});
});
});