From fa087fb4736af324ddd6b604dc5f3a12ec9519cf Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Thu, 14 Dec 2023 15:45:36 +0200 Subject: [PATCH] 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. --- src/lib/db/index.ts | 2 + .../createFeatureSearchService.ts | 13 +- .../fake-feature-search-store.ts | 15 + .../feature-search/feature-search-service.ts | 13 +- .../feature-search-store-type.ts | 15 + .../feature-search/feature-search-store.ts | 520 ++++++++++++++++++ .../fakes/fake-feature-strategies-store.ts | 12 +- .../feature-toggle-strategies-store.ts | 368 ------------- .../feature-toggle-strategies-store-type.ts | 8 - src/lib/types/stores.ts | 3 + src/test/fixtures/store.ts | 2 + 11 files changed, 569 insertions(+), 402 deletions(-) create mode 100644 src/lib/features/feature-search/fake-feature-search-store.ts create mode 100644 src/lib/features/feature-search/feature-search-store-type.ts create mode 100644 src/lib/features/feature-search/feature-search-store.ts diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts index 48209cb6487d..080654e69111 100644 --- a/src/lib/db/index.ts +++ b/src/lib/db/index.ts @@ -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), }; }; diff --git a/src/lib/features/feature-search/createFeatureSearchService.ts b/src/lib/features/feature-search/createFeatureSearchService.ts index fa2d9e5b7cc2..d7c8b49beda9 100644 --- a/src/lib/features/feature-search/createFeatureSearchService.ts +++ b/src/lib/features/feature-search/createFeatureSearchService.ts @@ -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, ); diff --git a/src/lib/features/feature-search/fake-feature-search-store.ts b/src/lib/features/feature-search/fake-feature-search-store.ts new file mode 100644 index 000000000000..90720a83c794 --- /dev/null +++ b/src/lib/features/feature-search/fake-feature-search-store.ts @@ -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.'); + } +} diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 4e65c1ecdfee..74d528549484 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -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, + { featureSearchStore }: Pick, { getLogger }: Pick, ) { - 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, diff --git a/src/lib/features/feature-search/feature-search-store-type.ts b/src/lib/features/feature-search/feature-search-store-type.ts new file mode 100644 index 000000000000..db56c3ab517b --- /dev/null +++ b/src/lib/features/feature-search/feature-search-store-type.ts @@ -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; + }>; +} diff --git a/src/lib/features/feature-search/feature-search-store.ts b/src/lib/features/feature-search/feature-search-store.ts new file mode 100644 index 000000000000..df49a8660ef3 --- /dev/null +++ b/src/lib/features/feature-search/feature-search-store.ts @@ -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 | 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, + row: Record, + ): 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, + row: Record, + ): 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; diff --git a/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts b/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts index e129b67f22ec..ccb7057b58fd 100644 --- a/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts +++ b/src/lib/features/feature-toggle/fakes/fake-feature-strategies-store.ts @@ -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 { return Promise.resolve([]); } - - searchFeatures( - params: IFeatureSearchParams, - ): Promise<{ features: IFeatureOverview[]; total: number }> { - return Promise.resolve({ features: [], total: 0 }); - } - getAllByFeatures( features: string[], environment?: string, diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts index 77cb3fc51c0a..6cc8a369f080 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -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 | 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; diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index 54b55c21b136..79059d24988d 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -91,14 +91,6 @@ export interface IFeatureStrategiesStore params: IFeatureProjectUserParams, ): Promise; - searchFeatures( - params: IFeatureSearchParams, - queryParams: IQueryParam[], - ): Promise<{ - features: IFeatureOverview[]; - total: number; - }>; - getStrategyById(id: string): Promise; updateStrategy( diff --git a/src/lib/types/stores.ts b/src/lib/types/stores.ts index 2d882ef941f5..26124bfcd0d4 100644 --- a/src/lib/types/stores.ts +++ b/src/lib/types/stores.ts @@ -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, }; diff --git a/src/test/fixtures/store.ts b/src/test/fixtures/store.ts index 5cf12c1d9c9c..bfd72cd9e676 100644 --- a/src/test/fixtures/store.ts +++ b/src/test/fixtures/store.ts @@ -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(), }; };