1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-02-23 00:22:19 +01:00

refactor: move search implementation out of strategies store (#5642)

This is first step of refactoring. Next steps follow with possibly a
query builder, or atleast using some reusable methods.
This commit is contained in:
Jaanus Sellin 2023-12-14 15:45:36 +02:00 committed by GitHub
parent fbb5dd9022
commit fa087fb473
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 569 additions and 402 deletions

View File

@ -39,6 +39,7 @@ import { ImportTogglesStore } from '../features/export-import-toggles/import-tog
import PrivateProjectStore from '../features/private-project/privateProjectStore';
import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store';
import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store';
import FeatureSearchStore from '../features/feature-search/feature-search-store';
export const createStores = (
config: IUnleashConfig,
@ -139,6 +140,7 @@ export const createStores = (
privateProjectStore: new PrivateProjectStore(db, getLogger),
dependentFeaturesStore: new DependentFeaturesStore(db),
lastSeenStore: new LastSeenStore(db, eventBus, getLogger),
featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger),
};
};

View File

@ -1,23 +1,22 @@
import { Db } from '../../db/db';
import { IUnleashConfig } from '../../types';
import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store';
import { FeatureSearchService } from './feature-search-service';
import FakeFeatureStrategiesStore from '../feature-toggle/fakes/fake-feature-strategies-store';
import FakeFeatureSearchStore from './fake-feature-search-store';
import FeatureSearchStore from './feature-search-store';
export const createFeatureSearchService =
(config: IUnleashConfig) =>
(db: Db): FeatureSearchService => {
const { getLogger, eventBus, flagResolver } = config;
const featureStrategiesStore = new FeatureStrategiesStore(
const featureSearchStore = new FeatureSearchStore(
db,
eventBus,
getLogger,
flagResolver,
);
return new FeatureSearchService(
{ featureStrategiesStore: featureStrategiesStore },
{ featureSearchStore: featureSearchStore },
config,
);
};
@ -25,11 +24,11 @@ export const createFeatureSearchService =
export const createFakeFeatureSearchService = (
config: IUnleashConfig,
): FeatureSearchService => {
const fakeFeatureStrategiesStore = new FakeFeatureStrategiesStore();
const fakeFeatureSearchStore = new FakeFeatureSearchStore();
return new FeatureSearchService(
{
featureStrategiesStore: fakeFeatureStrategiesStore,
featureSearchStore: fakeFeatureSearchStore,
},
config,
);

View File

@ -0,0 +1,15 @@
import { IFeatureOverview } from 'lib/types';
import {
IFeatureSearchParams,
IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { IFeatureSearchStore } from './feature-search-store-type';
export default class FakeFeatureSearchStore implements IFeatureSearchStore {
searchFeatures(
params: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{ features: IFeatureOverview[]; total: number }> {
throw new Error('Method not implemented.');
}
}

View File

@ -1,9 +1,8 @@
import { Logger } from '../../logger';
import {
IFeatureStrategiesStore,
IFeatureSearchStore,
IUnleashConfig,
IUnleashStores,
serializeDates,
} from '../../types';
import {
IFeatureSearchParams,
@ -12,22 +11,20 @@ import {
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
export class FeatureSearchService {
private featureStrategiesStore: IFeatureStrategiesStore;
private featureSearchStore: IFeatureSearchStore;
private logger: Logger;
constructor(
{
featureStrategiesStore,
}: Pick<IUnleashStores, 'featureStrategiesStore'>,
{ featureSearchStore }: Pick<IUnleashStores, 'featureSearchStore'>,
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
) {
this.featureStrategiesStore = featureStrategiesStore;
this.featureSearchStore = featureSearchStore;
this.logger = getLogger('services/feature-search-service.ts');
}
async search(params: IFeatureSearchParams) {
const queryParams = this.convertToQueryParams(params);
const { features, total } =
await this.featureStrategiesStore.searchFeatures(
await this.featureSearchStore.searchFeatures(
{
...params,
limit: params.limit,

View File

@ -0,0 +1,15 @@
import {
IFeatureSearchParams,
IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
import { IFeatureOverview } from '../../types';
export interface IFeatureSearchStore {
searchFeatures(
params: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{
features: IFeatureOverview[];
total: number;
}>;
}

View File

@ -0,0 +1,520 @@
import { Knex } from 'knex';
import EventEmitter from 'events';
import metricsHelper from '../../util/metrics-helper';
import { DB_TIME } from '../../metric-events';
import { Logger, LogProvider } from '../../logger';
import {
IEnvironmentOverview,
IFeatureOverview,
IFeatureSearchStore,
IFlagResolver,
ITag,
} from '../../types';
import FeatureToggleStore from '../feature-toggle/feature-toggle-store';
import { Db } from '../../db/db';
import Raw = Knex.Raw;
import {
IFeatureSearchParams,
IQueryParam,
} from '../feature-toggle/types/feature-toggle-strategies-store-type';
import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store';
const sortEnvironments = (overview: IFeatureOverview) => {
return Object.values(overview).map((data: IFeatureOverview) => ({
...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 implements IFeatureSearchStore {
private db: Db;
private logger: Logger;
private readonly timer: Function;
constructor(db: Db, eventBus: EventEmitter, getLogger: LogProvider) {
this.db = db;
this.logger = getLogger('feature-search-store.ts');
this.timer = (action) =>
metricsHelper.wrapTimer(eventBus, DB_TIME, {
store: 'feature-search',
action,
});
}
private static getEnvironment(r: any): IEnvironmentOverview {
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,
};
}
async searchFeatures(
{
userId,
searchParams,
type,
status,
offset,
limit,
sortOrder,
sortBy,
favoritesFirst,
}: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{
features: IFeatureOverview[];
total: number;
}> {
const stopTimer = this.timer('searchFeatures');
const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const finalQuery = this.db
.with('ranked_features', (query) => {
query.from('features');
applyQueryParams(query, queryParams);
const hasSearchParams = searchParams?.length;
if (hasSearchParams) {
const sqlParameters = searchParams.map(
(item) => `%${item}%`,
);
const sqlQueryParameters = sqlParameters
.map(() => '?')
.join(',');
query.where((builder) => {
builder
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.name', ...sqlParameters],
)
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.description', ...sqlParameters],
);
});
}
if (type) {
query.whereIn('features.type', type);
}
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' ? true : false,
);
});
}
});
}
query
.modify(FeatureToggleStore.filterByArchived, false)
.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(
'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',
);
query.leftJoin('last_seen_at_metrics', function () {
this.on(
'last_seen_at_metrics.environment',
'=',
'environments.name',
).andOn(
'last_seen_at_metrics.feature_name',
'=',
'features.name',
);
});
let selectColumns = [
'features.name as feature_name',
'features.description as description',
'features.type as type',
'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',
'segments.name as segment_name',
] as (string | Raw<any> | Knex.QueryBuilder)[];
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(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
),
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
),
];
const sortByMapping = {
name: 'features.name',
type: 'features.type',
lastSeenAt: lastSeenQuery,
};
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 (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`;
}
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'),
)
.select('*')
.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]);
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,
};
}
getAggregatedSearchData(rows): IFeatureOverview {
return rows.reduce((acc, row) => {
if (acc[row.feature_name] !== undefined) {
const environmentExists = acc[
row.feature_name
].environments.some(
(existingEnvironment) =>
existingEnvironment.name === row.environment,
);
if (!environmentExists) {
acc[row.feature_name].environments.push(
FeatureSearchStore.getEnvironment(row),
);
}
const segmentExists = acc[row.feature_name].segments.includes(
row.segment_name,
);
if (row.segment_name && !segmentExists) {
acc[row.feature_name].segments.push(row.segment_name);
}
if (this.isNewTag(acc[row.feature_name], row)) {
this.addTag(acc[row.feature_name], row);
}
} else {
acc[row.feature_name] = {
type: row.type,
description: row.description,
project: row.project,
favorite: row.favorite,
name: row.feature_name,
createdAt: row.created_at,
stale: row.stale,
impressionData: row.impression_data,
lastSeenAt: row.last_seen_at,
environments: [FeatureSearchStore.getEnvironment(row)],
segments: row.segment_name ? [row.segment_name] : [],
};
if (this.isNewTag(acc[row.feature_name], row)) {
this.addTag(acc[row.feature_name], row);
}
}
const featureRow = acc[row.feature_name];
if (
featureRow.lastSeenAt === undefined ||
new Date(row.env_last_seen_at) >
new Date(featureRow.last_seen_at)
) {
featureRow.lastSeenAt = row.env_last_seen_at;
}
return acc;
}, {});
}
private addTag(
featureToggle: Record<string, any>,
row: Record<string, any>,
): void {
const tags = featureToggle.tags || [];
const newTag = this.rowToTag(row);
featureToggle.tags = [...tags, newTag];
}
private rowToTag(r: any): ITag {
return {
value: r.tag_value,
type: r.tag_type,
};
}
private isNewTag(
featureToggle: Record<string, any>,
row: Record<string, any>,
): boolean {
return (
row.tag_type &&
row.tag_value &&
!featureToggle.tags?.some(
(tag) =>
tag.type === row.tag_type && tag.value === row.tag_value,
)
);
}
}
const applyQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
const tagConditions = queryParams.filter((param) => param.field === 'tag');
const segmentConditions = queryParams.filter(
(param) => param.field === 'segment',
);
const genericConditions = queryParams.filter(
(param) => param.field !== 'tag',
);
applyGenericQueryParams(query, genericConditions);
applyMultiQueryParams(
query,
tagConditions,
['tag_type', 'tag_value'],
createTagBaseQuery,
);
applyMultiQueryParams(
query,
segmentConditions,
'segments.name',
createSegmentBaseQuery,
);
};
const applyGenericQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
queryParams.forEach((param) => {
switch (param.operator) {
case 'IS':
case 'IS_ANY_OF':
query.whereIn(param.field, param.values);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.whereNotIn(param.field, param.values);
break;
case 'IS_BEFORE':
query.where(param.field, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(param.field, '>=', param.values[0]);
break;
}
});
};
const applyMultiQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
fields: string | string[],
createBaseQuery: (
values: string[] | string[][],
) => (dbSubQuery: Knex.QueryBuilder) => Knex.QueryBuilder,
): void => {
queryParams.forEach((param) => {
const values = param.values.map((val) =>
(Array.isArray(fields) ? val.split(':') : [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: string[][]) => {
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
return dbSubQuery
.from('feature_tag')
.select('feature_name')
.whereIn(['tag_type', 'tag_value'], tags);
};
};
const createSegmentBaseQuery = (segments: string[]) => {
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
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);
};
};
module.exports = FeatureSearchStore;
export default FeatureSearchStore;

View File

@ -8,10 +8,7 @@ import {
FeatureToggle,
} from '../../../types/model';
import NotFoundError from '../../../error/notfound-error';
import {
IFeatureSearchParams,
IFeatureStrategiesStore,
} from '../types/feature-toggle-strategies-store-type';
import { IFeatureStrategiesStore } from '../types/feature-toggle-strategies-store-type';
import { IFeatureProjectUserParams } from '../feature-toggle-controller';
interface ProjectEnvironment {
@ -324,13 +321,6 @@ export default class FakeFeatureStrategiesStore
): Promise<IFeatureOverview[]> {
return Promise.resolve([]);
}
searchFeatures(
params: IFeatureSearchParams,
): Promise<{ features: IFeatureOverview[]; total: number }> {
return Promise.resolve({ features: [], total: 0 });
}
getAllByFeatures(
features: string[],
environment?: string,

View File

@ -25,10 +25,6 @@ import { ensureStringValue, mapValues } from '../../util';
import { IFeatureProjectUserParams } from './feature-toggle-controller';
import { Db } from '../../db/db';
import Raw = Knex.Raw;
import {
IFeatureSearchParams,
IQueryParam,
} from './types/feature-toggle-strategies-store-type';
const COLUMNS = [
'id',
@ -527,237 +523,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
};
}
async searchFeatures(
{
userId,
searchParams,
type,
tag,
status,
offset,
limit,
sortOrder,
sortBy,
favoritesFirst,
}: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{
features: IFeatureOverview[];
total: number;
}> {
const validatedSortOrder =
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
const finalQuery = this.db
.with('ranked_features', (query) => {
query.from('features');
applyQueryParams(query, queryParams);
const hasSearchParams = searchParams?.length;
if (hasSearchParams) {
const sqlParameters = searchParams.map(
(item) => `%${item}%`,
);
const sqlQueryParameters = sqlParameters
.map(() => '?')
.join(',');
query.where((builder) => {
builder
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.name', ...sqlParameters],
)
.orWhereRaw(
`(??) ILIKE ANY (ARRAY[${sqlQueryParameters}])`,
['features.description', ...sqlParameters],
);
});
}
if (type) {
query.whereIn('features.type', type);
}
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' ? true : false,
);
});
}
});
}
query
.modify(FeatureToggleStore.filterByArchived, false)
.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(
'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',
);
query.leftJoin('last_seen_at_metrics', function () {
this.on(
'last_seen_at_metrics.environment',
'=',
'environments.name',
).andOn(
'last_seen_at_metrics.feature_name',
'=',
'features.name',
);
});
let selectColumns = [
'features.name as feature_name',
'features.description as description',
'features.type as type',
'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',
'segments.name as segment_name',
] as (string | Raw<any> | Knex.QueryBuilder)[];
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(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
),
this.db.raw(
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
),
];
const sortByMapping = {
name: 'features.name',
type: 'features.type',
lastSeenAt: lastSeenQuery,
};
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 (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`;
}
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'),
)
.select('*')
.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]);
const rows = await finalQuery;
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,
};
}
async getFeatureOverview({
projectId,
archived,
@ -1090,138 +855,5 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
}
}
const applyQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
const tagConditions = queryParams.filter((param) => param.field === 'tag');
const segmentConditions = queryParams.filter(
(param) => param.field === 'segment',
);
const genericConditions = queryParams.filter(
(param) => param.field !== 'tag',
);
applyGenericQueryParams(query, genericConditions);
applyMultiQueryParams(
query,
tagConditions,
['tag_type', 'tag_value'],
createTagBaseQuery,
);
applyMultiQueryParams(
query,
segmentConditions,
'segments.name',
createSegmentBaseQuery,
);
};
const applyGenericQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
): void => {
queryParams.forEach((param) => {
switch (param.operator) {
case 'IS':
case 'IS_ANY_OF':
query.whereIn(param.field, param.values);
break;
case 'IS_NOT':
case 'IS_NONE_OF':
query.whereNotIn(param.field, param.values);
break;
case 'IS_BEFORE':
query.where(param.field, '<', param.values[0]);
break;
case 'IS_ON_OR_AFTER':
query.where(param.field, '>=', param.values[0]);
break;
}
});
};
const applyMultiQueryParams = (
query: Knex.QueryBuilder,
queryParams: IQueryParam[],
fields: string | string[],
createBaseQuery: (
values: string[] | string[][],
) => (dbSubQuery: Knex.QueryBuilder) => Knex.QueryBuilder,
): void => {
queryParams.forEach((param) => {
const values = param.values.map((val) =>
(Array.isArray(fields) ? val.split(':') : [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: string[][]) => {
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
return dbSubQuery
.from('feature_tag')
.select('feature_name')
.whereIn(['tag_type', 'tag_value'], tags);
};
};
const createSegmentBaseQuery = (segments: string[]) => {
return (dbSubQuery: Knex.QueryBuilder): Knex.QueryBuilder => {
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);
};
};
module.exports = FeatureStrategiesStore;
export default FeatureStrategiesStore;

View File

@ -91,14 +91,6 @@ export interface IFeatureStrategiesStore
params: IFeatureProjectUserParams,
): Promise<IFeatureOverview[]>;
searchFeatures(
params: IFeatureSearchParams,
queryParams: IQueryParam[],
): Promise<{
features: IFeatureOverview[];
total: number;
}>;
getStrategyById(id: string): Promise<IFeatureStrategy>;
updateStrategy(

View File

@ -36,6 +36,7 @@ import { IImportTogglesStore } from '../features/export-import-toggles/import-to
import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType';
import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type';
import { ILastSeenStore } from '../services/client-metrics/last-seen/types/last-seen-store-type';
import { IFeatureSearchStore } from '../features/feature-search/feature-search-store-type';
export interface IUnleashStores {
accessStore: IAccessStore;
@ -76,6 +77,7 @@ export interface IUnleashStores {
privateProjectStore: IPrivateProjectStore;
dependentFeaturesStore: IDependentFeaturesStore;
lastSeenStore: ILastSeenStore;
featureSearchStore: IFeatureSearchStore;
}
export {
@ -116,4 +118,5 @@ export {
IPrivateProjectStore,
IDependentFeaturesStore,
ILastSeenStore,
IFeatureSearchStore,
};

View File

@ -39,6 +39,7 @@ import { FakeAccountStore } from './fake-account-store';
import FakeProjectStatsStore from './fake-project-stats-store';
import { FakeDependentFeaturesStore } from '../../lib/features/dependent-features/fake-dependent-features-store';
import { FakeLastSeenStore } from '../../lib/services/client-metrics/last-seen/fake-last-seen-store';
import FakeFeatureSearchStore from '../../lib/features/feature-search/fake-feature-search-store';
const db = {
select: () => ({
@ -87,6 +88,7 @@ const createStores: () => IUnleashStores = () => {
privateProjectStore: {} as IPrivateProjectStore,
dependentFeaturesStore: new FakeDependentFeaturesStore(),
lastSeenStore: new FakeLastSeenStore(),
featureSearchStore: new FakeFeatureSearchStore(),
};
};