/* * Copyright (c) 2016-present Invertase Limited & Contributors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import type { FirestorePipelineExecuteOptionsInternal, FirestorePipelineSerializedInternal, FirestorePipelineSourceInternal, } from '../types/internal'; export interface ValidatedPipelineExecuteRequest { pipeline: FirestorePipelineSerializedInternal; options?: FirestorePipelineExecuteOptionsInternal; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } function isNonEmptyString(value: unknown): value is string { return typeof value === 'string' && value.length > 0; } function validateNonEmptyStringArray(value: unknown, fieldName: string): void { if (!Array.isArray(value) || value.length === 0) { throw new Error(`pipelineExecute() expected ${fieldName} to contain at least one value.`); } if (!value.every(isNonEmptyString)) { throw new Error(`pipelineExecute() expected ${fieldName} to contain only non-empty strings.`); } } function validateSource( source: unknown, fieldName: string = 'pipeline.source', ): asserts source is FirestorePipelineSourceInternal { if (!isRecord(source)) { throw new Error(`pipelineExecute() expected ${fieldName} to be an object.`); } switch (source.source) { case 'collection': if (!isNonEmptyString(source.path)) { throw new Error(`pipelineExecute() expected ${fieldName}.path to be a non-empty string.`); } return; case 'collectionGroup': if (!isNonEmptyString(source.collectionId)) { throw new Error( `pipelineExecute() expected ${fieldName}.collectionId to be a non-empty string.`, ); } return; case 'database': return; case 'documents': validateNonEmptyStringArray(source.documents, `${fieldName}.documents`); return; case 'query': if (!isNonEmptyString(source.path)) { throw new Error(`pipelineExecute() expected ${fieldName}.path to be a non-empty string.`); } if (!isNonEmptyString(source.queryType)) { throw new Error( `pipelineExecute() expected ${fieldName}.queryType to be a non-empty string.`, ); } if (!Array.isArray(source.filters)) { throw new Error(`pipelineExecute() expected ${fieldName}.filters to be an array.`); } if (!Array.isArray(source.orders)) { throw new Error(`pipelineExecute() expected ${fieldName}.orders to be an array.`); } if (!isRecord(source.options)) { throw new Error(`pipelineExecute() expected ${fieldName}.options to be an object.`); } return; case 'subcollection': if (!isNonEmptyString(source.path)) { throw new Error(`pipelineExecute() expected ${fieldName}.path to be a non-empty string.`); } return; default: throw new Error('pipelineExecute() received an unknown source type.'); } } function validateNonEmptyStageArray(value: unknown, fieldName: string): void { if (!Array.isArray(value) || value.length === 0) { throw new Error(`pipelineExecute() expected ${fieldName} to contain at least one value.`); } } function validateStage(stage: unknown, stagePath: string, optionsPath: string): unknown { if (!isRecord(stage) || typeof stage.stage !== 'string' || !isRecord(stage.options)) { throw new Error(`pipelineExecute() expected ${stagePath} to include stage and options.`); } switch (stage.stage) { case 'select': validateNonEmptyStageArray(stage.options.selections, `${optionsPath}.selections`); return undefined; case 'addFields': validateNonEmptyStageArray(stage.options.fields, `${optionsPath}.fields`); return undefined; case 'removeFields': validateNonEmptyStageArray(stage.options.fields, `${optionsPath}.fields`); return undefined; case 'sort': validateNonEmptyStageArray(stage.options.orderings, `${optionsPath}.orderings`); return undefined; case 'aggregate': validateNonEmptyStageArray(stage.options.accumulators, `${optionsPath}.accumulators`); return undefined; case 'distinct': validateNonEmptyStageArray(stage.options.groups, `${optionsPath}.groups`); return undefined; case 'sample': { const hasDocuments = typeof stage.options.documents === 'number'; const hasPercentage = typeof stage.options.percentage === 'number'; if (!hasDocuments && !hasPercentage) { throw new Error( 'pipelineExecute() expected sample stage to include documents or percentage.', ); } return undefined; } case 'union': if ( !isRecord(stage.options.other) || !isRecord(stage.options.other.source) || !Array.isArray(stage.options.other.stages) ) { throw new Error( 'pipelineExecute() expected stage.options.other to be a serialized pipeline object.', ); } return stage.options.other; case 'where': case 'limit': case 'offset': case 'findNearest': return undefined; case 'search': if (stage.options.query === undefined) { throw new Error('pipelineExecute() expected search stage to include query.'); } return undefined; case 'define': validateNonEmptyStageArray(stage.options.variables, `${optionsPath}.variables`); return undefined; case 'replaceWith': case 'unnest': case 'rawStage': return undefined; default: throw new Error(`pipelineExecute() received an unknown stage: ${stage.stage}.`); } } export function validateSerializedPipeline( pipeline: unknown, fieldName: string = 'pipeline', ): asserts pipeline is FirestorePipelineSerializedInternal { const pending: Array<{ pipeline: unknown; fieldName: string }> = [{ pipeline, fieldName }]; for (let index = 0; index < pending.length; index++) { const current = pending[index]!; if (!isRecord(current.pipeline)) { throw new Error(`pipelineExecute() expected ${current.fieldName} to be an object.`); } validateSource(current.pipeline.source, `${current.fieldName}.source`); if (!Array.isArray(current.pipeline.stages)) { throw new Error(`pipelineExecute() expected ${current.fieldName}.stages to be an array.`); } for (let i = 0; i < current.pipeline.stages.length; i++) { const optionsPath = current.fieldName === 'pipeline' ? 'stage.options' : `${current.fieldName}.stages[${i}].options`; const nestedPipeline = validateStage( current.pipeline.stages[i], `${current.fieldName}.stages[${i}]`, optionsPath, ); if (nestedPipeline) { pending.push({ pipeline: nestedPipeline, fieldName: `${optionsPath}.other`, }); } } } } export function validateExecuteOptions( options: unknown, ): FirestorePipelineExecuteOptionsInternal | undefined { if (options === undefined) { return undefined; } if (!isRecord(options)) { throw new Error('pipelineExecute() expected options to be an object.'); } if (options.indexMode !== undefined && options.indexMode !== 'recommended') { throw new Error('pipelineExecute() expected options.indexMode to equal "recommended".'); } if (options.indexMode !== undefined) { throw new Error( 'pipelineExecute() does not support options.indexMode because Firestore pipeline execute options are currently unstable or unavailable.', ); } if (options.rawOptions !== undefined && !isRecord(options.rawOptions)) { throw new Error('pipelineExecute() expected options.rawOptions to be an object.'); } if (options.rawOptions !== undefined) { throw new Error( 'pipelineExecute() does not support options.rawOptions because Firestore pipeline execute options are currently unstable or unavailable.', ); } return options as FirestorePipelineExecuteOptionsInternal; } export function validatePipelineExecuteRequest( pipeline: unknown, options: unknown, ): ValidatedPipelineExecuteRequest { validateSerializedPipeline(pipeline); return { pipeline, options: validateExecuteOptions(options), }; }