mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +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:
		
							parent
							
								
									fbb5dd9022
								
							
						
					
					
						commit
						fa087fb473
					
				| @ -39,6 +39,7 @@ import { ImportTogglesStore } from '../features/export-import-toggles/import-tog | |||||||
| import PrivateProjectStore from '../features/private-project/privateProjectStore'; | import PrivateProjectStore from '../features/private-project/privateProjectStore'; | ||||||
| import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store'; | import { DependentFeaturesStore } from '../features/dependent-features/dependent-features-store'; | ||||||
| import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store'; | import LastSeenStore from '../services/client-metrics/last-seen/last-seen-store'; | ||||||
|  | import FeatureSearchStore from '../features/feature-search/feature-search-store'; | ||||||
| 
 | 
 | ||||||
| export const createStores = ( | export const createStores = ( | ||||||
|     config: IUnleashConfig, |     config: IUnleashConfig, | ||||||
| @ -139,6 +140,7 @@ export const createStores = ( | |||||||
|         privateProjectStore: new PrivateProjectStore(db, getLogger), |         privateProjectStore: new PrivateProjectStore(db, getLogger), | ||||||
|         dependentFeaturesStore: new DependentFeaturesStore(db), |         dependentFeaturesStore: new DependentFeaturesStore(db), | ||||||
|         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), |         lastSeenStore: new LastSeenStore(db, eventBus, getLogger), | ||||||
|  |         featureSearchStore: new FeatureSearchStore(db, eventBus, getLogger), | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -1,23 +1,22 @@ | |||||||
| import { Db } from '../../db/db'; | import { Db } from '../../db/db'; | ||||||
| import { IUnleashConfig } from '../../types'; | import { IUnleashConfig } from '../../types'; | ||||||
| 
 | 
 | ||||||
| import FeatureStrategiesStore from '../feature-toggle/feature-toggle-strategies-store'; |  | ||||||
| import { FeatureSearchService } from './feature-search-service'; | 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 = | export const createFeatureSearchService = | ||||||
|     (config: IUnleashConfig) => |     (config: IUnleashConfig) => | ||||||
|     (db: Db): FeatureSearchService => { |     (db: Db): FeatureSearchService => { | ||||||
|         const { getLogger, eventBus, flagResolver } = config; |         const { getLogger, eventBus, flagResolver } = config; | ||||||
|         const featureStrategiesStore = new FeatureStrategiesStore( |         const featureSearchStore = new FeatureSearchStore( | ||||||
|             db, |             db, | ||||||
|             eventBus, |             eventBus, | ||||||
|             getLogger, |             getLogger, | ||||||
|             flagResolver, |  | ||||||
|         ); |         ); | ||||||
| 
 | 
 | ||||||
|         return new FeatureSearchService( |         return new FeatureSearchService( | ||||||
|             { featureStrategiesStore: featureStrategiesStore }, |             { featureSearchStore: featureSearchStore }, | ||||||
|             config, |             config, | ||||||
|         ); |         ); | ||||||
|     }; |     }; | ||||||
| @ -25,11 +24,11 @@ export const createFeatureSearchService = | |||||||
| export const createFakeFeatureSearchService = ( | export const createFakeFeatureSearchService = ( | ||||||
|     config: IUnleashConfig, |     config: IUnleashConfig, | ||||||
| ): FeatureSearchService => { | ): FeatureSearchService => { | ||||||
|     const fakeFeatureStrategiesStore = new FakeFeatureStrategiesStore(); |     const fakeFeatureSearchStore = new FakeFeatureSearchStore(); | ||||||
| 
 | 
 | ||||||
|     return new FeatureSearchService( |     return new FeatureSearchService( | ||||||
|         { |         { | ||||||
|             featureStrategiesStore: fakeFeatureStrategiesStore, |             featureSearchStore: fakeFeatureSearchStore, | ||||||
|         }, |         }, | ||||||
|         config, |         config, | ||||||
|     ); |     ); | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								src/lib/features/feature-search/fake-feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/lib/features/feature-search/fake-feature-search-store.ts
									
									
									
									
									
										Normal 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.'); | ||||||
|  |     } | ||||||
|  | } | ||||||
| @ -1,9 +1,8 @@ | |||||||
| import { Logger } from '../../logger'; | import { Logger } from '../../logger'; | ||||||
| import { | import { | ||||||
|     IFeatureStrategiesStore, |     IFeatureSearchStore, | ||||||
|     IUnleashConfig, |     IUnleashConfig, | ||||||
|     IUnleashStores, |     IUnleashStores, | ||||||
|     serializeDates, |  | ||||||
| } from '../../types'; | } from '../../types'; | ||||||
| import { | import { | ||||||
|     IFeatureSearchParams, |     IFeatureSearchParams, | ||||||
| @ -12,22 +11,20 @@ import { | |||||||
| } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | } from '../feature-toggle/types/feature-toggle-strategies-store-type'; | ||||||
| 
 | 
 | ||||||
| export class FeatureSearchService { | export class FeatureSearchService { | ||||||
|     private featureStrategiesStore: IFeatureStrategiesStore; |     private featureSearchStore: IFeatureSearchStore; | ||||||
|     private logger: Logger; |     private logger: Logger; | ||||||
|     constructor( |     constructor( | ||||||
|         { |         { featureSearchStore }: Pick<IUnleashStores, 'featureSearchStore'>, | ||||||
|             featureStrategiesStore, |  | ||||||
|         }: Pick<IUnleashStores, 'featureStrategiesStore'>, |  | ||||||
|         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, |         { getLogger }: Pick<IUnleashConfig, 'getLogger'>, | ||||||
|     ) { |     ) { | ||||||
|         this.featureStrategiesStore = featureStrategiesStore; |         this.featureSearchStore = featureSearchStore; | ||||||
|         this.logger = getLogger('services/feature-search-service.ts'); |         this.logger = getLogger('services/feature-search-service.ts'); | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
|     async search(params: IFeatureSearchParams) { |     async search(params: IFeatureSearchParams) { | ||||||
|         const queryParams = this.convertToQueryParams(params); |         const queryParams = this.convertToQueryParams(params); | ||||||
|         const { features, total } = |         const { features, total } = | ||||||
|             await this.featureStrategiesStore.searchFeatures( |             await this.featureSearchStore.searchFeatures( | ||||||
|                 { |                 { | ||||||
|                     ...params, |                     ...params, | ||||||
|                     limit: params.limit, |                     limit: params.limit, | ||||||
|  | |||||||
							
								
								
									
										15
									
								
								src/lib/features/feature-search/feature-search-store-type.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								src/lib/features/feature-search/feature-search-store-type.ts
									
									
									
									
									
										Normal 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; | ||||||
|  |     }>; | ||||||
|  | } | ||||||
							
								
								
									
										520
									
								
								src/lib/features/feature-search/feature-search-store.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										520
									
								
								src/lib/features/feature-search/feature-search-store.ts
									
									
									
									
									
										Normal 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; | ||||||
| @ -8,10 +8,7 @@ import { | |||||||
|     FeatureToggle, |     FeatureToggle, | ||||||
| } from '../../../types/model'; | } from '../../../types/model'; | ||||||
| import NotFoundError from '../../../error/notfound-error'; | import NotFoundError from '../../../error/notfound-error'; | ||||||
| import { | import { IFeatureStrategiesStore } from '../types/feature-toggle-strategies-store-type'; | ||||||
|     IFeatureSearchParams, |  | ||||||
|     IFeatureStrategiesStore, |  | ||||||
| } from '../types/feature-toggle-strategies-store-type'; |  | ||||||
| import { IFeatureProjectUserParams } from '../feature-toggle-controller'; | import { IFeatureProjectUserParams } from '../feature-toggle-controller'; | ||||||
| 
 | 
 | ||||||
| interface ProjectEnvironment { | interface ProjectEnvironment { | ||||||
| @ -324,13 +321,6 @@ export default class FakeFeatureStrategiesStore | |||||||
|     ): Promise<IFeatureOverview[]> { |     ): Promise<IFeatureOverview[]> { | ||||||
|         return Promise.resolve([]); |         return Promise.resolve([]); | ||||||
|     } |     } | ||||||
| 
 |  | ||||||
|     searchFeatures( |  | ||||||
|         params: IFeatureSearchParams, |  | ||||||
|     ): Promise<{ features: IFeatureOverview[]; total: number }> { |  | ||||||
|         return Promise.resolve({ features: [], total: 0 }); |  | ||||||
|     } |  | ||||||
| 
 |  | ||||||
|     getAllByFeatures( |     getAllByFeatures( | ||||||
|         features: string[], |         features: string[], | ||||||
|         environment?: string, |         environment?: string, | ||||||
|  | |||||||
| @ -25,10 +25,6 @@ import { ensureStringValue, mapValues } from '../../util'; | |||||||
| import { IFeatureProjectUserParams } from './feature-toggle-controller'; | import { IFeatureProjectUserParams } from './feature-toggle-controller'; | ||||||
| import { Db } from '../../db/db'; | import { Db } from '../../db/db'; | ||||||
| import Raw = Knex.Raw; | import Raw = Knex.Raw; | ||||||
| import { |  | ||||||
|     IFeatureSearchParams, |  | ||||||
|     IQueryParam, |  | ||||||
| } from './types/feature-toggle-strategies-store-type'; |  | ||||||
| 
 | 
 | ||||||
| const COLUMNS = [ | const COLUMNS = [ | ||||||
|     'id', |     '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({ |     async getFeatureOverview({ | ||||||
|         projectId, |         projectId, | ||||||
|         archived, |         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; | module.exports = FeatureStrategiesStore; | ||||||
| export default FeatureStrategiesStore; | export default FeatureStrategiesStore; | ||||||
|  | |||||||
| @ -91,14 +91,6 @@ export interface IFeatureStrategiesStore | |||||||
|         params: IFeatureProjectUserParams, |         params: IFeatureProjectUserParams, | ||||||
|     ): Promise<IFeatureOverview[]>; |     ): Promise<IFeatureOverview[]>; | ||||||
| 
 | 
 | ||||||
|     searchFeatures( |  | ||||||
|         params: IFeatureSearchParams, |  | ||||||
|         queryParams: IQueryParam[], |  | ||||||
|     ): Promise<{ |  | ||||||
|         features: IFeatureOverview[]; |  | ||||||
|         total: number; |  | ||||||
|     }>; |  | ||||||
| 
 |  | ||||||
|     getStrategyById(id: string): Promise<IFeatureStrategy>; |     getStrategyById(id: string): Promise<IFeatureStrategy>; | ||||||
| 
 | 
 | ||||||
|     updateStrategy( |     updateStrategy( | ||||||
|  | |||||||
| @ -36,6 +36,7 @@ import { IImportTogglesStore } from '../features/export-import-toggles/import-to | |||||||
| import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType'; | import { IPrivateProjectStore } from '../features/private-project/privateProjectStoreType'; | ||||||
| import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type'; | import { IDependentFeaturesStore } from '../features/dependent-features/dependent-features-store-type'; | ||||||
| import { ILastSeenStore } from '../services/client-metrics/last-seen/types/last-seen-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 { | export interface IUnleashStores { | ||||||
|     accessStore: IAccessStore; |     accessStore: IAccessStore; | ||||||
| @ -76,6 +77,7 @@ export interface IUnleashStores { | |||||||
|     privateProjectStore: IPrivateProjectStore; |     privateProjectStore: IPrivateProjectStore; | ||||||
|     dependentFeaturesStore: IDependentFeaturesStore; |     dependentFeaturesStore: IDependentFeaturesStore; | ||||||
|     lastSeenStore: ILastSeenStore; |     lastSeenStore: ILastSeenStore; | ||||||
|  |     featureSearchStore: IFeatureSearchStore; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export { | export { | ||||||
| @ -116,4 +118,5 @@ export { | |||||||
|     IPrivateProjectStore, |     IPrivateProjectStore, | ||||||
|     IDependentFeaturesStore, |     IDependentFeaturesStore, | ||||||
|     ILastSeenStore, |     ILastSeenStore, | ||||||
|  |     IFeatureSearchStore, | ||||||
| }; | }; | ||||||
|  | |||||||
							
								
								
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										2
									
								
								src/test/fixtures/store.ts
									
									
									
									
										vendored
									
									
								
							| @ -39,6 +39,7 @@ import { FakeAccountStore } from './fake-account-store'; | |||||||
| import FakeProjectStatsStore from './fake-project-stats-store'; | import FakeProjectStatsStore from './fake-project-stats-store'; | ||||||
| import { FakeDependentFeaturesStore } from '../../lib/features/dependent-features/fake-dependent-features-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 { 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 = { | const db = { | ||||||
|     select: () => ({ |     select: () => ({ | ||||||
| @ -87,6 +88,7 @@ const createStores: () => IUnleashStores = () => { | |||||||
|         privateProjectStore: {} as IPrivateProjectStore, |         privateProjectStore: {} as IPrivateProjectStore, | ||||||
|         dependentFeaturesStore: new FakeDependentFeaturesStore(), |         dependentFeaturesStore: new FakeDependentFeaturesStore(), | ||||||
|         lastSeenStore: new FakeLastSeenStore(), |         lastSeenStore: new FakeLastSeenStore(), | ||||||
|  |         featureSearchStore: new FakeFeatureSearchStore(), | ||||||
|     }; |     }; | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user