unleash-server
Version:
Unleash is an enterprise ready feature flag service. It provides different strategies for handling feature flags.
560 lines • 24.4 kB
JavaScript
import metricsHelper from '../../util/metrics-helper.js';
import { DB_TIME } from '../../metric-events.js';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store.js';
import { applyGenericQueryParams, applySearchFilters, parseSearchOperatorValue, } from './search-utils.js';
import { generateImageUrl } from '../../util/index.js';
const sortEnvironments = (overview) => {
return overview.map((data) => ({
...data,
environments: data.environments
.filter((f) => f.name)
.sort((a, b) => {
if (a.sortOrder === b.sortOrder) {
return a.name.localeCompare(b.name);
}
return a.sortOrder - b.sortOrder;
}),
}));
};
class FeatureSearchStore {
constructor(db, eventBus, _getLogger, _flagResolver) {
this.db = db;
this.timer = (action) => metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-search',
action,
});
}
static getEnvironment(r) {
return {
name: r.environment,
enabled: r.enabled,
type: r.environment_type,
sortOrder: r.environment_sort_order,
variantCount: r.variants?.length || 0,
lastSeenAt: r.env_last_seen_at,
hasStrategies: r.has_strategies,
hasEnabledStrategies: r.has_enabled_strategies,
yes: Number(r.yes) || 0,
no: Number(r.no) || 0,
changeRequestIds: r.change_request_ids ?? [],
...(r.milestone_name
? {
milestoneName: r.milestone_name,
milestoneOrder: r.milestone_order,
totalMilestones: Number(r.total_milestones || 0),
}
: {}),
};
}
async searchFeatures({ userId, searchParams, status, offset, limit, lifecycle, sortOrder, sortBy, archived, favoritesFirst, }, queryParams) {
const stopTimer = this.timer('searchFeatures');
const validatedSortOrder = sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const finalQuery = this.db
.with('ranked_features', (query) => {
query.from('features');
let selectColumns = [
'features.name as feature_name',
'features.description as description',
'features.type as type',
'features.archived_at as archived_at',
'features.project as project',
'features.created_at as created_at',
'features.stale as stale',
'features.last_seen_at as last_seen_at',
'features.impression_data as impression_data',
'feature_environments.enabled as enabled',
'feature_environments.environment as environment',
'feature_environments.variants as variants',
'environments.type as environment_type',
'environments.sort_order as environment_sort_order',
'ft.tag_value as tag_value',
'ft.tag_type as tag_type',
'tag_types.color as tag_type_color',
'segments.name as segment_name',
'users.id as user_id',
'users.name as user_name',
'users.username as user_username',
'users.email as user_email',
'users.image_url as user_image_url',
'lifecycle.latest_stage',
'lifecycle.stage_status',
'lifecycle.entered_stage_at',
];
const lastSeenQuery = 'last_seen_at_metrics.last_seen_at';
selectColumns.push(`${lastSeenQuery} as env_last_seen_at`);
if (userId) {
query.leftJoin(`favorite_features`, function () {
this.on('favorite_features.feature', 'features.name').andOnVal('favorite_features.user_id', '=', userId);
});
selectColumns = [
...selectColumns,
this.db.raw('favorite_features.feature is not null as favorite'),
];
}
selectColumns = [
...selectColumns,
this.db.raw(`CASE
WHEN dependent_features.parent = features.name THEN 'parent'
WHEN dependent_features.child = features.name THEN 'child'
ELSE null
END AS dependency`),
];
applyQueryParams(query, queryParams);
applySearchFilters(query, searchParams, [
'features.name',
'features.description',
]);
if (status && status.length > 0) {
query.where((builder) => {
for (const [envName, envStatus] of status) {
builder.orWhere(function () {
this.where('feature_environments.environment', envName).andWhere('feature_environments.enabled', envStatus === 'enabled');
});
}
});
}
query
.modify(FeatureToggleStore.filterByArchived, archived)
.leftJoin('feature_environments', 'feature_environments.feature_name', 'features.name')
.leftJoin('environments', 'feature_environments.environment', 'environments.name')
.leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name')
.leftJoin('tag_types', 'tag_types.name', 'ft.tag_type')
.leftJoin('feature_strategies', 'feature_strategies.feature_name', 'features.name')
.leftJoin('feature_strategy_segment', 'feature_strategy_segment.feature_strategy_id', 'feature_strategies.id')
.leftJoin('segments', 'feature_strategy_segment.segment_id', 'segments.id')
.leftJoin('dependent_features', (qb) => {
qb.on('dependent_features.parent', '=', 'features.name').orOn('dependent_features.child', '=', 'features.name');
})
.leftJoin('users', 'users.id', 'features.created_by_user_id')
.leftJoin('last_seen_at_metrics', function () {
this.on('last_seen_at_metrics.environment', '=', 'environments.name').andOn('last_seen_at_metrics.feature_name', '=', 'features.name');
})
.leftJoin(this.db
.select('feature as stage_feature', 'stage as latest_stage', 'status as stage_status', 'created_at as entered_stage_at')
.from('feature_lifecycles')
.distinctOn('feature')
.orderBy([
'feature',
{ column: 'created_at', order: 'desc' },
])
.as('lifecycle'), 'features.name', 'lifecycle.stage_feature');
const parsedLifecycle = lifecycle
? parseSearchOperatorValue('lifecycle.latest_stage', lifecycle)
: null;
if (parsedLifecycle) {
applyGenericQueryParams(query, [parsedLifecycle]);
}
const rankingSql = this.buildRankingSql(favoritesFirst, sortBy || '', validatedSortOrder, lastSeenQuery);
query
.select(selectColumns)
.denseRank('rank', this.db.raw(rankingSql));
})
.with('final_ranks', this.db.raw('select feature_name, row_number() over (order by min(rank)) as final_rank from ranked_features group by feature_name'))
.with('total_features', this.db.raw('select count(*) as total from final_ranks'))
.with('metrics', (queryBuilder) => {
queryBuilder
.sum('yes as yes')
.sum('no as no')
.select([
'client_metrics_env.environment as metric_environment',
'client_metrics_env.feature_name as metric_feature_name',
])
.from('client_metrics_env')
.innerJoin('final_ranks', 'client_metrics_env.feature_name', 'final_ranks.feature_name')
.where('client_metrics_env.timestamp', '>=', this.db.raw("NOW() - INTERVAL '1 hour'"))
.groupBy([
'client_metrics_env.feature_name',
'client_metrics_env.environment',
]);
})
.select([
'ranked_features.*',
'total_features.total',
'final_ranks.final_rank',
'metrics.yes',
'metrics.no',
])
.from('ranked_features')
.innerJoin('final_ranks', 'ranked_features.feature_name', 'final_ranks.feature_name')
.joinRaw('CROSS JOIN total_features')
.whereBetween('final_rank', [offset + 1, offset + limit])
.orderBy('final_rank');
this.buildChangeRequestSql(finalQuery);
this.buildReleasePlanSql(finalQuery);
this.queryExtraData(finalQuery);
const rows = await finalQuery;
stopTimer();
if (rows.length > 0) {
const overview = this.getAggregatedSearchData(rows);
const features = sortEnvironments(overview);
return {
features,
total: Number(rows[0].total) || 0,
};
}
return {
features: [],
total: 0,
};
}
/*
This is noncritical data that can should be joined after paging and is not part of filtering/sorting
*/
queryExtraData(queryBuilder) {
this.queryMetrics(queryBuilder);
this.queryStrategiesByEnvironment(queryBuilder);
}
queryMetrics(queryBuilder) {
queryBuilder.leftJoin('metrics', (qb) => {
qb.on('metric_environment', '=', 'ranked_features.environment').andOn('metric_feature_name', '=', 'ranked_features.feature_name');
});
}
queryStrategiesByEnvironment(queryBuilder) {
queryBuilder.select(this.db.raw('has_strategies.feature_name IS NOT NULL AS has_strategies'), this.db.raw('enabled_strategies.feature_name IS NOT NULL AS has_enabled_strategies'));
queryBuilder
.leftJoin(this.db
.select('feature_name', 'environment')
.from('feature_strategies')
.groupBy('feature_name', 'environment')
.where(function () {
this.whereNull('disabled').orWhere('disabled', false);
})
.as('enabled_strategies'), function () {
this.on('enabled_strategies.feature_name', '=', 'ranked_features.feature_name').andOn('enabled_strategies.environment', '=', 'ranked_features.environment');
})
.leftJoin(this.db
.select('feature_name', 'environment')
.from('feature_strategies')
.groupBy('feature_name', 'environment')
.as('has_strategies'), function () {
this.on('has_strategies.feature_name', '=', 'ranked_features.feature_name').andOn('has_strategies.environment', '=', 'ranked_features.environment');
});
}
buildReleasePlanSql(queryBuilder) {
queryBuilder
.leftJoin(this.db
.with('total_milestones', (qb) => {
qb.select('release_plan_definition_id')
.count('* as total_milestones')
.from('milestones')
.groupBy('release_plan_definition_id');
})
.select([
'rpd.feature_name',
'rpd.environment',
'active_milestone.sort_order AS milestone_order',
'total_milestones.total_milestones',
'active_milestone.name AS milestone_name',
])
.from('release_plan_definitions AS rpd')
.join('total_milestones', 'total_milestones.release_plan_definition_id', 'rpd.id')
.join('milestones AS active_milestone', 'active_milestone.id', 'rpd.active_milestone_id')
.where('rpd.discriminator', 'plan')
.as('feature_release_plan'), function () {
this.on('feature_release_plan.feature_name', '=', 'ranked_features.feature_name').andOn('feature_release_plan.environment', '=', 'ranked_features.environment');
})
.select([
'feature_release_plan.milestone_name',
'feature_release_plan.milestone_order',
'feature_release_plan.total_milestones',
]);
}
buildChangeRequestSql(queryBuilder) {
queryBuilder
.leftJoin(this.db('change_request_events AS cre')
.join('change_requests AS cr', 'cre.change_request_id', 'cr.id')
.select('cre.feature')
.select(this.db.raw('array_agg(distinct cre.change_request_id) AS change_request_ids'))
.select('cr.environment')
.groupBy('cre.feature', 'cr.environment')
.whereNotIn('cr.state', [
'Applied',
'Cancelled',
'Rejected',
])
.as('feature_cr'), function () {
this.on('feature_cr.feature', '=', 'ranked_features.feature_name').andOn('feature_cr.environment', '=', 'ranked_features.environment');
})
.select('feature_cr.change_request_ids');
}
buildRankingSql(favoritesFirst, sortBy, validatedSortOrder, lastSeenQuery) {
const sortByMapping = {
name: 'features.name',
type: 'features.type',
stale: 'features.stale',
project: 'features.project',
};
let rankingSql = 'order by ';
if (favoritesFirst) {
rankingSql += 'favorite_features.feature is not null desc, ';
}
if (sortBy.startsWith('environment:')) {
const [, envName] = sortBy.split(':');
rankingSql += this.db
.raw(`CASE WHEN feature_environments.environment = ? THEN feature_environments.enabled ELSE NULL END ${validatedSortOrder} NULLS LAST, features.created_at asc, features.name asc`, [envName])
.toString();
}
else if (sortBy === 'lastSeenAt') {
rankingSql += `${this.db
.raw(`coalesce(${lastSeenQuery}, features.last_seen_at) ${validatedSortOrder} nulls last`)
.toString()}, features.created_at asc, features.name asc`;
}
else if (sortByMapping[sortBy]) {
rankingSql += `${this.db
.raw(`?? ${validatedSortOrder}`, [sortByMapping[sortBy]])
.toString()}, features.created_at asc, features.name asc`;
}
else {
rankingSql += `features.created_at ${validatedSortOrder}, features.name asc`;
}
return rankingSql;
}
getAggregatedSearchData(rows) {
const entriesMap = new Map();
const orderedEntries = [];
rows.forEach((row) => {
let entry = entriesMap.get(row.feature_name);
if (!entry) {
// Create a new entry
const name = row.user_name ||
row.user_username ||
row.user_email ||
'unknown';
entry = {
type: row.type,
description: row.description,
project: row.project,
favorite: row.favorite,
name: row.feature_name,
createdAt: row.created_at,
stale: row.stale,
archivedAt: row.archived_at,
impressionData: row.impression_data,
lastSeenAt: row.last_seen_at,
dependencyType: row.dependency,
environments: [],
segments: row.segment_name ? [row.segment_name] : [],
createdBy: {
id: Number(row.user_id),
name: name,
imageUrl: generateImageUrl({
id: row.user_id,
email: row.user_email,
username: name,
}),
},
};
entry.lifecycle = row.latest_stage
? {
stage: row.latest_stage,
...(row.stage_status
? { status: row.stage_status }
: {}),
enteredStageAt: row.entered_stage_at,
}
: undefined;
entriesMap.set(row.feature_name, entry);
orderedEntries.push(entry);
}
// Add environment if not already present
if (!entry.environments.some((e) => e.name === row.environment)) {
entry.environments.push(FeatureSearchStore.getEnvironment(row));
}
// Add segment if not already present
if (row.segment_name &&
!entry.segments.includes(row.segment_name)) {
entry.segments.push(row.segment_name);
}
// Add tag if new
if (this.isNewTag(entry, row)) {
this.addTag(entry, row);
}
// Update lastSeenAt if more recent
if (!entry.lastSeenAt ||
new Date(row.env_last_seen_at) > new Date(entry.lastSeenAt)) {
entry.lastSeenAt = row.env_last_seen_at;
}
});
return orderedEntries;
}
addTag(featureToggle, row) {
const tags = featureToggle.tags || [];
const newTag = this.rowToTag(row);
featureToggle.tags = [...tags, newTag];
}
rowToTag(r) {
return {
value: r.tag_value,
type: r.tag_type,
color: r.tag_type_color,
};
}
isTagRow(row) {
return row.tag_type && row.tag_value;
}
isNewTag(featureToggle, row) {
return (this.isTagRow(row) &&
!featureToggle.tags?.some((tag) => tag.type === row.tag_type && tag.value === row.tag_value));
}
}
const applyStaleConditions = (query, staleConditions) => {
if (!staleConditions)
return;
const { values, operator } = staleConditions;
if (!values.includes('potentially-stale')) {
applyGenericQueryParams(query, [
{
...staleConditions,
values: values.map((value) => value === 'active' ? 'false' : 'true'),
},
]);
return;
}
const valueSet = new Set(values.filter((value) => ['stale', 'active', 'potentially-stale'].includes(value || '')));
const allSelected = valueSet.size === 3;
const onlyPotentiallyStale = valueSet.size === 1;
const staleAndPotentiallyStale = valueSet.has('stale') && valueSet.size === 2;
if (allSelected) {
switch (operator) {
case 'IS':
case 'IS_ANY_OF':
// All flags included; no action needed
break;
case 'IS_NOT':
case 'IS_NONE_OF':
// All flags excluded
query.whereNotIn('features.stale', [false, true]);
break;
}
return;
}
if (onlyPotentiallyStale) {
switch (operator) {
case 'IS':
case 'IS_ANY_OF':
query
.where('features.stale', false)
.where('features.potentially_stale', true);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.where((qb) => qb
.where('features.stale', true)
.orWhere('features.potentially_stale', false));
break;
}
return;
}
if (staleAndPotentiallyStale) {
switch (operator) {
case 'IS':
case 'IS_ANY_OF':
query.where((qb) => qb
.where('features.stale', true)
.orWhere('features.potentially_stale', true));
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query
.where('features.stale', false)
.where('features.potentially_stale', false);
break;
}
}
else {
switch (operator) {
case 'IS':
case 'IS_ANY_OF':
query.where('features.stale', false);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.where('features.stale', true);
break;
}
}
};
const applyLastSeenAtConditions = (query, lastSeenAtConditions) => {
lastSeenAtConditions.forEach((param) => {
const lastSeenAtExpression = query.client.raw('coalesce(last_seen_at_metrics.last_seen_at, features.last_seen_at)');
switch (param.operator) {
case 'IS_BEFORE':
query.where(lastSeenAtExpression, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(lastSeenAtExpression, '>=', param.values[0]);
break;
}
});
};
const applyQueryParams = (query, queryParams) => {
const tagConditions = queryParams.filter((param) => param.field === 'tag');
const staleConditions = queryParams.find((param) => param.field === 'stale');
const segmentConditions = queryParams.filter((param) => param.field === 'segment');
const lastSeenAtConditions = queryParams.filter((param) => param.field === 'lastSeenAt');
const genericConditions = queryParams.filter((param) => !['tag', 'stale', 'segment', 'lastSeenAt'].includes(param.field));
applyGenericQueryParams(query, genericConditions);
applyStaleConditions(query, staleConditions);
applyLastSeenAtConditions(query, lastSeenAtConditions);
applyMultiQueryParams(query, tagConditions, ['tag_type', 'tag_value'], createTagBaseQuery);
applyMultiQueryParams(query, segmentConditions, 'segments.name', createSegmentBaseQuery);
};
const applyMultiQueryParams = (query, queryParams, fields, createBaseQuery) => {
queryParams.forEach((param) => {
const values = param.values
.filter((v) => typeof v === 'string')
.map((val) => (Array.isArray(fields)
? val.split(/:(.+)/).filter(Boolean)
: [val]).map((s) => s?.trim() || ''));
const baseSubQuery = createBaseQuery(values);
switch (param.operator) {
case 'INCLUDE':
case 'INCLUDE_ANY_OF':
if (Array.isArray(fields)) {
query.whereIn(fields, values);
}
else {
query.whereIn(fields, values.map((v) => v[0]));
}
break;
case 'DO_NOT_INCLUDE':
case 'EXCLUDE_IF_ANY_OF':
query.whereNotIn('features.name', baseSubQuery);
break;
case 'INCLUDE_ALL_OF':
query.whereIn('features.name', (dbSubQuery) => {
baseSubQuery(dbSubQuery)
.groupBy('feature_name')
.havingRaw('COUNT(*) = ?', [values.length]);
});
break;
case 'EXCLUDE_ALL':
query.whereNotIn('features.name', (dbSubQuery) => {
baseSubQuery(dbSubQuery)
.groupBy('feature_name')
.havingRaw('COUNT(*) = ?', [values.length]);
});
break;
}
});
};
const createTagBaseQuery = (tags) => {
return (dbSubQuery) => {
return dbSubQuery
.from('feature_tag')
.select('feature_name')
.whereIn(['tag_type', 'tag_value'], tags);
};
};
const createSegmentBaseQuery = (segments) => {
return (dbSubQuery) => {
return dbSubQuery
.from('feature_strategies')
.leftJoin('feature_strategy_segment', 'feature_strategy_segment.feature_strategy_id', 'feature_strategies.id')
.leftJoin('segments', 'feature_strategy_segment.segment_id', 'segments.id')
.select('feature_name')
.whereIn('name', segments);
};
};
export default FeatureSearchStore;
//# sourceMappingURL=feature-search-store.js.map