2022-03-29 14:59:14 +02:00
|
|
|
import { ISegmentStore } from '../types/stores/segment-store';
|
2023-07-14 13:25:31 +02:00
|
|
|
import {
|
|
|
|
IClientSegment,
|
|
|
|
IConstraint,
|
|
|
|
IFeatureStrategySegment,
|
|
|
|
ISegment,
|
|
|
|
} from '../types/model';
|
2022-03-29 14:59:14 +02:00
|
|
|
import { Logger, LogProvider } from '../logger';
|
|
|
|
import EventEmitter from 'events';
|
|
|
|
import NotFoundError from '../error/notfound-error';
|
|
|
|
import { PartialSome } from '../types/partial';
|
2022-09-16 09:54:27 +02:00
|
|
|
import User from '../types/user';
|
2023-01-30 09:02:44 +01:00
|
|
|
import { Db } from './db';
|
2023-05-26 13:37:00 +02:00
|
|
|
import { IFlagResolver } from '../types';
|
2022-03-29 14:59:14 +02:00
|
|
|
|
|
|
|
const T = {
|
|
|
|
segments: 'segments',
|
|
|
|
featureStrategies: 'feature_strategies',
|
|
|
|
featureStrategySegment: 'feature_strategy_segment',
|
|
|
|
};
|
|
|
|
|
|
|
|
const COLUMNS = [
|
|
|
|
'id',
|
|
|
|
'name',
|
|
|
|
'description',
|
2023-03-07 14:56:20 +01:00
|
|
|
'segment_project_id',
|
2022-03-29 14:59:14 +02:00
|
|
|
'created_by',
|
|
|
|
'created_at',
|
|
|
|
'constraints',
|
|
|
|
];
|
|
|
|
|
|
|
|
interface ISegmentRow {
|
|
|
|
id: number;
|
|
|
|
name: string;
|
|
|
|
description?: string;
|
2023-03-07 14:56:20 +01:00
|
|
|
segment_project_id?: string;
|
2022-03-29 14:59:14 +02:00
|
|
|
created_by?: string;
|
2023-05-26 13:37:00 +02:00
|
|
|
created_at: Date;
|
|
|
|
used_in_projects?: number;
|
|
|
|
used_in_features?: number;
|
2022-03-29 14:59:14 +02:00
|
|
|
constraints: IConstraint[];
|
|
|
|
}
|
|
|
|
|
|
|
|
interface IFeatureStrategySegmentRow {
|
|
|
|
feature_strategy_id: string;
|
|
|
|
segment_id: number;
|
|
|
|
created_at?: Date;
|
|
|
|
}
|
|
|
|
|
|
|
|
export default class SegmentStore implements ISegmentStore {
|
|
|
|
private logger: Logger;
|
|
|
|
|
|
|
|
private eventBus: EventEmitter;
|
|
|
|
|
2023-01-30 09:02:44 +01:00
|
|
|
private db: Db;
|
2022-03-29 14:59:14 +02:00
|
|
|
|
2023-05-26 13:37:00 +02:00
|
|
|
private flagResolver: IFlagResolver;
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
db: Db,
|
|
|
|
eventBus: EventEmitter,
|
|
|
|
getLogger: LogProvider,
|
|
|
|
flagResolver: IFlagResolver,
|
|
|
|
) {
|
2022-03-29 14:59:14 +02:00
|
|
|
this.db = db;
|
|
|
|
this.eventBus = eventBus;
|
2023-05-26 13:37:00 +02:00
|
|
|
this.flagResolver = flagResolver;
|
2022-03-29 14:59:14 +02:00
|
|
|
this.logger = getLogger('lib/db/segment-store.ts');
|
|
|
|
}
|
|
|
|
|
2022-10-25 13:10:27 +02:00
|
|
|
async count(): Promise<number> {
|
|
|
|
return this.db
|
|
|
|
.from(T.segments)
|
|
|
|
.count('*')
|
|
|
|
.then((res) => Number(res[0].count));
|
|
|
|
}
|
|
|
|
|
2022-03-29 14:59:14 +02:00
|
|
|
async create(
|
|
|
|
segment: PartialSome<ISegment, 'id'>,
|
|
|
|
user: Partial<Pick<User, 'username' | 'email'>>,
|
|
|
|
): Promise<ISegment> {
|
|
|
|
const rows = await this.db(T.segments)
|
|
|
|
.insert({
|
|
|
|
id: segment.id,
|
|
|
|
name: segment.name,
|
|
|
|
description: segment.description,
|
2023-03-10 14:26:48 +01:00
|
|
|
segment_project_id: segment.project || null,
|
2022-03-29 14:59:14 +02:00
|
|
|
constraints: JSON.stringify(segment.constraints),
|
|
|
|
created_by: user.username || user.email,
|
|
|
|
})
|
|
|
|
.returning(COLUMNS);
|
|
|
|
|
|
|
|
return this.mapRow(rows[0]);
|
|
|
|
}
|
|
|
|
|
|
|
|
async update(id: number, segment: Omit<ISegment, 'id'>): Promise<ISegment> {
|
|
|
|
const rows = await this.db(T.segments)
|
|
|
|
.where({ id })
|
|
|
|
.update({
|
|
|
|
name: segment.name,
|
|
|
|
description: segment.description,
|
2023-03-10 14:26:48 +01:00
|
|
|
segment_project_id: segment.project || null,
|
2022-03-29 14:59:14 +02:00
|
|
|
constraints: JSON.stringify(segment.constraints),
|
|
|
|
})
|
|
|
|
.returning(COLUMNS);
|
|
|
|
|
|
|
|
return this.mapRow(rows[0]);
|
|
|
|
}
|
|
|
|
|
|
|
|
delete(id: number): Promise<void> {
|
|
|
|
return this.db(T.segments).where({ id }).del();
|
|
|
|
}
|
|
|
|
|
2023-11-22 13:15:29 +01:00
|
|
|
async getAll(
|
|
|
|
includeChangeRequestUsageData: boolean = false,
|
|
|
|
): Promise<ISegment[]> {
|
|
|
|
if (
|
|
|
|
includeChangeRequestUsageData &&
|
|
|
|
this.flagResolver.isEnabled('detectSegmentUsageInChangeRequests')
|
|
|
|
) {
|
feat: include segment usage in CRs when showing usage in projects and flags (#5327)
This PR updates the segment usage counting to also include segment usage
in pending change requests.
The changes include:
- Updating the schema to explicitly call out that change request usage
is included.
- Adding two tests to verify the new features
- Writing an alternate query to count this data
Specifically, it'll update the part of the UI that tells you how many
places a segment is used:
![image](https://github.com/Unleash/unleash/assets/17786332/a77cf932-d735-4a13-ae43-a2840f7106cb)
## Implementation
Implementing this was a little tricky. Previously, we'd just count
distinct instances of feature names and project names on the
feature_strategy table. However, to merge this with change request data,
we can't just count existing usage and change request usage separately,
because that could cause duplicates.
Instead of turning this into a complex DB query, I've broken it up into
a few separate queries and done the merging in JS. I think that's more
readable and it was easier to reason about.
Here's the breakdown:
1. Get the list of pending change requests. We need their IDs and their
project.
2. Get the list of updateStrategy and addStrategy events that have
segment data.
3. Take the result from step 2 and turn it into a dictionary of segment
id to usage data.
4. Query the feature_strategy_segment and feature_strategies table, to
get existing segment usage data
5. Fold that data into the change request data.
6. Perform the preexisting segment query (without counting logic) to get
other segment data
7. Enrich the results of the query from step 2 with usage data.
## Discussion points
I feel like this could be done in a nicer way, so any ideas on how to
achieve that (whether that's as a db query or just breaking up the code
differently) is very welcome.
Second, using multiple queries obviously yields more overhead than just
a single one. However, I do not think this is in the hot path, so I
don't consider performance to be critical here, but I'm open to hearing
opposing thoughts on this of course.
2023-11-14 08:49:32 +01:00
|
|
|
const pendingCRs = await this.db
|
|
|
|
.select('id', 'project')
|
|
|
|
.from('change_requests')
|
|
|
|
.whereNotIn('state', ['Applied', 'Rejected', 'Cancelled']);
|
2023-07-14 12:30:15 +02:00
|
|
|
|
feat: include segment usage in CRs when showing usage in projects and flags (#5327)
This PR updates the segment usage counting to also include segment usage
in pending change requests.
The changes include:
- Updating the schema to explicitly call out that change request usage
is included.
- Adding two tests to verify the new features
- Writing an alternate query to count this data
Specifically, it'll update the part of the UI that tells you how many
places a segment is used:
![image](https://github.com/Unleash/unleash/assets/17786332/a77cf932-d735-4a13-ae43-a2840f7106cb)
## Implementation
Implementing this was a little tricky. Previously, we'd just count
distinct instances of feature names and project names on the
feature_strategy table. However, to merge this with change request data,
we can't just count existing usage and change request usage separately,
because that could cause duplicates.
Instead of turning this into a complex DB query, I've broken it up into
a few separate queries and done the merging in JS. I think that's more
readable and it was easier to reason about.
Here's the breakdown:
1. Get the list of pending change requests. We need their IDs and their
project.
2. Get the list of updateStrategy and addStrategy events that have
segment data.
3. Take the result from step 2 and turn it into a dictionary of segment
id to usage data.
4. Query the feature_strategy_segment and feature_strategies table, to
get existing segment usage data
5. Fold that data into the change request data.
6. Perform the preexisting segment query (without counting logic) to get
other segment data
7. Enrich the results of the query from step 2 with usage data.
## Discussion points
I feel like this could be done in a nicer way, so any ideas on how to
achieve that (whether that's as a db query or just breaking up the code
differently) is very welcome.
Second, using multiple queries obviously yields more overhead than just
a single one. However, I do not think this is in the hot path, so I
don't consider performance to be critical here, but I'm open to hearing
opposing thoughts on this of course.
2023-11-14 08:49:32 +01:00
|
|
|
const pendingChangeRequestIds = pendingCRs.map((cr) => cr.id);
|
|
|
|
|
|
|
|
const crFeatures = await this.db
|
|
|
|
.select(
|
|
|
|
'payload',
|
|
|
|
'feature',
|
|
|
|
'change_request_id as changeRequestId',
|
|
|
|
)
|
|
|
|
.from('change_request_events')
|
|
|
|
.whereIn('change_request_id', pendingChangeRequestIds)
|
|
|
|
.whereIn('action', ['addStrategy', 'updateStrategy'])
|
|
|
|
.andWhereRaw("jsonb_array_length(payload -> 'segments') > 0");
|
|
|
|
|
|
|
|
const changeRequestToProjectMap = pendingCRs.reduce(
|
|
|
|
(acc, { id, project }) => {
|
|
|
|
acc[id] = project;
|
|
|
|
return acc;
|
|
|
|
},
|
|
|
|
{},
|
|
|
|
);
|
|
|
|
|
|
|
|
const combinedUsageData = crFeatures.reduce((acc, segmentEvent) => {
|
|
|
|
const { payload, changeRequestId, feature } = segmentEvent;
|
|
|
|
const project = changeRequestToProjectMap[changeRequestId];
|
|
|
|
|
|
|
|
for (const segmentId of payload.segments) {
|
|
|
|
const existingData = acc[segmentId];
|
|
|
|
if (existingData) {
|
|
|
|
acc[segmentId] = {
|
|
|
|
features: existingData.features.add(feature),
|
|
|
|
projects: existingData.projects.add(project),
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
acc[segmentId] = {
|
|
|
|
features: new Set([feature]),
|
|
|
|
projects: new Set([project]),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return acc;
|
|
|
|
}, {});
|
|
|
|
|
|
|
|
const currentSegmentUsage = await this.db
|
|
|
|
.select(
|
|
|
|
`${T.featureStrategies}.feature_name as featureName`,
|
|
|
|
`${T.featureStrategies}.project_name as projectName`,
|
|
|
|
'segment_id as segmentId',
|
|
|
|
)
|
|
|
|
.from(T.featureStrategySegment)
|
|
|
|
.leftJoin(
|
|
|
|
T.featureStrategies,
|
|
|
|
`${T.featureStrategies}.id`,
|
|
|
|
`${T.featureStrategySegment}.feature_strategy_id`,
|
|
|
|
);
|
|
|
|
|
|
|
|
currentSegmentUsage.forEach(
|
|
|
|
({ segmentId, featureName, projectName }) => {
|
|
|
|
const usage = combinedUsageData[segmentId];
|
|
|
|
if (usage) {
|
|
|
|
combinedUsageData[segmentId] = {
|
|
|
|
features: usage.features.add(featureName),
|
|
|
|
projects: usage.projects.add(projectName),
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
combinedUsageData[segmentId] = {
|
|
|
|
features: new Set([featureName]),
|
|
|
|
projects: new Set([projectName]),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
},
|
|
|
|
);
|
|
|
|
|
|
|
|
const rows: ISegmentRow[] = await this.db
|
|
|
|
.select(this.prefixColumns())
|
|
|
|
.from(T.segments)
|
|
|
|
.leftJoin(
|
|
|
|
T.featureStrategySegment,
|
|
|
|
`${T.segments}.id`,
|
|
|
|
`${T.featureStrategySegment}.segment_id`,
|
|
|
|
)
|
|
|
|
.groupBy(this.prefixColumns())
|
|
|
|
.orderBy('name', 'asc');
|
|
|
|
|
|
|
|
const rowsWithUsageData: ISegmentRow[] = rows.map((row) => {
|
|
|
|
const usageData = combinedUsageData[row.id];
|
|
|
|
if (usageData) {
|
|
|
|
return {
|
|
|
|
...row,
|
|
|
|
used_in_features: usageData.features.size,
|
|
|
|
used_in_projects: usageData.projects.size,
|
|
|
|
};
|
|
|
|
} else {
|
|
|
|
return {
|
|
|
|
...row,
|
|
|
|
used_in_features: 0,
|
|
|
|
used_in_projects: 0,
|
|
|
|
};
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return rowsWithUsageData.map(this.mapRow);
|
|
|
|
} else {
|
|
|
|
const rows: ISegmentRow[] = await this.db
|
|
|
|
.select(
|
|
|
|
this.prefixColumns(),
|
|
|
|
'used_in_projects',
|
|
|
|
'used_in_features',
|
|
|
|
)
|
|
|
|
.countDistinct(
|
|
|
|
`${T.featureStrategies}.project_name AS used_in_projects`,
|
|
|
|
)
|
|
|
|
.countDistinct(
|
|
|
|
`${T.featureStrategies}.feature_name AS used_in_features`,
|
|
|
|
)
|
|
|
|
.from(T.segments)
|
|
|
|
.leftJoin(
|
|
|
|
T.featureStrategySegment,
|
|
|
|
`${T.segments}.id`,
|
|
|
|
`${T.featureStrategySegment}.segment_id`,
|
|
|
|
)
|
|
|
|
.leftJoin(
|
|
|
|
T.featureStrategies,
|
|
|
|
`${T.featureStrategies}.id`,
|
|
|
|
`${T.featureStrategySegment}.feature_strategy_id`,
|
|
|
|
)
|
|
|
|
.groupBy(this.prefixColumns())
|
|
|
|
.orderBy('name', 'asc');
|
|
|
|
|
|
|
|
return rows.map(this.mapRow);
|
|
|
|
}
|
2022-03-29 14:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async getActive(): Promise<ISegment[]> {
|
|
|
|
const rows: ISegmentRow[] = await this.db
|
|
|
|
.distinct(this.prefixColumns())
|
|
|
|
.from(T.segments)
|
|
|
|
.orderBy('name', 'asc')
|
|
|
|
.join(
|
|
|
|
T.featureStrategySegment,
|
|
|
|
`${T.featureStrategySegment}.segment_id`,
|
|
|
|
`${T.segments}.id`,
|
|
|
|
);
|
|
|
|
|
|
|
|
return rows.map(this.mapRow);
|
|
|
|
}
|
|
|
|
|
2023-07-14 13:25:31 +02:00
|
|
|
async getActiveForClient(): Promise<IClientSegment[]> {
|
|
|
|
const fullSegments = await this.getActive();
|
|
|
|
|
|
|
|
return fullSegments.map((segments) => ({
|
|
|
|
id: segments.id,
|
|
|
|
name: segments.name,
|
|
|
|
constraints: segments.constraints,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2022-03-29 14:59:14 +02:00
|
|
|
async getByStrategy(strategyId: string): Promise<ISegment[]> {
|
|
|
|
const rows = await this.db
|
|
|
|
.select(this.prefixColumns())
|
|
|
|
.from<ISegmentRow>(T.segments)
|
|
|
|
.join(
|
|
|
|
T.featureStrategySegment,
|
|
|
|
`${T.featureStrategySegment}.segment_id`,
|
|
|
|
`${T.segments}.id`,
|
|
|
|
)
|
|
|
|
.where(
|
|
|
|
`${T.featureStrategySegment}.feature_strategy_id`,
|
|
|
|
'=',
|
|
|
|
strategyId,
|
|
|
|
);
|
|
|
|
return rows.map(this.mapRow);
|
|
|
|
}
|
|
|
|
|
|
|
|
deleteAll(): Promise<void> {
|
|
|
|
return this.db(T.segments).del();
|
|
|
|
}
|
|
|
|
|
|
|
|
async exists(id: number): Promise<boolean> {
|
|
|
|
const result = await this.db.raw(
|
|
|
|
`SELECT EXISTS(SELECT 1 FROM ${T.segments} WHERE id = ?) AS present`,
|
|
|
|
[id],
|
|
|
|
);
|
|
|
|
|
|
|
|
return result.rows[0].present;
|
|
|
|
}
|
|
|
|
|
|
|
|
async get(id: number): Promise<ISegment> {
|
|
|
|
const rows: ISegmentRow[] = await this.db
|
|
|
|
.select(this.prefixColumns())
|
|
|
|
.from(T.segments)
|
|
|
|
.where({ id });
|
|
|
|
|
2023-06-30 11:02:24 +02:00
|
|
|
const row = rows[0];
|
|
|
|
if (!row) {
|
|
|
|
throw new NotFoundError(`No segment exists with ID "${id}"`);
|
|
|
|
}
|
|
|
|
|
|
|
|
return this.mapRow(row);
|
2022-03-29 14:59:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
async addToStrategy(id: number, strategyId: string): Promise<void> {
|
|
|
|
await this.db(T.featureStrategySegment).insert({
|
|
|
|
segment_id: id,
|
|
|
|
feature_strategy_id: strategyId,
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
async removeFromStrategy(id: number, strategyId: string): Promise<void> {
|
|
|
|
await this.db(T.featureStrategySegment)
|
|
|
|
.where({ segment_id: id, feature_strategy_id: strategyId })
|
|
|
|
.del();
|
|
|
|
}
|
|
|
|
|
|
|
|
async getAllFeatureStrategySegments(): Promise<IFeatureStrategySegment[]> {
|
|
|
|
const rows: IFeatureStrategySegmentRow[] = await this.db
|
|
|
|
.select(['segment_id', 'feature_strategy_id'])
|
|
|
|
.from(T.featureStrategySegment);
|
|
|
|
|
|
|
|
return rows.map((row) => ({
|
|
|
|
featureStrategyId: row.feature_strategy_id,
|
|
|
|
segmentId: row.segment_id,
|
|
|
|
}));
|
|
|
|
}
|
|
|
|
|
2022-04-06 15:01:50 +02:00
|
|
|
async existsByName(name: string): Promise<boolean> {
|
|
|
|
const rows: ISegmentRow[] = await this.db
|
|
|
|
.select(this.prefixColumns())
|
|
|
|
.from(T.segments)
|
|
|
|
.where({ name });
|
|
|
|
|
|
|
|
return Boolean(rows[0]);
|
|
|
|
}
|
|
|
|
|
2022-03-29 14:59:14 +02:00
|
|
|
prefixColumns(): string[] {
|
|
|
|
return COLUMNS.map((c) => `${T.segments}.${c}`);
|
|
|
|
}
|
|
|
|
|
|
|
|
mapRow(row?: ISegmentRow): ISegment {
|
|
|
|
if (!row) {
|
|
|
|
throw new NotFoundError('No row');
|
|
|
|
}
|
|
|
|
|
2023-06-12 09:55:58 +02:00
|
|
|
return {
|
2022-03-29 14:59:14 +02:00
|
|
|
id: row.id,
|
|
|
|
name: row.name,
|
|
|
|
description: row.description,
|
2023-03-13 11:25:48 +01:00
|
|
|
project: row.segment_project_id || undefined,
|
2022-03-29 14:59:14 +02:00
|
|
|
constraints: row.constraints,
|
|
|
|
createdBy: row.created_by,
|
|
|
|
createdAt: row.created_at,
|
2023-06-12 09:55:58 +02:00
|
|
|
...(row.used_in_projects && {
|
|
|
|
usedInProjects: Number(row.used_in_projects),
|
|
|
|
}),
|
|
|
|
...(row.used_in_features && {
|
|
|
|
usedInFeatures: Number(row.used_in_features),
|
|
|
|
}),
|
2022-03-29 14:59:14 +02:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
destroy(): void {}
|
|
|
|
}
|