x-pack/platform/plugins/shared/ml/server/saved_objects/service.ts (730 lines of code) (raw):

/* * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one * or more contributor license agreements. Licensed under the Elastic License * 2.0; you may not use this file except in compliance with the Elastic License * 2.0. */ import { memoize } from 'lodash'; import type { KibanaRequest, SavedObjectsClientContract, SavedObjectsFindOptions, SavedObjectsFindResult, IScopedClusterClient, } from '@kbn/core/server'; import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; import type { JobType, TrainedModelType, SavedObjectResult, MlSavedObjectType, } from '../../common/types/saved_objects'; import { ML_JOB_SAVED_OBJECT_TYPE, ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, } from '../../common/types/saved_objects'; import { MLJobNotFound, MLModelNotFound } from '../lib/ml_client'; import { getSavedObjectClientError } from './util'; import { authorizationProvider } from './authorization'; export interface JobObject { job_id: string; datafeed_id: string | null; type: JobType; } type JobObjectFilter = { [k in keyof JobObject]?: string }; export interface TrainedModelObject { model_id: string; job: null | TrainedModelJob; } export interface TrainedModelJob { job_id: string; create_time: number; } type TrainedModelObjectFilter = { [k in keyof TrainedModelObject]?: string }; export type MLSavedObjectService = ReturnType<typeof mlSavedObjectServiceFactory>; export function mlSavedObjectServiceFactory( savedObjectsClient: SavedObjectsClientContract, internalSavedObjectsClient: SavedObjectsClientContract, spacesEnabled: boolean, authorization: SecurityPluginSetup['authz'] | undefined, client: IScopedClusterClient, isMlReady: () => Promise<void> ) { const _savedObjectsClientFindMemo = memoize( async <T>(options: SavedObjectsFindOptions) => savedObjectsClient.find<T>(options), (options: SavedObjectsFindOptions) => JSON.stringify(options) ); function _clearSavedObjectsClientCache() { if (_savedObjectsClientFindMemo.cache.clear) { _savedObjectsClientFindMemo.cache.clear(); } } async function _getJobObjects( jobType?: JobType, jobId?: string, datafeedId?: string, currentSpaceOnly: boolean = true ) { await isMlReady(); const filterObject: JobObjectFilter = Object.create(null); if (jobType !== undefined) { filterObject.type = jobType; } if (jobId !== undefined) { filterObject.job_id = jobId; } else if (datafeedId !== undefined) { filterObject.datafeed_id = datafeedId; } const { filter, searchFields } = createSavedObjectFilter( filterObject, ML_JOB_SAVED_OBJECT_TYPE ); const options: SavedObjectsFindOptions = { type: ML_JOB_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), searchFields, filter, }; const jobs = await _savedObjectsClientFindMemo<JobObject>(options); return jobs.saved_objects; } async function _createJob( jobType: JobType, jobId: string, datafeedId?: string, addToAllSpaces = false ) { await isMlReady(); const job: JobObject = { job_id: jobId, datafeed_id: datafeedId ?? null, type: jobType, }; const id = _jobSavedObjectId(job); try { const [existingJobObject] = await getAllJobObjectsForAllSpaces(jobType, jobId); if (existingJobObject !== undefined) { // a saved object for this job already exists, this may be left over from a previously deleted job if (existingJobObject.namespaces?.length) { // use a force delete just in case the saved object exists only in another space. await _forceDeleteJob(jobType, jobId, existingJobObject.namespaces[0]); } else { // the saved object has no spaces, this is unexpected, attempt a normal delete await savedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, id, { force: true }); _clearSavedObjectsClientCache(); } } } catch (error) { // the saved object may exist if a previous job with the same ID has been deleted. // if not, this error will be throw which we ignore. } await savedObjectsClient.create<JobObject>(ML_JOB_SAVED_OBJECT_TYPE, job, { id, ...(addToAllSpaces ? { initialNamespaces: ['*'] } : {}), }); _clearSavedObjectsClientCache(); } async function _bulkCreateJobs(jobs: Array<{ job: JobObject; namespaces: string[] }>) { await isMlReady(); const results = await savedObjectsClient.bulkCreate<JobObject>( jobs.map((j) => ({ type: ML_JOB_SAVED_OBJECT_TYPE, id: _jobSavedObjectId(j.job), attributes: j.job, initialNamespaces: j.namespaces, })) ); _clearSavedObjectsClientCache(); return results; } function _jobSavedObjectId(job: JobObject) { return `${job.type}-${job.job_id}`; } async function _deleteJob(jobType: JobType, jobId: string) { const jobs = await _getJobObjects(jobType, jobId); const job = jobs[0]; if (job === undefined) { throw new MLJobNotFound('job not found'); } await savedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, job.id, { force: true }); _clearSavedObjectsClientCache(); } async function _forceDeleteJob(jobType: JobType, jobId: string, namespace: string) { const id = _jobSavedObjectId({ job_id: jobId, datafeed_id: null, type: jobType, }); // * space cannot be used in a delete call, so use undefined which // is the same as specifying the default space await internalSavedObjectsClient.delete(ML_JOB_SAVED_OBJECT_TYPE, id, { namespace: namespace === '*' ? undefined : namespace, force: true, }); _clearSavedObjectsClientCache(); } async function createAnomalyDetectionJob( jobId: string, datafeedId?: string, addToAllSpaces = false ) { await _createJob('anomaly-detector', jobId, datafeedId, addToAllSpaces); } async function deleteAnomalyDetectionJob(jobId: string) { await _deleteJob('anomaly-detector', jobId); } async function forceDeleteAnomalyDetectionJob(jobId: string, namespace: string) { await _forceDeleteJob('anomaly-detector', jobId, namespace); } async function createDataFrameAnalyticsJob(jobId: string, addToAllSpaces = false) { await _createJob('data-frame-analytics', jobId, undefined, addToAllSpaces); } async function deleteDataFrameAnalyticsJob(jobId: string) { await _deleteJob('data-frame-analytics', jobId); } async function forceDeleteDataFrameAnalyticsJob(jobId: string, namespace: string) { await _forceDeleteJob('data-frame-analytics', jobId, namespace); } async function bulkCreateJobs(jobs: Array<{ job: JobObject; namespaces: string[] }>) { return await _bulkCreateJobs(jobs); } async function getAllJobObjects(jobType?: JobType, currentSpaceOnly: boolean = true) { return await _getJobObjects(jobType, undefined, undefined, currentSpaceOnly); } async function getJobObject( jobType: JobType, jobId: string, currentSpaceOnly: boolean = true ): Promise<SavedObjectsFindResult<JobObject> | undefined> { const [jobObject] = await _getJobObjects(jobType, jobId, undefined, currentSpaceOnly); return jobObject; } async function getAllJobObjectsForAllSpaces(jobType?: JobType, jobId?: string) { await isMlReady(); const filterObject: JobObjectFilter = Object.create(null); if (jobType !== undefined) { filterObject.type = jobType; } if (jobId !== undefined) { filterObject.job_id = jobId; } const { filter, searchFields } = createSavedObjectFilter( filterObject, ML_JOB_SAVED_OBJECT_TYPE ); const options: SavedObjectsFindOptions = { type: ML_JOB_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false ? {} : { namespaces: ['*'] }), searchFields, filter, }; return (await internalSavedObjectsClient.find<JobObject>(options)).saved_objects; } async function addDatafeed(datafeedId: string, jobId: string) { const jobs = await _getJobObjects('anomaly-detector', jobId); const job = jobs[0]; if (job === undefined) { throw new MLJobNotFound(`'${datafeedId}' not found`); } const jobObject = job.attributes; jobObject.datafeed_id = datafeedId; await savedObjectsClient.update<JobObject>(ML_JOB_SAVED_OBJECT_TYPE, job.id, jobObject); _clearSavedObjectsClientCache(); } async function deleteDatafeed(datafeedId: string) { const jobs = await _getJobObjects('anomaly-detector', undefined, datafeedId); const job = jobs[0]; if (job === undefined) { throw new MLJobNotFound(`'${datafeedId}' not found`); } const jobObject = job.attributes; jobObject.datafeed_id = null; await savedObjectsClient.update<JobObject>(ML_JOB_SAVED_OBJECT_TYPE, job.id, jobObject); _clearSavedObjectsClientCache(); } async function _getIds(jobType: JobType, idType: keyof JobObject) { const jobs = await _getJobObjects(jobType); return jobs.map((o) => o.attributes[idType]); } async function _filterJobObjectsForSpace<T>( jobType: JobType, list: T[], field: keyof T, key: keyof JobObject ): Promise<T[]> { if (list.length === 0) { return []; } const jobIds = await _getIds(jobType, key); return list.filter((j) => jobIds.includes(j[field] as unknown as string)); } async function filterJobsForSpace<T>(jobType: JobType, list: T[], field: keyof T): Promise<T[]> { return _filterJobObjectsForSpace<T>(jobType, list, field, 'job_id'); } async function filterDatafeedsForSpace<T>( jobType: JobType, list: T[], field: keyof T ): Promise<T[]> { return _filterJobObjectsForSpace<T>(jobType, list, field, 'datafeed_id'); } async function _filterJobObjectIdsForSpace( jobType: JobType, ids: string[], key: keyof JobObject, allowWildcards: boolean = false ): Promise<string[]> { if (ids.length === 0) { return []; } const jobIds = await _getIds(jobType, key); // check to see if any of the ids supplied contain a wildcard if (allowWildcards === false || ids.join().match('\\*') === null) { // wildcards are not allowed or no wildcards could be found return ids.filter((id) => jobIds.includes(id)); } // if any of the ids contain a wildcard, check each one. return ids.filter((id) => { if (id.match('\\*') === null) { return jobIds.includes(id); } const regex = new RegExp(id.replaceAll('*', '.*')); return jobIds.some((jId) => typeof jId === 'string' && regex.exec(jId)); }); } async function filterJobIdsForSpace( jobType: JobType, ids: string[], allowWildcards: boolean = false ): Promise<string[]> { return _filterJobObjectIdsForSpace(jobType, ids, 'job_id', allowWildcards); } async function filterDatafeedIdsForSpace( ids: string[], allowWildcards: boolean = false ): Promise<string[]> { return _filterJobObjectIdsForSpace('anomaly-detector', ids, 'datafeed_id', allowWildcards); } async function updateJobsSpaces( jobType: JobType, jobIds: string[], spacesToAdd: string[], spacesToRemove: string[] ): Promise<SavedObjectResult> { const type = jobType; if (jobIds.length === 0 || (spacesToAdd.length === 0 && spacesToRemove.length === 0)) { return {}; } const results: SavedObjectResult = Object.create(null); const jobs = await _getJobObjects(jobType); const jobObjectIdMap = new Map<string, string>(); const jobObjectsToUpdate: Array<{ type: string; id: string }> = []; for (const jobId of jobIds) { const job = jobs.find((j) => j.attributes.job_id === jobId); if (job === undefined) { results[jobId] = { success: false, type, error: createJobError(jobId, 'job_id'), }; } else { jobObjectIdMap.set(job.id, jobId); jobObjectsToUpdate.push({ type: ML_JOB_SAVED_OBJECT_TYPE, id: job.id }); } } try { const updateResult = await savedObjectsClient.updateObjectsSpaces( jobObjectsToUpdate, spacesToAdd, spacesToRemove ); _clearSavedObjectsClientCache(); updateResult.objects.forEach(({ id: objectId, error }) => { const jobId = jobObjectIdMap.get(objectId)!; if (error) { results[jobId] = { success: false, type, error: getSavedObjectClientError(error), }; } else { results[jobId] = { success: true, type, }; } }); } catch (error) { // If the entire operation failed, return success: false for each job const clientError = getSavedObjectClientError(error); jobObjectsToUpdate.forEach(({ id: objectId }) => { const jobId = jobObjectIdMap.get(objectId)!; results[jobId] = { success: false, type, error: clientError, }; }); } return { ...results }; } async function canCreateGlobalMlSavedObjects( request: KibanaRequest, mlSavedObjectType?: MlSavedObjectType ) { if (authorization === undefined) { return true; } const { authorizationCheck } = authorizationProvider(authorization); const { canCreateJobsGlobally, canCreateTrainedModelsGlobally } = await authorizationCheck( request ); if (mlSavedObjectType === undefined) { return canCreateJobsGlobally && canCreateTrainedModelsGlobally; } return mlSavedObjectType === 'trained-model' ? canCreateTrainedModelsGlobally : canCreateJobsGlobally; } async function getTrainedModelObject( modelId: string, currentSpaceOnly: boolean = true ): Promise<SavedObjectsFindResult<TrainedModelObject> | undefined> { const [modelObject] = await _getTrainedModelObjects(modelId, currentSpaceOnly); return modelObject; } async function createTrainedModel( modelId: string, job: TrainedModelJob | null, addToAllSpaces = false ) { await _createTrainedModel(modelId, job, addToAllSpaces); } async function bulkCreateTrainedModel(models: TrainedModelObject[], namespaceFallback?: string) { return await _bulkCreateTrainedModel(models, namespaceFallback); } async function deleteTrainedModel(modelId: string) { await _deleteTrainedModel(modelId); } async function forceDeleteTrainedModel(modelId: string, namespace: string) { await _forceDeleteTrainedModel(modelId, namespace); } async function getAllTrainedModelObjects(currentSpaceOnly: boolean = true) { return await _getTrainedModelObjects(undefined, currentSpaceOnly); } async function _getTrainedModelObjects(modelId?: string, currentSpaceOnly: boolean = true) { await isMlReady(); const filterObject: TrainedModelObjectFilter = Object.create(null); if (modelId !== undefined) { filterObject.model_id = modelId; } const { filter, searchFields } = createSavedObjectFilter( filterObject, ML_TRAINED_MODEL_SAVED_OBJECT_TYPE ); const options: SavedObjectsFindOptions = { type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), searchFields, filter, }; const models = await _savedObjectsClientFindMemo<TrainedModelObject>(options); return models.saved_objects; } async function _createTrainedModel( modelId: string, job: TrainedModelJob | null, addToAllSpaces = false ) { await isMlReady(); const modelObject: TrainedModelObject = { model_id: modelId, job, }; try { const [existingModelObject] = await getAllTrainedModelObjectsForAllSpaces([modelId]); if (existingModelObject !== undefined) { // a saved object for this job already exists, this may be left over from a previously deleted job if (existingModelObject.namespaces?.length) { // use a force delete just in case the saved object exists only in another space. await _forceDeleteTrainedModel(modelId, existingModelObject.namespaces[0]); } else { // the saved object has no spaces, this is unexpected, attempt a normal delete await savedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, modelId, { force: true, }); _clearSavedObjectsClientCache(); } } } catch (error) { // the saved object may exist if a previous job with the same ID has been deleted. // if not, this error will be throw which we ignore. } let initialNamespaces = addToAllSpaces ? ['*'] : undefined; // if a job exists for this model, ensure the initial namespaces for the model // are the same as the job if (job !== null) { const [existingJobObject] = await getAllJobObjectsForAllSpaces( 'data-frame-analytics', job.job_id ); if (existingJobObject?.namespaces !== undefined) { initialNamespaces = existingJobObject?.namespaces; } } await savedObjectsClient.create<TrainedModelObject>( ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, modelObject, { id: modelId, ...(initialNamespaces ? { initialNamespaces } : {}), } ); _clearSavedObjectsClientCache(); } async function _bulkCreateTrainedModel(models: TrainedModelObject[], namespaceFallback?: string) { await isMlReady(); const namespacesPerJob = (await getAllJobObjectsForAllSpaces()).reduce((acc, cur) => { acc[cur.attributes.job_id] = cur.namespaces; return acc; }, {} as Record<string, string[] | undefined>); const results = await savedObjectsClient.bulkCreate<TrainedModelObject>( models.map((m) => { let initialNamespaces = m.job && namespacesPerJob[m.job.job_id]; if (!initialNamespaces?.length && namespaceFallback) { // use the namespace fallback if it is defined and no namespaces can // be found for a related job. // otherwise initialNamespaces will be undefined and the SO client will // use the current space. initialNamespaces = [namespaceFallback]; } return { type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, id: m.model_id, attributes: m, ...(initialNamespaces ? { initialNamespaces } : {}), }; }) ); _clearSavedObjectsClientCache(); return results; } async function getAllTrainedModelObjectsForAllSpaces(modelIds?: string[]) { await isMlReady(); const searchFields = ['model_id']; let filter = ''; if (modelIds !== undefined && modelIds.length) { filter = modelIds .map((m) => `${ML_TRAINED_MODEL_SAVED_OBJECT_TYPE}.attributes.model_id: "${m}"`) .join(' OR '); } const options: SavedObjectsFindOptions = { type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false ? {} : { namespaces: ['*'] }), searchFields, filter, }; return (await internalSavedObjectsClient.find<TrainedModelObject>(options)).saved_objects; } async function _deleteTrainedModel(modelId: string) { const [model] = await _getTrainedModelObjects(modelId); if (model === undefined) { throw new MLModelNotFound('trained model not found'); } await savedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, model.id, { force: true }); _clearSavedObjectsClientCache(); } async function _forceDeleteTrainedModel(modelId: string, namespace: string) { // * space cannot be used in a delete call, so use undefined which // is the same as specifying the default space await internalSavedObjectsClient.delete(ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, modelId, { namespace: namespace === '*' ? undefined : namespace, force: true, }); _clearSavedObjectsClientCache(); } async function filterTrainedModelsForSpace<T>(list: T[], field: keyof T): Promise<T[]> { return _filterModelObjectsForSpace<T>(list, field, 'model_id'); } async function filterTrainedModelIdsForSpace( ids: string[], allowWildcards: boolean = false ): Promise<string[]> { return _filterModelObjectIdsForSpace(ids, 'model_id', allowWildcards); } async function _filterModelObjectIdsForSpace( ids: string[], key: keyof TrainedModelObject, allowWildcards: boolean = false ): Promise<string[]> { if (ids.length === 0) { return []; } const modelIds = await _getModelIds(key); // check to see if any of the ids supplied contain a wildcard if (allowWildcards === false || ids.join().match('\\*') === null) { // wildcards are not allowed or no wildcards could be found return ids.filter((id) => modelIds.includes(id)); } // if any of the ids contain a wildcard, check each one. return ids.filter((id) => { if (id.match('\\*') === null) { return modelIds.includes(id); } const regex = new RegExp(id.replaceAll('*', '.*')); return modelIds.some((jId) => typeof jId === 'string' && regex.exec(jId)); }); } async function _filterModelObjectsForSpace<T>( list: T[], field: keyof T, key: keyof TrainedModelObject ): Promise<T[]> { if (list.length === 0) { return []; } const modelIds = await _getModelIds(key); return list.filter((j) => modelIds.includes(j[field] as unknown as string)); } async function _getModelIds(idType: keyof TrainedModelObject) { const models = await _getTrainedModelObjects(); return models.map((o) => o.attributes[idType]); } async function getAnomalyDetectionJobIds() { return _getIds('anomaly-detector', 'job_id'); } async function getDataFrameAnalyticsJobIds() { return _getIds('data-frame-analytics', 'job_id'); } async function getTrainedModelsIds() { return _getModelIds('model_id'); } async function findTrainedModelsObjectForJobs( jobIds: string[], currentSpaceOnly: boolean = true ) { await isMlReady(); const { data_frame_analytics: jobs } = await client.asInternalUser.ml.getDataFrameAnalytics({ id: jobIds.join(','), }); const searches = jobs.map((job) => { const createTime = job.create_time!; const filterObject = { 'job.job_id': job.id, 'job.create_time': createTime, } as TrainedModelObjectFilter; const { filter, searchFields } = createSavedObjectFilter( filterObject, ML_TRAINED_MODEL_SAVED_OBJECT_TYPE ); const options: SavedObjectsFindOptions = { type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, perPage: 10000, ...(spacesEnabled === false || currentSpaceOnly === true ? {} : { namespaces: ['*'] }), searchFields, filter, }; return _savedObjectsClientFindMemo<TrainedModelObject>(options); }); const finedResult = await Promise.all(searches); return finedResult.reduce((acc, cur) => { const savedObject = cur.saved_objects[0]; if (savedObject) { const jobId = savedObject.attributes.job!.job_id; acc[jobId] = savedObject; } return acc; }, {} as Record<string, SavedObjectsFindResult<TrainedModelObject>>); } async function updateTrainedModelsSpaces( modelIds: string[], spacesToAdd: string[], spacesToRemove: string[] ): Promise<SavedObjectResult> { const type: TrainedModelType = 'trained-model'; if (modelIds.length === 0 || (spacesToAdd.length === 0 && spacesToRemove.length === 0)) { return {}; } const results: SavedObjectResult = Object.create(null); const models = await _getTrainedModelObjects(); const trainedModelObjectIdMap = new Map<string, string>(); const objectsToUpdate: Array<{ type: string; id: string }> = []; for (const modelId of modelIds) { const model = models.find(({ attributes }) => attributes.model_id === modelId); if (model === undefined) { results[modelId] = { success: false, type, error: createTrainedModelError(modelId), }; } else { trainedModelObjectIdMap.set(model.id, model.attributes.model_id); objectsToUpdate.push({ type: ML_TRAINED_MODEL_SAVED_OBJECT_TYPE, id: model.id }); } } try { const updateResult = await savedObjectsClient.updateObjectsSpaces( objectsToUpdate, spacesToAdd, spacesToRemove ); _clearSavedObjectsClientCache(); updateResult.objects.forEach(({ id: objectId, error }) => { const model = trainedModelObjectIdMap.get(objectId)!; if (error) { results[model] = { success: false, type, error: getSavedObjectClientError(error), }; } else { results[model] = { success: true, type, }; } }); } catch (error) { // If the entire operation failed, return success: false for each job const clientError = getSavedObjectClientError(error); objectsToUpdate.forEach(({ id: objectId }) => { const modelId = trainedModelObjectIdMap.get(objectId)!; results[modelId] = { success: false, type, error: clientError, }; }); } return results; } return { getAnomalyDetectionJobIds, getDataFrameAnalyticsJobIds, getTrainedModelsIds, getAllJobObjects, getJobObject, createAnomalyDetectionJob, createDataFrameAnalyticsJob, deleteAnomalyDetectionJob, forceDeleteAnomalyDetectionJob, deleteDataFrameAnalyticsJob, forceDeleteDataFrameAnalyticsJob, addDatafeed, deleteDatafeed, filterJobsForSpace, filterJobIdsForSpace, filterDatafeedsForSpace, filterDatafeedIdsForSpace, updateJobsSpaces, bulkCreateJobs, getAllJobObjectsForAllSpaces, canCreateGlobalMlSavedObjects, getTrainedModelObject, createTrainedModel, bulkCreateTrainedModel, deleteTrainedModel, forceDeleteTrainedModel, updateTrainedModelsSpaces, getAllTrainedModelObjects, getAllTrainedModelObjectsForAllSpaces, filterTrainedModelsForSpace, filterTrainedModelIdsForSpace, findTrainedModelsObjectForJobs, }; } export function createJobError(id: string, key: keyof JobObject) { let reason = `'${id}' not found`; if (key === 'job_id') { reason = `No known job with id '${id}'`; } else if (key === 'datafeed_id') { reason = `No known datafeed with id '${id}'`; } return reason; } export function createTrainedModelError(id: string) { return `No known trained model with id '${id}'`; } function createSavedObjectFilter( filterObject: JobObjectFilter | TrainedModelObjectFilter, savedObjectType: string ) { const searchFields: string[] = []; const filter = Object.entries(filterObject) .map(([k, v]) => { searchFields.push(k); return `${savedObjectType}.attributes.${k}: "${v}"`; }) .join(' AND '); return { filter, searchFields }; }