diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 9eaeb384970e..6cd1b8821cc3 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -107,6 +107,7 @@ import { InternalDefineStageOptions, InternalSubcollectionStageOptions, UpdateStage, + InternalUpdateStageOptions, Search, InternalSearchStageOptions, InsertStage, @@ -135,37 +136,24 @@ export class PipelineSource implements firestore.Pipelines.PipelineSource { * Set the pipeline's source to the in-memory documents specified by the given records. * * @param documents An array of objects/records specifying the in-memory documents. + */ + literals(documents: Array>): Pipeline; + /** + * Set the pipeline's source to the in-memory documents specified by the given records. + * * @param options Options defining how this LiteralsSource stage is evaluated. */ - literals( - documents: Array>, - options?: firestore.Pipelines.LiteralsStageOptions, - ): Pipeline; literals(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; literals( - docsOrOptions: + documentsOrOptions: Array> | firestore.Pipelines.LiteralsStageOptions, - options?: firestore.Pipelines.LiteralsStageOptions, ): Pipeline { - let documents: Array> = []; - let opts: InternalLiteralsStageOptions = {}; - - if (Array.isArray(docsOrOptions)) { - documents = docsOrOptions; - opts = options ?? {}; - } else if ( - docsOrOptions && - Array.isArray( - (docsOrOptions as firestore.Pipelines.LiteralsStageOptions).documents, - ) - ) { - const {documents: docs, ...rest} = - docsOrOptions as firestore.Pipelines.LiteralsStageOptions; - documents = docs ?? []; - opts = rest; - } - - return new Pipeline(this.db, [new LiteralsSource(documents, opts)]); + const options: InternalLiteralsStageOptions = Array.isArray( + documentsOrOptions, + ) + ? {documents: documentsOrOptions} + : documentsOrOptions; + return new Pipeline(this.db, [new LiteralsSource(options)]); } /** @@ -1788,8 +1776,16 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * * @return A new {@code Pipeline} object with this stage appended to the stage list. */ - delete(): Pipeline { - return this._addStage(new DeleteStage()); + delete(): Pipeline; + /** + * Performs a delete operation on documents from previous stages. + * + * @param options - Options defining how this Delete stage is evaluated. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + delete(options: firestore.Pipelines.DeleteStageOptions): Pipeline; + delete(options?: firestore.Pipelines.DeleteStageOptions): Pipeline { + return this._addStage(new DeleteStage(options ?? {})); } /** @@ -1819,47 +1815,55 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an update operation using documents from previous stages. * - * @param fieldsMap - Map of field transformations to apply. + * @param options - Options defining how this Update stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ + update(options: firestore.Pipelines.UpdateStageOptions): Pipeline; update( - fieldsMap: Record | Map, - ): Pipeline; - update( - transformedFieldsOrMap?: + transformedFieldOrOptions?: | AliasedExpression | AliasedExpression[] - | Map - | Record, + | firestore.Pipelines.UpdateStageOptions, ...additionalFields: AliasedExpression[] ): Pipeline { - if (isAliasedExpr(transformedFieldsOrMap)) { - return this._addStage( - new UpdateStage([transformedFieldsOrMap, ...additionalFields]), - ); - } else if ( - transformedFieldsOrMap instanceof Map || - Array.isArray(transformedFieldsOrMap) - ) { - return this._addStage(new UpdateStage(transformedFieldsOrMap)); - } else if ( - transformedFieldsOrMap && - isPlainObject(transformedFieldsOrMap) - ) { - const map = new Map( - Object.entries(transformedFieldsOrMap as Record), - ); - return this._addStage(new UpdateStage(map)); + let options: firestore.Pipelines.UpdateStageOptions; + if (isAliasedExpr(transformedFieldOrOptions)) { + options = { + transformedFields: [transformedFieldOrOptions, ...additionalFields], + }; + } else if (Array.isArray(transformedFieldOrOptions)) { + options = {transformedFields: transformedFieldOrOptions}; + } else { + options = transformedFieldOrOptions ?? {}; } - return this._addStage(new UpdateStage()); + + const {transformedFields, ...rest} = options; + const internalOptions: InternalUpdateStageOptions = { + ...rest, + ...(transformedFields + ? { + transformedFields: selectablesToMap( + transformedFields as AliasedExpression[], + ), + } + : {}), + }; + return this._addStage(new UpdateStage(internalOptions)); } + /** + * Performs an insert operation on documents from previous stages. + * + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + insert(): Pipeline; /** * Performs an insert operation on documents from previous stages. * * @param options - Options defining how this Insert stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ + insert(options: firestore.Pipelines.InsertStageOptions): Pipeline; insert(options?: firestore.Pipelines.InsertStageOptions): Pipeline { return this._addStage( new InsertStage((options ?? {}) as InternalInsertStageOptions), @@ -1872,13 +1876,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @return A new {@code Pipeline} object with this stage appended to the stage list. */ upsert(): Pipeline; - /** - * Performs an upsert operation on documents from previous stages. - * - * @param options - Options defining how this Upsert stage is evaluated. - * @return A new {@code Pipeline} object with this stage appended to the stage list. - */ - upsert(options: firestore.Pipelines.UpsertStageOptions): Pipeline; /** * Performs an upsert operation on documents from previous stages. * @@ -1893,83 +1890,23 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an upsert operation on documents from previous stages. * - * @param additionalFields - Additional fields to apply on upsert. * @param options - Options defining how this Upsert stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ + upsert(options: firestore.Pipelines.UpsertStageOptions): Pipeline; upsert( - additionalFields: AliasedExpression[], - options?: firestore.Pipelines.UpsertStageOptions, - ): Pipeline; - upsert( - additionalFieldsOrOptions?: - | AliasedExpression - | AliasedExpression[] - | firestore.Pipelines.UpsertStageOptions, - ...restArgs: unknown[] - ): Pipeline { - let additionalFields: AliasedExpression[] | undefined; - let options: firestore.Pipelines.UpsertStageOptions | undefined; - - if (isAliasedExpr(additionalFieldsOrOptions)) { - additionalFields = [ - additionalFieldsOrOptions, - ...(restArgs as AliasedExpression[]), - ]; - options = undefined; - } else if (Array.isArray(additionalFieldsOrOptions)) { - additionalFields = additionalFieldsOrOptions; - options = restArgs[0] as - firestore.Pipelines.UpsertStageOptions | undefined; - } else { - additionalFields = undefined; - options = additionalFieldsOrOptions; - } - - return this._addStage( - new UpsertStage({ - ...options, - ...(additionalFields ? {additionalFields} : {}), - }), - ); - } - - /** - * Appends a literals stage to the pipeline. - * - * @param documents An array of objects/records specifying in-memory documents. - * @param options Options defining how this LiteralsSource stage is evaluated. - * @return A new {@code Pipeline} object with this stage appended to the stage list. - */ - literals( - documents: Array>, - options?: firestore.Pipelines.LiteralsStageOptions, - ): Pipeline; - literals(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; - literals( - docsOrOptions: - Array> | firestore.Pipelines.LiteralsStageOptions, - options?: firestore.Pipelines.LiteralsStageOptions, + additionalFieldOrOptions?: + AliasedExpression | firestore.Pipelines.UpsertStageOptions, + ...additionalFields: AliasedExpression[] ): Pipeline { - let documents: Array> = []; - let opts: InternalLiteralsStageOptions = {}; - - if (Array.isArray(docsOrOptions)) { - documents = docsOrOptions; - opts = options ?? {}; - } else if ( - docsOrOptions && - Array.isArray( - (docsOrOptions as firestore.Pipelines.LiteralsStageOptions).documents, - ) - ) { - const {documents: docs, ...rest} = - docsOrOptions as firestore.Pipelines.LiteralsStageOptions; - documents = docs ?? []; - opts = rest; + if (isAliasedExpr(additionalFieldOrOptions)) { + return this._addStage( + new UpsertStage({ + additionalFields: [additionalFieldOrOptions, ...additionalFields], + }), + ); } - - return this._addStage(new LiteralsSource(documents, opts)); + return this._addStage(new UpsertStage(additionalFieldOrOptions ?? {})); } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 4404c5d55f6e..ebc0eaf741f6 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -18,6 +18,8 @@ import api = protos.google.firestore.v1; import {DocumentReference} from '../reference/document-reference'; import {hasUserData, ProtoSerializable, Serializer} from '../serializer'; +import {FieldTransform} from '../field-value'; +import {isPlainObject} from '../util'; import { AggregateFunction, @@ -26,11 +28,16 @@ import { Expression, Field, field, + MapValue, Ordering, } from './expression'; import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; -import {validateUserDataHelper, selectablesToMap} from './pipeline-util'; +import { + validateUserDataHelper, + selectablesToMap, + valueToDefaultExpr, +} from './pipeline-util'; import {Pipeline} from './pipelines'; /** @@ -907,6 +914,11 @@ export class RawStage implements Stage { } } +/** + * Internal options for Delete stage. + */ +export type InternalDeleteStageOptions = firestore.Pipelines.DeleteStageOptions; + /** * Delete stage. */ @@ -914,7 +926,7 @@ export class DeleteStage implements Stage { name = 'delete'; readonly optionsUtil = new OptionsUtil({}); - constructor() {} + constructor(private options: InternalDeleteStageOptions = {}) {} _toProto(serializer: Serializer): api.Pipeline.IStage { const args: api.IValue[] = []; @@ -922,50 +934,58 @@ export class DeleteStage implements Stage { return { name: this.name, args, - options: this.optionsUtil.getOptionsProto(serializer, {}, {}), + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), }; } _validateUserData(_: boolean): void {} } +/** + * Internal options for Update stage. + */ +export type InternalUpdateStageOptions = Omit< + firestore.Pipelines.UpdateStageOptions, + 'transformedFields' +> & { + transformedFields?: Map; +}; + /** * Update stage. */ export class UpdateStage implements Stage { name = 'update'; readonly optionsUtil = new OptionsUtil({}); - private readonly transformedMap?: Map; - constructor( - transformedFields?: AliasedExpression[] | Map, - ) { - if (transformedFields instanceof Map) { - this.transformedMap = transformedFields; - } else if (transformedFields && transformedFields.length > 0) { - this.transformedMap = selectablesToMap(transformedFields); - } - } + constructor(private options: InternalUpdateStageOptions = {}) {} _toProto(serializer: Serializer): api.Pipeline.IStage { - const args: api.IValue[] = []; - - if (this.transformedMap && this.transformedMap.size > 0) { - args.push(serializer.encodeValue(this.transformedMap)!); - } else { - args.push(serializer.encodeValue(new Map())!); - } + const args: api.IValue[] = [ + serializer.encodeValue(this.options.transformedFields ?? new Map())!, + ]; return { name: this.name, args, - options: this.optionsUtil.getOptionsProto(serializer, {}, {}), + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), }; } _validateUserData(ignoreUndefinedProperties: boolean): void { - if (this.transformedMap) { - validateUserDataHelper(this.transformedMap, ignoreUndefinedProperties); + if (this.options.transformedFields) { + validateUserDataHelper( + this.options.transformedFields, + ignoreUndefinedProperties, + ); } } } @@ -1042,11 +1062,10 @@ export class InsertStage implements Stage { */ export type InternalUpsertStageOptions = Omit< firestore.Pipelines.UpsertStageOptions, - 'additionalFields' | 'transforms' | 'collection' | 'documentIdExpression' + 'additionalFields' | 'collection' | 'documentIdExpression' > & { additionalFields?: AliasedExpression[] | firestore.Pipelines.AliasedExpression[]; - transforms?: AliasedExpression[] | firestore.Pipelines.AliasedExpression[]; collection?: string | CollectionReference | firestore.CollectionReference; documentIdExpression?: string | Expression | firestore.Pipelines.Expression; }; @@ -1061,30 +1080,12 @@ export class UpsertStage implements Stage { private readonly documentIdExpr?: Expression; private readonly additionalFields: Map; - private readonly options: InternalUpsertStageOptions; - - constructor( - additionalFieldsOrOptions?: - AliasedExpression[] | InternalUpsertStageOptions, - options?: InternalUpsertStageOptions, - ) { - let fields: AliasedExpression[] = []; - let opts: InternalUpsertStageOptions = {}; - - if (Array.isArray(additionalFieldsOrOptions)) { - fields = additionalFieldsOrOptions; - opts = options ?? {}; - } else if (additionalFieldsOrOptions) { - opts = additionalFieldsOrOptions; - } else if (options) { - opts = options; - } - this.options = opts; + constructor(private readonly options: InternalUpsertStageOptions = {}) { + const opts = options; - const resolvedFields = (opts.additionalFields ?? - opts.transforms ?? - fields) as AliasedExpression[]; - this.additionalFields = selectablesToMap(resolvedFields); + this.additionalFields = selectablesToMap( + (opts.additionalFields ?? []) as AliasedExpression[], + ); if (opts.collection) { this.collectionPath = typeof opts.collection === 'string' @@ -1147,15 +1148,23 @@ export class LiteralsSource implements Stage { name = 'literals'; readonly optionsUtil = new OptionsUtil({}); - constructor( - readonly documents: Array>, - private options: InternalLiteralsStageOptions = {}, - ) {} + constructor(private options: InternalLiteralsStageOptions) {} _toProto(serializer: Serializer): api.Pipeline.IStage { - const args: api.IValue[] = this.documents.map(doc => - serializer.encodeValue(doc)!, - ); + // Each document is sent as a top-level mapValue. The backend only + // evaluates expressions in the top-level fields of a literal document, so + // nested objects and arrays are converted to map() and array() expressions + // (the same conversion used for objects and arrays passed to expressions + // and to rawStage()). This allows expressions at any depth. + const args: api.IValue[] = this.options.documents.map(doc => { + const fields = new Map(); + for (const [key, value] of Object.entries(doc)) { + if (value !== undefined) { + fields.set(key, valueToDefaultExpr(value)); + } + } + return new MapValue(fields)._toProto(serializer); + }); return { name: this.name, args, @@ -1168,12 +1177,39 @@ export class LiteralsSource implements Stage { } _validateUserData(ignoreUndefinedProperties: boolean): void { - this.documents.forEach(doc => { - Object.values(doc).forEach(val => { - if (hasUserData(val)) { - val._validateUserData(ignoreUndefinedProperties); + const validate = (value: unknown, path: string[]): void => { + const location = + path.length > 0 ? ` (found in field ${path.join('.')})` : ''; + if (value instanceof FieldTransform) { + // FieldValue sentinels (serverTimestamp(), increment(), delete(), ...) + // have no representation in a pipeline, so they cannot be encoded. + // The error matches the Web SDK. + throw new Error( + `Function literals() called with invalid data. ${value.methodName}() ` + + `can only be used with update() and set()${location}`, + ); + } else if (value === undefined) { + // Undefined values are omitted from the encoded document, which is + // only allowed when `ignoreUndefinedProperties` is enabled. + if (!ignoreUndefinedProperties) { + throw new Error( + 'Function literals() called with invalid data. Cannot use ' + + `"undefined" as a Firestore value${location}. If you want to ` + + 'ignore undefined values, enable `ignoreUndefinedProperties`.', + ); } - }); - }); + } else if (value === null) { + // null is a valid literal value. + } else if (hasUserData(value)) { + value._validateUserData(ignoreUndefinedProperties); + } else if (Array.isArray(value)) { + value.forEach(element => validate(element, path)); + } else if (isPlainObject(value)) { + Object.entries(value as Record).forEach( + ([key, fieldValue]) => validate(fieldValue, [...path, key]), + ); + } + }; + this.options.documents.forEach(doc => validate(doc, [])); } } diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 13ebb045a01c..13c79b91e9af 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -717,15 +717,17 @@ describe.skipClassic('Pipeline class', () => { expect(docSnap.get('rating')).to.equal(4.7); }); - it('can upsert (update) an existing document with array of expressions', async () => { + it('can upsert (update) an existing document with additionalFields option', async () => { const res = await firestore .pipeline() .collection(dmlCol.path) .where(equal(field('__name__').documentId(), 'book1')) - .upsert([ - constant('Updated Genre List').as('genre'), - constant(5.0).as('rating'), - ]) + .upsert({ + additionalFields: [ + constant('Updated Genre List').as('genre'), + constant(5.0).as('rating'), + ], + }) .execute({atomic: true}); expectResults(res, {documents_modified: 1}); @@ -797,6 +799,45 @@ describe.skipClassic('Pipeline class', () => { expect(snap.get('title')).to.equal('Tx Upsert Book'); }); + it('treats a string documentIdExpression as a field name', async () => { + const targetCol = getTestRoot(); + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .addFields(constant('id_from_field').as('customId')) + .insert({ + collection: targetCol.path, + documentIdExpression: 'customId', + }) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await targetCol.doc('id_from_field').get(); + expect(docSnap.exists).to.be.true; + expect(docSnap.get('title')).to.equal( + "The Hitchhiker's Guide to the Galaxy", + ); + // A literal document ID 'customId' is not used. + expect((await targetCol.doc('customId').get()).exists).to.be.false; + }); + + it('upsert with documentIdExpression requires a collection', async () => { + let error: Error | undefined; + try { + await firestore + .pipeline() + .collection(dmlCol.path) + .upsert({documentIdExpression: constant('some_id')}) + .execute({atomic: true}); + } catch (e) { + error = e as Error; + } + expect(error?.message).to.contain( + "The 'upsert(...)' stage must specify a collection when a document id is provided.", + ); + }); + it('can execute pipeline with literals stage', async () => { const res = await firestore .pipeline() @@ -826,6 +867,44 @@ describe.skipClassic('Pipeline class', () => { expect(res.results.length).to.equal(1); expect(res.results[0].data()).to.deep.equal({base: 10, computed: 35}); }); + + it('can execute literals stage with nested expressions', async () => { + const res = await firestore + .pipeline() + .literals([ + { + nested: {sum: add(constant(1), constant(2)), label: 'x'}, + arr: [1, add(constant(3), constant(4))], + deep: {a: {b: [{c: add(constant(5), constant(6))}]}}, + }, + ]) + .execute(); + expect(res.results.length).to.equal(1); + expect(res.results[0].data()).to.deep.equal({ + nested: {sum: 3, label: 'x'}, + arr: [1, 7], + deep: {a: {b: [{c: 11}]}}, + }); + }); + + it('can execute literals stage with nested constant values', async () => { + const res = await firestore + .pipeline() + .literals([ + { + emptyMap: {}, + emptyArr: [], + nested: {a: 1, b: [true, null, 'str'], c: {d: 2.5}}, + }, + ]) + .execute(); + expect(res.results.length).to.equal(1); + expect(res.results[0].data()).to.deep.equal({ + emptyMap: {}, + emptyArr: [], + nested: {a: 1, b: [true, null, 'str'], c: {d: 2.5}}, + }); + }); }); it('empty snapshot as expected', async () => { diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 51269f4e6bf9..2864cc19385c 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -410,7 +410,9 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { doubled: Pipelines.multiply(constant(10), constant(2)), nested: { val: constant('hello'), + sum: Pipelines.add(constant(1), constant(2)), }, + arr: [1, Pipelines.add(constant(3), constant(4))], }, ]) .execute(); @@ -420,6 +422,9 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { 'pipeline' ]['stages']; + // The document itself is a mapValue, but nested objects and arrays are + // wrapped in map() and array() expressions, because the backend only + // evaluates expressions in the top-level fields of a literal document. expect(stages[0].name).to.equal('literals'); expect(stages[0].args).to.deep.equal([ { @@ -433,10 +438,67 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { }, }, nested: { - mapValue: { - fields: { - val: {stringValue: 'hello'}, - }, + functionValue: { + name: 'map', + args: [ + {stringValue: 'val'}, + {stringValue: 'hello'}, + {stringValue: 'sum'}, + { + functionValue: { + name: 'add', + args: [{integerValue: 1}, {integerValue: 2}], + }, + }, + ], + }, + }, + arr: { + functionValue: { + name: 'array', + args: [ + {integerValue: 1}, + { + functionValue: { + name: 'add', + args: [{integerValue: 3}, {integerValue: 4}], + }, + }, + ], + }, + }, + }, + }, + }, + ]); + }); + + it('omits undefined literal fields when ignoreUndefinedProperties is set', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance( + {executePipeline: spy}, + {ignoreUndefinedProperties: true}, + ); + + await firestore + .pipeline() + .literals([{a: 1, b: undefined, nested: {c: undefined, d: 2}}]) + .execute(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages']; + + expect(stages[0].args).to.deep.equal([ + { + mapValue: { + fields: { + a: {integerValue: 1}, + nested: { + functionValue: { + name: 'map', + args: [{stringValue: 'd'}, {integerValue: 2}], }, }, }, @@ -556,6 +618,35 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { }, ]); }); + + it('serializes options update({transformedFields})', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .update({transformedFields: [constant(true).as('active')]}) + .execute(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages']; + + expect(stages[1].name).to.equal('update'); + expect(stages[1].args).to.deep.equal([ + { + mapValue: { + fields: { + active: {booleanValue: true}, + }, + }, + }, + ]); + }); }); describe('upsert stage overloads', () => { @@ -678,7 +769,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[1].options).to.be.undefined; }); - it('serializes array of expressions without options upsert([expr])', async () => { + it('serializes additionalFields option upsert({additionalFields})', async () => { const spy = sinon.fake.returns(stream()); const firestore = await createInstance({ executePipeline: spy, @@ -687,7 +778,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { await firestore .pipeline() .collection('foo') - .upsert([constant(true).as('active')]) + .upsert({additionalFields: [constant(true).as('active')]}) .execute(); const stages = @@ -708,39 +799,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[1].options).to.be.undefined; }); - it('serializes array of expressions with options upsert([expr], options)', async () => { - const spy = sinon.fake.returns(stream()); - const firestore = await createInstance({ - executePipeline: spy, - }); - - await firestore - .pipeline() - .collection('foo') - .upsert([constant(true).as('active')], {collection: 'users'}) - .execute(); - - const stages = - spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ - 'pipeline' - ]['stages']; - - expect(stages[1].name).to.equal('upsert'); - expect(stages[1].args).to.deep.equal([ - { - mapValue: { - fields: { - active: {booleanValue: true}, - }, - }, - }, - ]); - expect(stages[1].options).to.deep.equal({ - collection: {referenceValue: '/users'}, - }); - }); - - it('supports deprecated transforms option for backward compatibility', async () => { + it('serializes additionalFields with other options upsert({additionalFields, collection})', async () => { const spy = sinon.fake.returns(stream()); const firestore = await createInstance({ executePipeline: spy, @@ -750,8 +809,8 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { .pipeline() .collection('foo') .upsert({ + additionalFields: [constant(true).as('active')], collection: 'users', - transforms: [constant(true).as('active')], }) .execute(); diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index e1fd454e7402..6cb7ccb7e5f8 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -19,7 +19,7 @@ import {expect} from 'chai'; import * as sinon from 'sinon'; import {createInstance, stream} from '../util/helpers'; import {google} from '../../protos/firestore_v1_proto_api'; -import {Pipelines, Firestore} from '../../src'; +import {Pipelines, Firestore, FieldValue} from '../../src'; import Pipeline = Pipelines.Pipeline; import field = Pipelines.field; import sum = Pipelines.sum; @@ -249,6 +249,54 @@ describe('stage option serialization', () => { }), stageIndex: 1, }, + { + name: 'literals source stage', + pipeline: firestore => + firestore.pipeline().literals({ + documents: [{foo: 'bar'}], + rawOptions, + }), + }, + { + name: 'delete stage', + pipeline: firestore => + firestore.pipeline().database().delete({ + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'update stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .update({ + transformedFields: [constant(true).as('foo')], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'insert stage', + pipeline: firestore => + firestore.pipeline().database().insert({ + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'upsert stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .upsert({ + additionalFields: [constant(true).as('foo')], + rawOptions, + }), + stageIndex: 1, + }, { name: 'search stage', pipeline: firestore => @@ -396,4 +444,106 @@ describe('stage _validateUserData', () => { }) .execute(); }); + + describe('literals stage rejects FieldValue sentinels', () => { + const cases: Array<{ + name: string; + doc: Record; + expected: string; + }> = [ + { + name: 'top-level serverTimestamp()', + doc: {a: 1, ts: FieldValue.serverTimestamp()}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field ts)', + }, + { + name: 'increment()', + doc: {n: FieldValue.increment(1)}, + expected: + 'Function literals() called with invalid data. FieldValue.increment() can only be used with update() and set() (found in field n)', + }, + { + name: 'arrayUnion()', + doc: {arr: FieldValue.arrayUnion('x')}, + expected: + 'Function literals() called with invalid data. FieldValue.arrayUnion() can only be used with update() and set() (found in field arr)', + }, + { + name: 'delete()', + doc: {gone: FieldValue.delete()}, + expected: + 'Function literals() called with invalid data. FieldValue.delete() can only be used with update() and set() (found in field gone)', + }, + { + name: 'nested in a map', + doc: {a: {b: FieldValue.serverTimestamp()}}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field a.b)', + }, + { + name: 'inside an array', + doc: {arr: [1, FieldValue.serverTimestamp()]}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field arr)', + }, + ]; + + cases.forEach(({name, doc, expected}) => { + it(name, async () => { + const firestore = await createInstance(); + expect(() => { + void firestore.pipeline().literals([doc]).execute(); + }).to.throw(expected); + }); + }); + + it('accepts literals without sentinels', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({executePipeline: spy}); + await firestore + .pipeline() + .literals([ + {a: 1, nested: {b: [1, 2, null], n: null}, c: constant(3), d: null}, + ]) + .execute(); + expect(spy.callCount).to.equal(1); + }); + }); + + describe('literals stage rejects undefined by default', () => { + const cases: Array<{ + name: string; + doc: Record; + expected: string; + }> = [ + { + name: 'top-level undefined', + doc: {a: 1, b: undefined}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field b).', + }, + { + name: 'nested undefined', + doc: {a: {b: undefined}}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field a.b).', + }, + { + name: 'undefined inside an array', + doc: {arr: [1, undefined]}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field arr).', + }, + ]; + + cases.forEach(({name, doc, expected}) => { + it(name, async () => { + const firestore = await createInstance(); + expect(() => { + void firestore.pipeline().literals([doc]).execute(); + }).to.throw(expected); + }); + }); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 46388c383e52..394f4b2bd686 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12457,11 +12457,15 @@ declare namespace FirebaseFirestore { createFrom(query: Query): Pipeline; /** * Set the pipeline's source to the in-memory documents specified by the given records. + * + * @param documents An array of objects/records specifying the in-memory documents. + */ + literals(documents: Array>): Pipeline; + /** + * Set the pipeline's source to the in-memory documents specified by the given records. + * + * @param options Options defining how this LiteralsSource stage is evaluated. */ - literals( - documents: Array>, - options?: LiteralsStageOptions, - ): Pipeline; literals(options: LiteralsStageOptions): Pipeline; } @@ -12886,6 +12890,13 @@ declare namespace FirebaseFirestore { * @return A new {@link Pipeline} object with this stage appended to the stage list. */ delete(): Pipeline; + /** + * Performs a delete operation on documents from previous stages. + * + * @param options - Options defining how this Delete stage is evaluated. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ + delete(options: DeleteStageOptions): Pipeline; /** * Performs an update operation using documents from previous stages. * @@ -12913,32 +12924,29 @@ declare namespace FirebaseFirestore { /** * Performs an update operation using documents from previous stages. * - * @param fieldsMap - Map of field transformations to apply. + * @param options - Options defining how this Update stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - update( - fieldsMap: Record | Map, - ): Pipeline; + update(options: UpdateStageOptions): Pipeline; /** * Performs an insert operation on documents from previous stages. * - * @param options - Options defining how this Insert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - insert(options?: InsertStageOptions): Pipeline; + insert(): Pipeline; /** - * Performs an upsert operation on documents from previous stages. + * Performs an insert operation on documents from previous stages. * + * @param options - Options defining how this Insert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - upsert(): Pipeline; + insert(options: InsertStageOptions): Pipeline; /** * Performs an upsert operation on documents from previous stages. * - * @param options - Options defining how this Upsert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - upsert(options: UpsertStageOptions): Pipeline; + upsert(): Pipeline; /** * Performs an upsert operation on documents from previous stages. * @@ -12953,26 +12961,10 @@ declare namespace FirebaseFirestore { /** * Performs an upsert operation on documents from previous stages. * - * @param additionalFields - Additional fields to apply on upsert. * @param options - Options defining how this Upsert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - upsert( - additionalFields: AliasedExpression[], - options?: UpsertStageOptions, - ): Pipeline; - /** - * Appends a literals stage to the pipeline. - * - * @param documents An array of objects/records specifying in-memory documents. - * @param options Options defining how this LiteralsSource stage is evaluated. - * @return A new {@code Pipeline} object with this stage appended to the stage list. - */ - literals( - documents: Array>, - options?: LiteralsStageOptions, - ): Pipeline; - literals(options: LiteralsStageOptions): Pipeline; + upsert(options: UpsertStageOptions): Pipeline; /** * Filters the documents from previous stages to only include those matching the specified {@link * BooleanExpression}. @@ -13674,7 +13666,17 @@ declare namespace FirebaseFirestore { */ indexMode?: 'recommended'; /** - * Indicates that the pipeline will be executed atomically. + * If `true`, the pipeline is executed in a new read-write transaction that + * is committed automatically when the pipeline completes, so all writes + * made by DML stages (`insert`, `upsert`, `update`, `delete`) succeed or + * fail together. + * + * If `false` or omitted, DML writes are applied per document without a + * transaction, and the pipeline may partially succeed. Some DML pipelines + * (e.g. an `upsert` from a `literals()` source) can only be executed + * atomically, and are otherwise rejected with "The non-transactional DML + * operation was rejected because it was impossible to guarantee + * document-level atomicity." */ atomic?: boolean; /** @@ -14154,24 +14156,91 @@ declare namespace FirebaseFirestore { // separator?: string; // }; + /** + * Options defining how a Delete stage is evaluated. + */ + export type DeleteStageOptions = StageOptions; + + /** + * Options defining how an Update stage is evaluated. + */ + export type UpdateStageOptions = StageOptions & { + /** + * The field transformations to apply to each document being updated. + */ + transformedFields?: AliasedExpression[]; + }; + /** * Options defining how an Insert stage is evaluated. + * + * Each input document is written as a new document; the insert fails if the + * target document already exists. The target is determined by `collection` + * and `documentIdExpression`: + * + * - Only `collection` set: the input document's ID is reused under + * `collection`. If the input document has no ID (e.g. it came from + * `literals()`), an ID is generated automatically. + * - Only `documentIdExpression` set: the evaluated ID is used under the input + * document's parent collection. + * - Both set: the evaluated ID is used under `collection`. + * + * If neither is set, each document is written back to its input document's + * path, which fails for documents read from the database because they + * already exist. */ export type InsertStageOptions = StageOptions & { + /** + * The collection to insert documents into. If omitted, each document is + * inserted into the parent collection of its input document. + */ collection?: string | CollectionReference; + /** + * An expression that is evaluated against each input document to produce + * the ID of the document to insert. It must evaluate to a document ID + * (e.g. `'abc123'`), not a document path or `DocumentReference`. + * + * A string is treated as a field name: `'id'` is equivalent to + * `field('id')`, and uses the value of each input document's `id` field as + * its document ID. To use a fixed document ID, pass `constant('abc123')`. + */ documentIdExpression?: string | Expression; }; /** * Options defining how an Upsert stage is evaluated. + * + * Each input document is written to its target document. A target document + * that doesn't exist is created; an existing one is replaced entirely + * (stored fields are not merged). The target is determined by `collection` + * and `documentIdExpression`: + * + * - Neither set: each document is written back to its input document's path. + * - Only `collection` set: the input document's ID is reused under + * `collection`. + * - Both set: the evaluated ID is used under `collection`. */ export type UpsertStageOptions = StageOptions & { + /** + * Fields to add to each input document before it is written. A field with + * the same name as an existing input field overwrites that field. + */ additionalFields?: AliasedExpression[]; /** - * @deprecated Use `additionalFields` instead. + * The collection to upsert documents into. If omitted, each document is + * written to the parent collection of its input document. Required when + * `documentIdExpression` is set. */ - transforms?: AliasedExpression[]; collection?: string | CollectionReference; + /** + * An expression that is evaluated against each input document to produce + * the ID of the target document. It must evaluate to a document ID + * (e.g. `'abc123'`), not a document path or `DocumentReference`. Requires + * `collection`. + * + * A string is treated as a field name: `'id'` is equivalent to + * `field('id')`. To use a fixed document ID, pass `constant('abc123')`. + */ documentIdExpression?: string | Expression; }; @@ -14179,7 +14248,10 @@ declare namespace FirebaseFirestore { * Options defining how a LiteralsSource stage is evaluated. */ export type LiteralsStageOptions = StageOptions & { - documents?: Array>; + /** + * An array of objects specifying the in-memory documents that will be the source of this pipeline. + */ + documents: Array>; }; /**