From 8c8c87f0d270860f57c7b2db347b2d851e8815ef Mon Sep 17 00:00:00 2001 From: wuandy Date: Mon, 10 Aug 2026 17:37:50 +0000 Subject: [PATCH 01/17] feat(firestore): add DML stages (insert, upsert, delete, update), literals source, and atomic execution option to Node SDK pipelines --- .../dev/protos/firestore_v1_proto_api.d.ts | 6 + .../dev/src/pipelines/pipeline-util.ts | 5 +- .../firestore/dev/src/pipelines/pipelines.ts | 165 +++++++++++++- .../firestore/dev/src/pipelines/stage.ts | 210 +++++++++++++++++- .../dev/src/pipelines/structured-pipeline.ts | 3 +- .../firestore/dev/system-test/pipeline.ts | 64 ++++++ .../firestore/dev/test/pipelines/pipeline.ts | 96 ++++++++ handwritten/firestore/types/firestore.d.ts | 79 +++++++ .../types/protos/firestore_v1_proto_api.d.ts | 6 + 9 files changed, 623 insertions(+), 11 deletions(-) diff --git a/handwritten/firestore/dev/protos/firestore_v1_proto_api.d.ts b/handwritten/firestore/dev/protos/firestore_v1_proto_api.d.ts index f0353f5d340e..280691459009 100644 --- a/handwritten/firestore/dev/protos/firestore_v1_proto_api.d.ts +++ b/handwritten/firestore/dev/protos/firestore_v1_proto_api.d.ts @@ -2690,6 +2690,9 @@ export namespace google { /** ExecutePipelineRequest readTime */ readTime?: (google.protobuf.ITimestamp|null); + + /** ExecutePipelineRequest autoCommitTransaction */ + autoCommitTransaction?: (boolean|null); } /** Represents an ExecutePipelineRequest. */ @@ -2716,6 +2719,9 @@ export namespace google { /** ExecutePipelineRequest readTime. */ public readTime?: (google.protobuf.ITimestamp|null); + /** ExecutePipelineRequest autoCommitTransaction. */ + public autoCommitTransaction?: (boolean|null); + /** ExecutePipelineRequest pipelineType. */ public pipelineType?: "structuredPipeline"; diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index bac75f1d3460..a09ca33985b4 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -264,7 +264,10 @@ export class ExecutionUtil { structuredPipeline: structuredPipeline._toProto(this._serializer), }; - if (transactionOrReadTime instanceof Uint8Array) { + if (structuredPipeline.options?.atomic) { + request.newTransaction = {readWrite: {}}; + request.autoCommitTransaction = true; + } else if (transactionOrReadTime instanceof Uint8Array) { request.transaction = transactionOrReadTime; } else if (transactionOrReadTime instanceof Timestamp) { request.readTime = transactionOrReadTime.toProto().timestampValue; diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 924d597722f9..cdaba07e1704 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -109,6 +109,12 @@ import { UpdateStage, Search, InternalSearchStageOptions, + InsertStage, + InternalInsertStageOptions, + UpsertStage, + InternalUpsertStageOptions, + LiteralsSource, + InternalLiteralsStageOptions, } from './stage'; import {StructuredPipeline} from './structured-pipeline'; import Selectable = FirebaseFirestore.Pipelines.Selectable; @@ -126,6 +132,46 @@ import { export class PipelineSource implements firestore.Pipelines.PipelineSource { constructor(private db: Firestore) {} + /** + * 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. + * @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: + | 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)]); + } + /** * Returns all documents from the entire collection. The collection can be nested. * @param collection - Name or reference to the collection that will be used as the Pipeline source. @@ -1766,8 +1812,123 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @return A new {@code Pipeline} object with this stage appended to the stage list. */ update(transformedFields: AliasedExpression[]): Pipeline; - update(transformedFields?: AliasedExpression[]): Pipeline { - return this._addStage(new UpdateStage(transformedFields)); + /** + * @beta + * Performs an update operation using documents from previous stages. + * + * @param fieldsMap - Map of field transformations to apply. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + update( + fieldsMap: Record | Map, + ): Pipeline; + update( + transformedFieldsOrMap?: + | AliasedExpression[] + | Map + | Record, + ): Pipeline { + 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)); + } + return this._addStage(new UpdateStage()); + } + + /** + * @beta + * 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 { + return this._addStage( + new InsertStage((options ?? {}) as InternalInsertStageOptions), + ); + } + + /** + * @beta + * Performs an upsert operation on documents from previous stages. + * + * @param transforms - Transformations 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( + transforms?: AliasedExpression[], + options?: Omit, + ): Pipeline; + upsert(options?: firestore.Pipelines.UpsertStageOptions): Pipeline; + upsert( + transformsOrOptions?: + | AliasedExpression[] + | firestore.Pipelines.UpsertStageOptions, + options?: Omit, + ): Pipeline { + let transforms: AliasedExpression[] = []; + let opts: InternalUpsertStageOptions = {}; + + if (Array.isArray(transformsOrOptions)) { + transforms = transformsOrOptions; + opts = (options ?? {}) as InternalUpsertStageOptions; + } else if (transformsOrOptions) { + const {transforms: t, ...rest} = + transformsOrOptions as firestore.Pipelines.UpsertStageOptions; + transforms = (t ?? []) as AliasedExpression[]; + opts = rest as InternalUpsertStageOptions; + } + + return this._addStage(new UpsertStage(transforms, opts)); + } + + /** + * 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, + ): 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 this._addStage(new LiteralsSource(documents, opts)); } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index c1153a03ad7e..f20d9848590e 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -17,7 +17,7 @@ import * as protos from '../../protos/firestore_v1_proto_api'; import api = protos.google.firestore.v1; import {DocumentReference} from '../reference/document-reference'; -import {ProtoSerializable, Serializer} from '../serializer'; +import {hasUserData, ProtoSerializable, Serializer} from '../serializer'; import { AggregateFunction, @@ -935,15 +935,23 @@ export class DeleteStage implements Stage { export class UpdateStage implements Stage { name = 'update'; readonly optionsUtil = new OptionsUtil({}); + private readonly transformedMap?: Map; - constructor(private transformedFields?: AliasedExpression[]) {} + constructor( + transformedFields?: AliasedExpression[] | Map, + ) { + if (transformedFields instanceof Map) { + this.transformedMap = transformedFields; + } else if (transformedFields && transformedFields.length > 0) { + this.transformedMap = selectablesToMap(transformedFields); + } + } _toProto(serializer: Serializer): api.Pipeline.IStage { const args: api.IValue[] = []; - if (this.transformedFields && this.transformedFields.length > 0) { - const mapped = selectablesToMap(this.transformedFields); - args.push(serializer.encodeValue(mapped)!); + if (this.transformedMap && this.transformedMap.size > 0) { + args.push(serializer.encodeValue(this.transformedMap)!); } else { args.push(serializer.encodeValue(new Map())!); } @@ -956,8 +964,196 @@ export class UpdateStage implements Stage { } _validateUserData(ignoreUndefinedProperties: boolean): void { - if (this.transformedFields) { - validateUserDataHelper(this.transformedFields, ignoreUndefinedProperties); + if (this.transformedMap) { + validateUserDataHelper(this.transformedMap, ignoreUndefinedProperties); + } + } +} + +/** + * Internal options for Insert stage. + */ +export type InternalInsertStageOptions = Omit< + firestore.Pipelines.InsertStageOptions, + 'collection' | 'documentId' +> & { + collection?: string | CollectionReference | firestore.CollectionReference; + documentId?: string | Expression | firestore.Pipelines.Expression; +}; + +/** + * Insert stage. + */ +export class InsertStage implements Stage { + name = 'insert'; + readonly optionsUtil = new OptionsUtil({}); + private readonly collectionPath?: string; + private readonly documentIdExpr?: Expression; + + constructor(private options: InternalInsertStageOptions = {}) { + if (options.collection) { + this.collectionPath = + typeof options.collection === 'string' + ? options.collection + : (options.collection as CollectionReference).path; + if (!this.collectionPath.startsWith('/')) { + this.collectionPath = '/' + this.collectionPath; + } + } + if (options.documentId) { + this.documentIdExpr = + typeof options.documentId === 'string' + ? field(options.documentId) + : (options.documentId as Expression); + } + } + + _toProto(serializer: Serializer): api.Pipeline.IStage { + const options = + this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ) || {}; + + if (this.collectionPath) { + options['collection'] = serializer.encodeReference(this.collectionPath); + } + if (this.documentIdExpr) { + options['document_id'] = this.documentIdExpr._toProto(serializer); + } + + return { + name: this.name, + args: [], + options: Object.keys(options).length > 0 ? options : undefined, + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + if (this.documentIdExpr) { + validateUserDataHelper(this.documentIdExpr, ignoreUndefinedProperties); + } + } +} + +/** + * Internal options for Upsert stage. + */ +export type InternalUpsertStageOptions = Omit< + firestore.Pipelines.UpsertStageOptions, + 'transforms' | 'collection' | 'documentId' +> & { + transforms?: AliasedExpression[]; + collection?: string | CollectionReference | firestore.CollectionReference; + documentId?: string | Expression | firestore.Pipelines.Expression; +}; + +/** + * Upsert stage. + */ +export class UpsertStage implements Stage { + name = 'upsert'; + readonly optionsUtil = new OptionsUtil({}); + private readonly collectionPath?: string; + private readonly documentIdExpr?: Expression; + private readonly transforms: Map; + + constructor( + transforms: AliasedExpression[] = [], + private options: InternalUpsertStageOptions = {}, + ) { + this.transforms = selectablesToMap(transforms); + if (options.collection) { + this.collectionPath = + typeof options.collection === 'string' + ? options.collection + : (options.collection as CollectionReference).path; + if (!this.collectionPath.startsWith('/')) { + this.collectionPath = '/' + this.collectionPath; + } + } + if (options.documentId) { + this.documentIdExpr = + typeof options.documentId === 'string' + ? field(options.documentId) + : (options.documentId as Expression); + } + } + + _toProto(serializer: Serializer): api.Pipeline.IStage { + const options = + this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ) || {}; + + if (this.collectionPath) { + options['collection'] = serializer.encodeReference(this.collectionPath); + } + if (this.documentIdExpr) { + options['document_id'] = this.documentIdExpr._toProto(serializer); + } + + const args: api.IValue[] = [serializer.encodeValue(this.transforms)!]; + + return { + name: this.name, + args, + options: Object.keys(options).length > 0 ? options : undefined, + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + validateUserDataHelper(this.transforms, ignoreUndefinedProperties); + if (this.documentIdExpr) { + validateUserDataHelper(this.documentIdExpr, ignoreUndefinedProperties); } } } + +/** + * Internal options for Literals stage. + */ +export type InternalLiteralsStageOptions = + firestore.Pipelines.LiteralsStageOptions; + +/** + * Literals stage. + */ +export class LiteralsSource implements Stage { + name = 'literals'; + readonly optionsUtil = new OptionsUtil({}); + + constructor( + readonly documents: Array>, + private options: InternalLiteralsStageOptions = {}, + ) {} + + _toProto(serializer: Serializer): api.Pipeline.IStage { + const args: api.IValue[] = this.documents.map( + doc => serializer.encodeValue(doc)!, + ); + return { + name: this.name, + args, + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + this.documents.forEach(doc => { + Object.values(doc).forEach(val => { + if (hasUserData(val)) { + val._validateUserData(ignoreUndefinedProperties); + } + }); + }); + } +} + diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index bd74d236997a..18cef99879ec 100644 --- a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts +++ b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts @@ -27,6 +27,7 @@ import {OptionsUtil} from './options-util'; */ export type StructuredPipelineOptions = { indexMode?: 'recommended'; + atomic?: boolean; explainOptions?: { mode?: 'execute' | 'explain' | 'analyze'; outputFormat?: 'text'; @@ -72,7 +73,7 @@ export class StructuredPipeline */ constructor( private pipeline: ProtoSerializable, - private options: StructuredPipelineOptions, + public options: StructuredPipelineOptions, private optionsOverride: Record, ) {} diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 9ae5bcd51596..0be62a12f9ef 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -465,6 +465,70 @@ describe.skipClassic('Pipeline class', () => { const res = await promise; expectResults(res, {documents_modified: 0}); }); + + it('can execute insert stage', async () => { + const newDocId = 'newBook_insert_1'; + const res = await firestore + .pipeline() + .literals([{title: 'New Book', author: 'Author 1'}]) + .insert({collection: dmlCol.path, documentId: newDocId}) + .execute(); + + expectResults(res, {documents_modified: 1}); + + const snap = await dmlCol.doc(newDocId).get(); + expect(snap.exists).to.be.true; + expect(snap.get('title')).to.equal('New Book'); + }); + + it('can execute upsert stage with transforms', async () => { + const upsertDocId = 'upsertBook_1'; + const res = await firestore + .pipeline() + .literals([{title: 'Upserted Book', count: 1}]) + .upsert([add(field('count'), constant(1)).as('count')], { + collection: dmlCol.path, + documentId: upsertDocId, + }) + .execute(); + + expectResults(res, {documents_modified: 1}); + + const snap = await dmlCol.doc(upsertDocId).get(); + expect(snap.exists).to.be.true; + expect(snap.get('title')).to.equal('Upserted Book'); + }); + + it('can execute DML with atomic option', async () => { + const atomicDocId = 'atomicBook_1'; + const res = await firestore + .pipeline() + .literals([{title: 'Atomic Book'}]) + .insert({collection: dmlCol.path, documentId: atomicDocId}) + .execute({atomic: true}); + + expectResults(res, {documents_modified: 1}); + + const snap = await dmlCol.doc(atomicDocId).get(); + expect(snap.exists).to.be.true; + }); + + it('can execute insert/upsert stages inside transaction runner', async () => { + const txDocId = 'txBook_1'; + await firestore.runTransaction(async transaction => { + const insertPpl = firestore + .pipeline() + .literals([{title: 'Tx Book'}]) + .insert({collection: dmlCol.path, documentId: txDocId}); + + const res = await transaction.execute(insertPpl); + expectResults(res, {documents_modified: 1}); + }); + + const snap = await dmlCol.doc(txDocId).get(); + expect(snap.exists).to.be.true; + expect(snap.get('title')).to.equal('Tx Book'); + }); }); 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 db31d6cd398f..6e395a9123b3 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -242,4 +242,100 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { }).to.throw(/Invalid CollectionReference.*database name/); }); }); + + it('serializes atomic execute option', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .execute({ + atomic: true, + }); + + const executePipelineRequest: IExecutePipelineRequest = { + database: 'projects/test-project/databases/(default)', + newTransaction: { + readWrite: {}, + }, + autoCommitTransaction: true, + structuredPipeline: { + options: {}, + pipeline: { + stages: [ + { + args: [ + { + referenceValue: '/foo', + }, + ], + name: 'collection', + options: {}, + }, + ], + }, + }, + }; + expect(spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]).to.deep.equal( + executePipelineRequest, + ); + }); + + it('serializes DML stages (delete, update, insert, upsert, literals)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .literals([{foo: 'bar'}]) + .insert({collection: 'foo', documentId: 'doc1'}) + .update() + .upsert([], {collection: 'bar'}) + .delete() + .execute(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages']; + + expect(stages.length).to.equal(5); + expect(stages[0].name).to.equal('literals'); + expect(stages[0].args).to.deep.equal([ + { + mapValue: { + fields: { + foo: {stringValue: 'bar'}, + }, + }, + }, + ]); + + expect(stages[1].name).to.equal('insert'); + expect(stages[1].options).to.deep.equal({ + collection: {referenceValue: '/foo'}, + document_id: {fieldReferenceValue: 'doc1'}, + }); + + expect(stages[2].name).to.equal('update'); + expect(stages[2].args).to.deep.equal([ + { + mapValue: { + fields: {}, + }, + }, + ]); + + expect(stages[3].name).to.equal('upsert'); + expect(stages[3].options).to.deep.equal({ + collection: {referenceValue: '/bar'}, + }); + + expect(stages[4].name).to.equal('delete'); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 6cadd2e970fb..5d507e6ecef7 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12455,6 +12455,14 @@ declare namespace FirebaseFirestore { * @throws {@FirestoreError} Thrown if any of the provided DocumentReferences target a different project or database than the pipeline. */ createFrom(query: Query): Pipeline; + /** + * Set the pipeline's source to the in-memory documents specified by the given records. + */ + literals( + documents: Array>, + options?: LiteralsStageOptions, + ): Pipeline; + literals(options: LiteralsStageOptions): Pipeline; } /** @@ -12894,6 +12902,49 @@ declare namespace FirebaseFirestore { * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(transformedFields: AliasedExpression[]): Pipeline; + /** + * @beta + * Performs an update operation using documents from previous stages. + * + * @param fieldsMap - Map of field transformations to apply. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ + update( + fieldsMap: Record | Map, + ): Pipeline; + /** + * @beta + * 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; + /** + * @beta + * Performs an upsert operation on documents from previous stages. + * + * @param transforms - Transformations 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( + transforms?: AliasedExpression[], + options?: Omit, + ): Pipeline; + upsert(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; /** * Filters the documents from previous stages to only include those matching the specified {@link * BooleanExpression}. @@ -13594,6 +13645,10 @@ declare namespace FirebaseFirestore { * Specifies the index mode for the query. */ indexMode?: 'recommended'; + /** + * Indicates that the pipeline will be executed atomically. + */ + atomic?: boolean; /** * @beta Options used to configure explain queries. */ explainOptions?: { @@ -14071,6 +14126,30 @@ declare namespace FirebaseFirestore { // separator?: string; // }; + /** + * Options defining how an Insert stage is evaluated. + */ + export type InsertStageOptions = StageOptions & { + collection?: string | CollectionReference; + documentId?: string | Expression; + }; + + /** + * Options defining how an Upsert stage is evaluated. + */ + export type UpsertStageOptions = StageOptions & { + transforms?: AliasedExpression[]; + collection?: string | CollectionReference; + documentId?: string | Expression; + }; + + /** + * Options defining how a LiteralsSource stage is evaluated. + */ + export type LiteralsStageOptions = StageOptions & { + documents?: Array>; + }; + /** * Represents a field value within the explain statistics, which can be a primitive type (null, string, number, boolean) * or a recursively defined object where keys are strings and values are also `ExplainStatsFieldValue`. diff --git a/handwritten/firestore/types/protos/firestore_v1_proto_api.d.ts b/handwritten/firestore/types/protos/firestore_v1_proto_api.d.ts index 65ceec85751b..2b0de4830873 100644 --- a/handwritten/firestore/types/protos/firestore_v1_proto_api.d.ts +++ b/handwritten/firestore/types/protos/firestore_v1_proto_api.d.ts @@ -2690,6 +2690,9 @@ export namespace google { /** ExecutePipelineRequest readTime */ readTime?: (google.protobuf.ITimestamp|null); + + /** ExecutePipelineRequest autoCommitTransaction */ + autoCommitTransaction?: (boolean|null); } /** Represents an ExecutePipelineRequest. */ @@ -2716,6 +2719,9 @@ export namespace google { /** ExecutePipelineRequest readTime. */ public readTime?: (google.protobuf.ITimestamp|null); + /** ExecutePipelineRequest autoCommitTransaction. */ + public autoCommitTransaction?: (boolean|null); + /** ExecutePipelineRequest pipelineType. */ public pipelineType?: "structuredPipeline"; From 18d2516725bc0959bcf246979767def250702388 Mon Sep 17 00:00:00 2001 From: wuandy Date: Tue, 11 Aug 2026 11:46:08 +0000 Subject: [PATCH 02/17] test(firestore): refine Node SDK DML integration tests for backend execution --- handwritten/firestore/dev/system-test/pipeline.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 0be62a12f9ef..6c150894deeb 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -471,7 +471,7 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'New Book', author: 'Author 1'}]) - .insert({collection: dmlCol.path, documentId: newDocId}) + .insert({collection: dmlCol.path, documentId: constant(newDocId)}) .execute(); expectResults(res, {documents_modified: 1}); @@ -488,9 +488,9 @@ describe.skipClassic('Pipeline class', () => { .literals([{title: 'Upserted Book', count: 1}]) .upsert([add(field('count'), constant(1)).as('count')], { collection: dmlCol.path, - documentId: upsertDocId, + documentId: constant(upsertDocId), }) - .execute(); + .execute({atomic: true}); expectResults(res, {documents_modified: 1}); @@ -504,7 +504,7 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'Atomic Book'}]) - .insert({collection: dmlCol.path, documentId: atomicDocId}) + .insert({collection: dmlCol.path, documentId: constant(atomicDocId)}) .execute({atomic: true}); expectResults(res, {documents_modified: 1}); @@ -519,7 +519,7 @@ describe.skipClassic('Pipeline class', () => { const insertPpl = firestore .pipeline() .literals([{title: 'Tx Book'}]) - .insert({collection: dmlCol.path, documentId: txDocId}); + .insert({collection: dmlCol.path, documentId: constant(txDocId)}); const res = await transaction.execute(insertPpl); expectResults(res, {documents_modified: 1}); From 89dc6c6f4336408eeaef272dbdafb6d81cb1d2ea Mon Sep 17 00:00:00 2001 From: wuandy Date: Mon, 24 Aug 2026 14:40:55 +0000 Subject: [PATCH 03/17] fix(firestore): align transaction precedence over atomic pipeline execution and test atomic: false --- .../dev/src/pipelines/pipeline-util.ts | 8 ++-- .../firestore/dev/test/pipelines/pipeline.ts | 37 +++++++++++++++++++ 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index a09ca33985b4..9158038942d4 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -264,15 +264,15 @@ export class ExecutionUtil { structuredPipeline: structuredPipeline._toProto(this._serializer), }; - if (structuredPipeline.options?.atomic) { - request.newTransaction = {readWrite: {}}; - request.autoCommitTransaction = true; - } else if (transactionOrReadTime instanceof Uint8Array) { + if (transactionOrReadTime instanceof Uint8Array) { request.transaction = transactionOrReadTime; } else if (transactionOrReadTime instanceof Timestamp) { request.readTime = transactionOrReadTime.toProto().timestampValue; } else if (transactionOrReadTime) { request.newTransaction = transactionOrReadTime; + } else if (structuredPipeline.options?.atomic) { + request.newTransaction = {readWrite: {}}; + request.autoCommitTransaction = true; } let streamActive: Deferred; diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 6e395a9123b3..d18eff4bf193 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -284,6 +284,43 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { ); }); + it('does not serialize atomic when atomic is false', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .execute({ + atomic: false, + }); + + const executePipelineRequest: IExecutePipelineRequest = { + database: 'projects/test-project/databases/(default)', + structuredPipeline: { + options: {}, + pipeline: { + stages: [ + { + args: [ + { + referenceValue: '/foo', + }, + ], + name: 'collection', + options: {}, + }, + ], + }, + }, + }; + expect(spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]).to.deep.equal( + executePipelineRequest, + ); + }); + it('serializes DML stages (delete, update, insert, upsert, literals)', async () => { const spy = sinon.fake.returns(stream()); const firestore = await createInstance({ From 6374829fc1984d340a50dba3279947b37c55d488 Mon Sep 17 00:00:00 2001 From: wuandy Date: Tue, 8 Sep 2026 19:17:08 +0000 Subject: [PATCH 04/17] refactor(firestore): rename documentId to documentIdExpression in stage options --- .../firestore/dev/src/pipelines/stage.ts | 24 +++++++++---------- .../firestore/dev/system-test/pipeline.ts | 8 +++---- .../firestore/dev/test/pipelines/pipeline.ts | 2 +- handwritten/firestore/types/firestore.d.ts | 4 ++-- 4 files changed, 19 insertions(+), 19 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index f20d9848590e..6d80a662df54 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -975,10 +975,10 @@ export class UpdateStage implements Stage { */ export type InternalInsertStageOptions = Omit< firestore.Pipelines.InsertStageOptions, - 'collection' | 'documentId' + 'collection' | 'documentIdExpression' > & { collection?: string | CollectionReference | firestore.CollectionReference; - documentId?: string | Expression | firestore.Pipelines.Expression; + documentIdExpression?: string | Expression | firestore.Pipelines.Expression; }; /** @@ -1000,11 +1000,11 @@ export class InsertStage implements Stage { this.collectionPath = '/' + this.collectionPath; } } - if (options.documentId) { + if (options.documentIdExpression) { this.documentIdExpr = - typeof options.documentId === 'string' - ? field(options.documentId) - : (options.documentId as Expression); + typeof options.documentIdExpression === 'string' + ? field(options.documentIdExpression) + : (options.documentIdExpression as Expression); } } @@ -1042,11 +1042,11 @@ export class InsertStage implements Stage { */ export type InternalUpsertStageOptions = Omit< firestore.Pipelines.UpsertStageOptions, - 'transforms' | 'collection' | 'documentId' + 'transforms' | 'collection' | 'documentIdExpression' > & { transforms?: AliasedExpression[]; collection?: string | CollectionReference | firestore.CollectionReference; - documentId?: string | Expression | firestore.Pipelines.Expression; + documentIdExpression?: string | Expression | firestore.Pipelines.Expression; }; /** @@ -1073,11 +1073,11 @@ export class UpsertStage implements Stage { this.collectionPath = '/' + this.collectionPath; } } - if (options.documentId) { + if (options.documentIdExpression) { this.documentIdExpr = - typeof options.documentId === 'string' - ? field(options.documentId) - : (options.documentId as Expression); + typeof options.documentIdExpression === 'string' + ? field(options.documentIdExpression) + : (options.documentIdExpression as Expression); } } diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 6c150894deeb..734c74a1d3d8 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -471,7 +471,7 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'New Book', author: 'Author 1'}]) - .insert({collection: dmlCol.path, documentId: constant(newDocId)}) + .insert({collection: dmlCol.path, documentIdExpression: constant(newDocId)}) .execute(); expectResults(res, {documents_modified: 1}); @@ -488,7 +488,7 @@ describe.skipClassic('Pipeline class', () => { .literals([{title: 'Upserted Book', count: 1}]) .upsert([add(field('count'), constant(1)).as('count')], { collection: dmlCol.path, - documentId: constant(upsertDocId), + documentIdExpression: constant(upsertDocId), }) .execute({atomic: true}); @@ -504,7 +504,7 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'Atomic Book'}]) - .insert({collection: dmlCol.path, documentId: constant(atomicDocId)}) + .insert({collection: dmlCol.path, documentIdExpression: constant(atomicDocId)}) .execute({atomic: true}); expectResults(res, {documents_modified: 1}); @@ -519,7 +519,7 @@ describe.skipClassic('Pipeline class', () => { const insertPpl = firestore .pipeline() .literals([{title: 'Tx Book'}]) - .insert({collection: dmlCol.path, documentId: constant(txDocId)}); + .insert({collection: dmlCol.path, documentIdExpression: constant(txDocId)}); const res = await transaction.execute(insertPpl); expectResults(res, {documents_modified: 1}); diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index d18eff4bf193..42b3992892d6 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -330,7 +330,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { await firestore .pipeline() .literals([{foo: 'bar'}]) - .insert({collection: 'foo', documentId: 'doc1'}) + .insert({collection: 'foo', documentIdExpression: 'doc1'}) .update() .upsert([], {collection: 'bar'}) .delete() diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 5d507e6ecef7..4a3cd90e3694 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -14131,7 +14131,7 @@ declare namespace FirebaseFirestore { */ export type InsertStageOptions = StageOptions & { collection?: string | CollectionReference; - documentId?: string | Expression; + documentIdExpression?: string | Expression; }; /** @@ -14140,7 +14140,7 @@ declare namespace FirebaseFirestore { export type UpsertStageOptions = StageOptions & { transforms?: AliasedExpression[]; collection?: string | CollectionReference; - documentId?: string | Expression; + documentIdExpression?: string | Expression; }; /** From 4b96d596b087becc9e30e8c706d782f980fe195c Mon Sep 17 00:00:00 2001 From: wuandy Date: Tue, 8 Sep 2026 20:15:21 +0000 Subject: [PATCH 05/17] refactor(firestore): rename transforms to additionalFields in upsert stage --- .../firestore/dev/src/pipelines/pipelines.ts | 94 ++++- .../firestore/dev/src/pipelines/stage.ts | 58 ++- .../firestore/dev/system-test/pipeline.ts | 5 +- .../firestore/dev/test/pipelines/pipeline.ts | 337 +++++++++++++++++- handwritten/firestore/types/firestore.d.ts | 50 ++- 5 files changed, 501 insertions(+), 43 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index cdaba07e1704..5e70d386a402 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -1804,6 +1804,18 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @return A new {@code Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; + /** + * @beta + * Performs an update operation using documents from previous stages. + * + * @param transformedField - The first field transformation to apply. + * @param additionalFields - Additional field transformations to apply. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + update( + transformedField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): Pipeline; /** * @beta * Performs an update operation using documents from previous stages. @@ -1824,11 +1836,17 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ): Pipeline; update( transformedFieldsOrMap?: + | AliasedExpression | AliasedExpression[] | Map | Record, + ...additionalFields: AliasedExpression[] ): Pipeline { - if ( + if (isAliasedExpr(transformedFieldsOrMap)) { + return this._addStage( + new UpdateStage([transformedFieldsOrMap, ...additionalFields]), + ); + } else if ( transformedFieldsOrMap instanceof Map || Array.isArray(transformedFieldsOrMap) ) { @@ -1859,35 +1877,73 @@ export class Pipeline implements firestore.Pipelines.Pipeline { * @beta * Performs an upsert operation on documents from previous stages. * - * @param transforms - Transformations to apply on upsert. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + upsert(): Pipeline; + /** + * @beta + * 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; + /** + * @beta + * Performs an upsert operation on documents from previous stages. + * + * @param additionalField - The first additional field to apply on upsert. + * @param additionalFields - Additional fields to apply on upsert. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ upsert( - transforms?: AliasedExpression[], - options?: Omit, + additionalField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): Pipeline; + /** + * @beta + * 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( + additionalFields: AliasedExpression[], + options?: firestore.Pipelines.UpsertStageOptions, ): Pipeline; - upsert(options?: firestore.Pipelines.UpsertStageOptions): Pipeline; upsert( - transformsOrOptions?: + additionalFieldsOrOptions?: + | AliasedExpression | AliasedExpression[] | firestore.Pipelines.UpsertStageOptions, - options?: Omit, + ...restArgs: unknown[] ): Pipeline { - let transforms: AliasedExpression[] = []; - let opts: InternalUpsertStageOptions = {}; - - if (Array.isArray(transformsOrOptions)) { - transforms = transformsOrOptions; - opts = (options ?? {}) as InternalUpsertStageOptions; - } else if (transformsOrOptions) { - const {transforms: t, ...rest} = - transformsOrOptions as firestore.Pipelines.UpsertStageOptions; - transforms = (t ?? []) as AliasedExpression[]; - opts = rest as InternalUpsertStageOptions; + 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(transforms, opts)); + return this._addStage( + new UpsertStage({ + ...options, + ...(additionalFields ? {additionalFields} : {}), + }), + ); } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 6d80a662df54..5b3a2a907b9a 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -1042,9 +1042,14 @@ export class InsertStage implements Stage { */ export type InternalUpsertStageOptions = Omit< firestore.Pipelines.UpsertStageOptions, - 'transforms' | 'collection' | 'documentIdExpression' + 'additionalFields' | 'transforms' | 'collection' | 'documentIdExpression' > & { - transforms?: AliasedExpression[]; + additionalFields?: + | AliasedExpression[] + | firestore.Pipelines.AliasedExpression[]; + transforms?: + | AliasedExpression[] + | firestore.Pipelines.AliasedExpression[]; collection?: string | CollectionReference | firestore.CollectionReference; documentIdExpression?: string | Expression | firestore.Pipelines.Expression; }; @@ -1057,27 +1062,48 @@ export class UpsertStage implements Stage { readonly optionsUtil = new OptionsUtil({}); private readonly collectionPath?: string; private readonly documentIdExpr?: Expression; - private readonly transforms: Map; + private readonly additionalFields: Map; + + private readonly options: InternalUpsertStageOptions; constructor( - transforms: AliasedExpression[] = [], - private options: InternalUpsertStageOptions = {}, + additionalFieldsOrOptions?: + | AliasedExpression[] + | InternalUpsertStageOptions, + options?: InternalUpsertStageOptions, ) { - this.transforms = selectablesToMap(transforms); - if (options.collection) { + 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; + + const resolvedFields = + (opts.additionalFields ?? + opts.transforms ?? + fields) as AliasedExpression[]; + this.additionalFields = selectablesToMap(resolvedFields); + if (opts.collection) { this.collectionPath = - typeof options.collection === 'string' - ? options.collection - : (options.collection as CollectionReference).path; + typeof opts.collection === 'string' + ? opts.collection + : (opts.collection as CollectionReference).path; if (!this.collectionPath.startsWith('/')) { this.collectionPath = '/' + this.collectionPath; } } - if (options.documentIdExpression) { + if (opts.documentIdExpression) { this.documentIdExpr = - typeof options.documentIdExpression === 'string' - ? field(options.documentIdExpression) - : (options.documentIdExpression as Expression); + typeof opts.documentIdExpression === 'string' + ? field(opts.documentIdExpression) + : (opts.documentIdExpression as Expression); } } @@ -1096,7 +1122,7 @@ export class UpsertStage implements Stage { options['document_id'] = this.documentIdExpr._toProto(serializer); } - const args: api.IValue[] = [serializer.encodeValue(this.transforms)!]; + const args: api.IValue[] = [serializer.encodeValue(this.additionalFields)!]; return { name: this.name, @@ -1106,7 +1132,7 @@ export class UpsertStage implements Stage { } _validateUserData(ignoreUndefinedProperties: boolean): void { - validateUserDataHelper(this.transforms, ignoreUndefinedProperties); + validateUserDataHelper(this.additionalFields, ignoreUndefinedProperties); if (this.documentIdExpr) { validateUserDataHelper(this.documentIdExpr, ignoreUndefinedProperties); } diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 734c74a1d3d8..877453118779 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -481,14 +481,15 @@ describe.skipClassic('Pipeline class', () => { expect(snap.get('title')).to.equal('New Book'); }); - it('can execute upsert stage with transforms', async () => { + it('can execute upsert stage with additionalFields', async () => { const upsertDocId = 'upsertBook_1'; const res = await firestore .pipeline() .literals([{title: 'Upserted Book', count: 1}]) - .upsert([add(field('count'), constant(1)).as('count')], { + .upsert({ collection: dmlCol.path, documentIdExpression: constant(upsertDocId), + additionalFields: [add(field('count'), constant(1)).as('count')], }) .execute({atomic: true}); diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 42b3992892d6..e8e504064df5 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -19,7 +19,9 @@ import {expect} from 'chai'; import * as sinon from 'sinon'; import {createInstance, stream} from '../util/helpers'; import {google} from '../../protos/firestore_v1_proto_api'; -import {Firestore, Timestamp} from '../../src'; +import {Firestore, Pipelines, Timestamp} from '../../src'; +import constant = Pipelines.constant; +import field = Pipelines.field; import IExecutePipelineRequest = google.firestore.v1.IExecutePipelineRequest; import IExecutePipelineResponse = google.firestore.v1.IExecutePipelineResponse; @@ -332,7 +334,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { .literals([{foo: 'bar'}]) .insert({collection: 'foo', documentIdExpression: 'doc1'}) .update() - .upsert([], {collection: 'bar'}) + .upsert({collection: 'bar', additionalFields: []}) .delete() .execute(); @@ -375,4 +377,335 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[4].name).to.equal('delete'); }); + + describe('update stage overloads', () => { + it('serializes 0-arg update()', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore.pipeline().collection('foo').update().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: {}, + }, + }, + ]); + }); + + it('serializes single expression update(expr)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .update(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}, + }, + }, + }, + ]); + }); + + it('serializes multiple variadic expressions update(expr1, expr2)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .update(constant(true).as('active'), constant(1).as('count')) + .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}, + count: {integerValue: 1}, + }, + }, + }, + ]); + }); + + it('serializes array of expressions update([expr])', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .update([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', () => { + it('serializes 0-arg upsert()', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore.pipeline().collection('foo').upsert().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: {}, + }, + }, + ]); + expect(stages[1].options).to.be.undefined; + }); + + it('serializes options-only upsert(options)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert({ + collection: 'users', + documentIdExpression: field('id'), + }) + .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: {}, + }, + }, + ]); + expect(stages[1].options).to.deep.equal({ + collection: {referenceValue: '/users'}, + document_id: {fieldReferenceValue: 'id'}, + }); + }); + + it('serializes single variadic expression upsert(expr)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert(constant(true).as('active')) + .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.be.undefined; + }); + + it('serializes multiple variadic expressions upsert(expr1, expr2)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert(constant(true).as('active'), constant(1).as('count')) + .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}, + count: {integerValue: 1}, + }, + }, + }, + ]); + expect(stages[1].options).to.be.undefined; + }); + + it('serializes array of expressions without options upsert([expr])', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert([constant(true).as('active')]) + .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.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 () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert({ + collection: 'users', + transforms: [constant(true).as('active')], + }) + .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'}, + }); + }); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 4a3cd90e3694..aa2fb5ccfc08 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12894,6 +12894,18 @@ declare namespace FirebaseFirestore { * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; + /** + * @beta + * Performs an update operation using documents from previous stages. + * + * @param transformedField - The first field transformation to apply. + * @param additionalFields - Additional field transformations to apply. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ + update( + transformedField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): Pipeline; /** * @beta * Performs an update operation using documents from previous stages. @@ -12924,15 +12936,41 @@ declare namespace FirebaseFirestore { * @beta * Performs an upsert operation on documents from previous stages. * - * @param transforms - Transformations to apply on upsert. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ + upsert(): Pipeline; + /** + * @beta + * 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; + /** + * @beta + * Performs an upsert operation on documents from previous stages. + * + * @param additionalField - The first additional field to apply on upsert. + * @param additionalFields - Additional fields to apply on upsert. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ upsert( - transforms?: AliasedExpression[], - options?: Omit, + additionalField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): Pipeline; + /** + * @beta + * 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; - upsert(options?: UpsertStageOptions): Pipeline; /** * Appends a literals stage to the pipeline. * @@ -14138,6 +14176,10 @@ declare namespace FirebaseFirestore { * Options defining how an Upsert stage is evaluated. */ export type UpsertStageOptions = StageOptions & { + additionalFields?: AliasedExpression[]; + /** + * @deprecated Use `additionalFields` instead. + */ transforms?: AliasedExpression[]; collection?: string | CollectionReference; documentIdExpression?: string | Expression; From 3c9a10e9d634493a43b058e259c52e57a2c02e7f Mon Sep 17 00:00:00 2001 From: wuandy Date: Fri, 2 Oct 2026 14:07:25 +0000 Subject: [PATCH 06/17] test(firestore): expand pipeline DML unit and system test coverage to parity --- .../dev/src/pipelines/pipeline-util.ts | 5 +- .../firestore/dev/src/pipelines/pipelines.ts | 33 +- .../firestore/dev/src/pipelines/stage.ts | 22 +- .../dev/src/pipelines/structured-pipeline.ts | 4 +- .../firestore/dev/system-test/pipeline.ts | 302 +++++++++++++++++- .../firestore/dev/test/pipelines/pipeline.ts | 91 +++++- handwritten/firestore/types/firestore.d.ts | 10 - 7 files changed, 399 insertions(+), 68 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts index 9158038942d4..ed820eadfb8a 100644 --- a/handwritten/firestore/dev/src/pipelines/pipeline-util.ts +++ b/handwritten/firestore/dev/src/pipelines/pipeline-util.ts @@ -18,11 +18,11 @@ import {Duplex, Transform} from 'stream'; import {google} from '../../protos/firestore_v1_proto_api'; import * as protos from '../../protos/firestore_v1_proto_api'; -import './expression'; -import Firestore, { +import { CollectionReference, DocumentReference, FieldValue, + Firestore, Timestamp, VectorValue, } from '../index'; @@ -303,6 +303,7 @@ export class ExecutionUtil { backendStream.resume(); backendStream.pipe(stream); } while (await streamActive.promise); + return; }) .catch(e => { logger( diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 5e70d386a402..9eaeb384970e 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -112,7 +112,6 @@ import { InsertStage, InternalInsertStageOptions, UpsertStage, - InternalUpsertStageOptions, LiteralsSource, InternalLiteralsStageOptions, } from './stage'; @@ -142,13 +141,10 @@ export class PipelineSource implements firestore.Pipelines.PipelineSource { documents: Array>, options?: firestore.Pipelines.LiteralsStageOptions, ): Pipeline; - literals( - options: firestore.Pipelines.LiteralsStageOptions, - ): Pipeline; + literals(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; literals( docsOrOptions: - | Array> - | firestore.Pipelines.LiteralsStageOptions, + Array> | firestore.Pipelines.LiteralsStageOptions, options?: firestore.Pipelines.LiteralsStageOptions, ): Pipeline { let documents: Array> = []; @@ -1781,7 +1777,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } /** - * @beta * Performs a delete operation on documents from previous stages. * * @example @@ -1798,14 +1793,12 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } /** - * @beta * Performs an update operation using documents from previous stages. * * @return A new {@code Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param transformedField - The first field transformation to apply. @@ -1817,7 +1810,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ...additionalFields: AliasedExpression[] ): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param transformedFields - The list of transformations to apply. @@ -1825,7 +1817,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { */ update(transformedFields: AliasedExpression[]): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param fieldsMap - Map of field transformations to apply. @@ -1851,7 +1842,10 @@ export class Pipeline implements firestore.Pipelines.Pipeline { Array.isArray(transformedFieldsOrMap) ) { return this._addStage(new UpdateStage(transformedFieldsOrMap)); - } else if (transformedFieldsOrMap && isPlainObject(transformedFieldsOrMap)) { + } else if ( + transformedFieldsOrMap && + isPlainObject(transformedFieldsOrMap) + ) { const map = new Map( Object.entries(transformedFieldsOrMap as Record), ); @@ -1861,7 +1855,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } /** - * @beta * Performs an insert operation on documents from previous stages. * * @param options - Options defining how this Insert stage is evaluated. @@ -1874,14 +1867,12 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } /** - * @beta * Performs an upsert operation on documents from previous stages. * * @return A new {@code Pipeline} object with this stage appended to the stage list. */ upsert(): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param options - Options defining how this Upsert stage is evaluated. @@ -1889,7 +1880,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { */ upsert(options: firestore.Pipelines.UpsertStageOptions): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param additionalField - The first additional field to apply on upsert. @@ -1901,7 +1891,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { ...additionalFields: AliasedExpression[] ): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param additionalFields - Additional fields to apply on upsert. @@ -1931,8 +1920,7 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } else if (Array.isArray(additionalFieldsOrOptions)) { additionalFields = additionalFieldsOrOptions; options = restArgs[0] as - | firestore.Pipelines.UpsertStageOptions - | undefined; + firestore.Pipelines.UpsertStageOptions | undefined; } else { additionalFields = undefined; options = additionalFieldsOrOptions; @@ -1957,13 +1945,10 @@ export class Pipeline implements firestore.Pipelines.Pipeline { documents: Array>, options?: firestore.Pipelines.LiteralsStageOptions, ): Pipeline; - literals( - options: firestore.Pipelines.LiteralsStageOptions, - ): Pipeline; + literals(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; literals( docsOrOptions: - | Array> - | firestore.Pipelines.LiteralsStageOptions, + Array> | firestore.Pipelines.LiteralsStageOptions, options?: firestore.Pipelines.LiteralsStageOptions, ): Pipeline { let documents: Array> = []; diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 5b3a2a907b9a..4404c5d55f6e 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -1045,11 +1045,8 @@ export type InternalUpsertStageOptions = Omit< 'additionalFields' | 'transforms' | 'collection' | 'documentIdExpression' > & { additionalFields?: - | AliasedExpression[] - | firestore.Pipelines.AliasedExpression[]; - transforms?: - | AliasedExpression[] - | firestore.Pipelines.AliasedExpression[]; + AliasedExpression[] | firestore.Pipelines.AliasedExpression[]; + transforms?: AliasedExpression[] | firestore.Pipelines.AliasedExpression[]; collection?: string | CollectionReference | firestore.CollectionReference; documentIdExpression?: string | Expression | firestore.Pipelines.Expression; }; @@ -1068,8 +1065,7 @@ export class UpsertStage implements Stage { constructor( additionalFieldsOrOptions?: - | AliasedExpression[] - | InternalUpsertStageOptions, + AliasedExpression[] | InternalUpsertStageOptions, options?: InternalUpsertStageOptions, ) { let fields: AliasedExpression[] = []; @@ -1085,10 +1081,9 @@ export class UpsertStage implements Stage { } this.options = opts; - const resolvedFields = - (opts.additionalFields ?? - opts.transforms ?? - fields) as AliasedExpression[]; + const resolvedFields = (opts.additionalFields ?? + opts.transforms ?? + fields) as AliasedExpression[]; this.additionalFields = selectablesToMap(resolvedFields); if (opts.collection) { this.collectionPath = @@ -1158,8 +1153,8 @@ export class LiteralsSource implements Stage { ) {} _toProto(serializer: Serializer): api.Pipeline.IStage { - const args: api.IValue[] = this.documents.map( - doc => serializer.encodeValue(doc)!, + const args: api.IValue[] = this.documents.map(doc => + serializer.encodeValue(doc)!, ); return { name: this.name, @@ -1182,4 +1177,3 @@ export class LiteralsSource implements Stage { }); } } - diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index 18cef99879ec..ac990ef29c23 100644 --- a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts +++ b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts @@ -41,9 +41,7 @@ export type StructuredPipelineOptions = { * StructuredPipeline encapsulates the Pipeline and PipelineOptions and supports * serialization of this data to a StructuredPipeline proto. */ -export class StructuredPipeline - implements ProtoSerializable -{ +export class StructuredPipeline implements ProtoSerializable { /** * OptionsUtil which defines the known StructuredPipelineOptions. * This maps client side options to their server names and structure. diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 877453118779..13ebb045a01c 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -471,7 +471,10 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'New Book', author: 'Author 1'}]) - .insert({collection: dmlCol.path, documentIdExpression: constant(newDocId)}) + .insert({ + collection: dmlCol.path, + documentIdExpression: constant(newDocId), + }) .execute(); expectResults(res, {documents_modified: 1}); @@ -505,7 +508,10 @@ describe.skipClassic('Pipeline class', () => { const res = await firestore .pipeline() .literals([{title: 'Atomic Book'}]) - .insert({collection: dmlCol.path, documentIdExpression: constant(atomicDocId)}) + .insert({ + collection: dmlCol.path, + documentIdExpression: constant(atomicDocId), + }) .execute({atomic: true}); expectResults(res, {documents_modified: 1}); @@ -520,7 +526,10 @@ describe.skipClassic('Pipeline class', () => { const insertPpl = firestore .pipeline() .literals([{title: 'Tx Book'}]) - .insert({collection: dmlCol.path, documentIdExpression: constant(txDocId)}); + .insert({ + collection: dmlCol.path, + documentIdExpression: constant(txDocId), + }); const res = await transaction.execute(insertPpl); expectResults(res, {documents_modified: 1}); @@ -530,6 +539,293 @@ describe.skipClassic('Pipeline class', () => { expect(snap.exists).to.be.true; expect(snap.get('title')).to.equal('Tx Book'); }); + + it('can delete a single document', async () => { + const deletePpl = firestore + .pipeline() + .documents([dmlCol.doc('book2')]) + .delete(); + + const deleteRes = await deletePpl.execute(); + expectResults(deleteRes, {documents_modified: 1}); + + const docSnap2 = await dmlCol.doc('book2').get(); + expect(docSnap2.exists).to.be.false; + + const docSnap1 = await dmlCol.doc('book1').get(); + expect(docSnap1.exists).to.be.true; + }); + + it('can delete a non-existing document without error', async () => { + const nonExistingId = 'nonExistingId_999'; + const deleteRes = await firestore + .pipeline() + .documents([dmlCol.doc(nonExistingId)]) + .delete() + .execute(); + expectResults(deleteRes, {documents_modified: 0}); + }); + + it('can delete documents with atomic: true', async () => { + const deleteRes = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .delete() + .execute({atomic: true}); + expectResults(deleteRes, {documents_modified: 1}); + + const docSnap1 = await dmlCol.doc('book1').get(); + expect(docSnap1.exists).to.be.false; + }); + + it('can delete documents with atomic: false', async () => { + const deleteRes = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book3')) + .delete() + .execute({atomic: false}); + expectResults(deleteRes, {documents_modified: 1}); + + const docSnap3 = await dmlCol.doc('book3').get(); + expect(docSnap3.exists).to.be.false; + }); + + it('can update documents with a single vararg expression', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .update(constant('UpdatedVariadic').as('status')) + .execute(); + expectResults(res, {documents_modified: 1}); + + const docSnap = await dmlCol.doc('book1').get(); + expect(docSnap.get('status')).to.equal('UpdatedVariadic'); + }); + + it('can update documents with multiple vararg expressions', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .update( + constant('UpdatedMulti').as('status'), + constant(99).as('newField'), + ) + .execute(); + expectResults(res, {documents_modified: 1}); + + const docSnap = await dmlCol.doc('book1').get(); + expect(docSnap.get('status')).to.equal('UpdatedMulti'); + expect(docSnap.get('newField')).to.equal(99); + }); + + it('can update documents atomically', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .update([constant('AtomicUpdate').as('status')]) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await dmlCol.doc('book1').get(); + expect(docSnap.get('status')).to.equal('AtomicUpdate'); + }); + + it('can execute update stage within a transaction', async () => { + await firestore.runTransaction(async transaction => { + const updatePpl = firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book2')) + .update([constant('TxUpdated').as('status')]); + + const res = await transaction.execute(updatePpl); + expectResults(res, {documents_modified: 1}); + }); + + const docSnap = await dmlCol.doc('book2').get(); + expect(docSnap.get('status')).to.equal('TxUpdated'); + }); + + it('can insert a new document with auto-generated ID', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .removeFields('__name__') + .insert({collection: dmlCol.path}) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const snap = await dmlCol.get(); + expect(snap.size).to.equal(11); + }); + + it('insert fails if document already exists', async () => { + let error: Error | undefined; + try { + await firestore + .pipeline() + .literals([{title: 'Duplicate Book'}]) + .insert({ + collection: dmlCol.path, + documentIdExpression: constant('book1'), + }) + .execute({atomic: true}); + } catch (e) { + error = e as Error; + } + expect(error).to.exist; + }); + + it('can insert into a different collection', async () => { + const targetCol = getTestRoot(); + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .removeFields('__name__') + .insert({collection: targetCol.path}) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const snap = await targetCol.get(); + expect(snap.size).to.equal(1); + expect(snap.docs[0].get('title')).to.equal( + "The Hitchhiker's Guide to the Galaxy", + ); + }); + + it('can upsert (update) an existing document with varargs', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .upsert( + constant('Comedy Sci-Fi').as('genre'), + add(field('rating'), constant(0.5)).as('rating'), + ) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await dmlCol.doc('book1').get(); + expect(docSnap.get('genre')).to.equal('Comedy Sci-Fi'); + expect(docSnap.get('rating')).to.equal(4.7); + }); + + it('can upsert (update) an existing document with array of expressions', 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'), + ]) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await dmlCol.doc('book1').get(); + expect(docSnap.get('genre')).to.equal('Updated Genre List'); + expect(docSnap.get('rating')).to.equal(5.0); + }); + + it('can upsert into a different collection with additionalFields', async () => { + const targetCol = getTestRoot(); + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .upsert({ + collection: targetCol.path, + documentIdExpression: constant('target_doc_1'), + additionalFields: [ + constant('Target Upsert Title').as('title'), + constant('Target Genre').as('genre'), + ], + }) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await targetCol.doc('target_doc_1').get(); + expect(docSnap.exists).to.be.true; + expect(docSnap.get('title')).to.equal('Target Upsert Title'); + expect(docSnap.get('genre')).to.equal('Target Genre'); + }); + + it('can upsert into a different collection without additionalFields', async () => { + const targetCol = getTestRoot(); + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .upsert({ + collection: targetCol.path, + documentIdExpression: constant('target_doc_2'), + }) + .execute({atomic: true}); + expectResults(res, {documents_modified: 1}); + + const docSnap = await targetCol.doc('target_doc_2').get(); + expect(docSnap.exists).to.be.true; + expect(docSnap.get('title')).to.equal( + "The Hitchhiker's Guide to the Galaxy", + ); + }); + + it('can execute upsert stage within a transaction', async () => { + const txDocId = 'txUpsertBook_1'; + await firestore.runTransaction(async transaction => { + const upsertPpl = firestore + .pipeline() + .literals([{title: 'Tx Upsert Book'}]) + .upsert({ + collection: dmlCol.path, + documentIdExpression: constant(txDocId), + }); + + const res = await transaction.execute(upsertPpl); + expectResults(res, {documents_modified: 1}); + }); + + const snap = await dmlCol.doc(txDocId).get(); + expect(snap.exists).to.be.true; + expect(snap.get('title')).to.equal('Tx Upsert Book'); + }); + + it('can execute pipeline with literals stage', async () => { + const res = await firestore + .pipeline() + .literals([ + {name: 'Alice', score: 100}, + {name: 'Bob', score: 200}, + ]) + .execute(); + expect(res.results.length).to.equal(2); + expect(res.results[0].data()).to.deep.equal({ + name: 'Alice', + score: 100, + }); + expect(res.results[1].data()).to.deep.equal({name: 'Bob', score: 200}); + }); + + it('can execute literals stage with expressions', async () => { + const res = await firestore + .pipeline() + .literals([ + { + base: 10, + computed: add(constant(10), constant(25)), + }, + ]) + .execute(); + expect(res.results.length).to.equal(1); + expect(res.results[0].data()).to.deep.equal({base: 10, computed: 35}); + }); }); 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 e8e504064df5..51269f4e6bf9 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -251,12 +251,9 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { executePipeline: spy, }); - await firestore - .pipeline() - .collection('foo') - .execute({ - atomic: true, - }); + await firestore.pipeline().collection('foo').execute({ + atomic: true, + }); const executePipelineRequest: IExecutePipelineRequest = { database: 'projects/test-project/databases/(default)', @@ -292,12 +289,9 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { executePipeline: spy, }); - await firestore - .pipeline() - .collection('foo') - .execute({ - atomic: false, - }); + await firestore.pipeline().collection('foo').execute({ + atomic: false, + }); const executePipelineRequest: IExecutePipelineRequest = { database: 'projects/test-project/databases/(default)', @@ -378,6 +372,79 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[4].name).to.equal('delete'); }); + it('serializes insert stage with auto-generated ID (collection only)', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .insert({collection: 'users'}) + .execute(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages']; + + expect(stages[1].name).to.equal('insert'); + expect(stages[1].options).to.deep.equal({ + collection: {referenceValue: '/users'}, + }); + expect(stages[1].args).to.deep.equal([]); + }); + + it('serializes literals stage containing expressions and nested maps', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .literals([ + { + base: 10, + doubled: Pipelines.multiply(constant(10), constant(2)), + nested: { + val: constant('hello'), + }, + }, + ]) + .execute(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + 'pipeline' + ]['stages']; + + expect(stages[0].name).to.equal('literals'); + expect(stages[0].args).to.deep.equal([ + { + mapValue: { + fields: { + base: {integerValue: 10}, + doubled: { + functionValue: { + name: 'multiply', + args: [{integerValue: 10}, {integerValue: 2}], + }, + }, + nested: { + mapValue: { + fields: { + val: {stringValue: 'hello'}, + }, + }, + }, + }, + }, + }, + ]); + }); + describe('update stage overloads', () => { it('serializes 0-arg update()', async () => { const spy = sinon.fake.returns(stream()); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index aa2fb5ccfc08..46388c383e52 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12881,21 +12881,18 @@ declare namespace FirebaseFirestore { */ select(options: SelectStageOptions): Pipeline; /** - * @beta * Performs a delete operation on documents from previous stages. * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ delete(): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param transformedField - The first field transformation to apply. @@ -12907,7 +12904,6 @@ declare namespace FirebaseFirestore { ...additionalFields: AliasedExpression[] ): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param transformedFields - The list of transformations to apply. @@ -12915,7 +12911,6 @@ declare namespace FirebaseFirestore { */ update(transformedFields: AliasedExpression[]): Pipeline; /** - * @beta * Performs an update operation using documents from previous stages. * * @param fieldsMap - Map of field transformations to apply. @@ -12925,7 +12920,6 @@ declare namespace FirebaseFirestore { fieldsMap: Record | Map, ): Pipeline; /** - * @beta * Performs an insert operation on documents from previous stages. * * @param options - Options defining how this Insert stage is evaluated. @@ -12933,14 +12927,12 @@ declare namespace FirebaseFirestore { */ insert(options?: InsertStageOptions): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ upsert(): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param options - Options defining how this Upsert stage is evaluated. @@ -12948,7 +12940,6 @@ declare namespace FirebaseFirestore { */ upsert(options: UpsertStageOptions): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param additionalField - The first additional field to apply on upsert. @@ -12960,7 +12951,6 @@ declare namespace FirebaseFirestore { ...additionalFields: AliasedExpression[] ): Pipeline; /** - * @beta * Performs an upsert operation on documents from previous stages. * * @param additionalFields - Additional fields to apply on upsert. From 84ee1de1a03b9006303deddbe5c7492c8f0dc3de Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:33:26 +0000 Subject: [PATCH 07/17] refactor(firestore): align DML and literals stage overloads with pipeline API conventions Follow the existing Pipeline stage convention of `stage(requiredArg)` / variadic convenience overloads plus a single `stage(options)` overload, where the options object carries the required params. - literals (PipelineSource and Pipeline): remove the `(documents, options?)` overload; `documents` is now required in LiteralsStageOptions. - update: remove the `Record | Map` overload (not in the API proposal, no precedent, and indistinguishable at runtime from an options object); add `update(options: UpdateStageOptions)` with `transformedFields`. - delete: add `delete(options: DeleteStageOptions)` so rawOptions can be provided. - upsert: remove the `(additionalFields[], options?)` overload; use `upsert({additionalFields, ...})` instead. --- .../firestore/dev/src/pipelines/pipelines.ts | 199 ++++++++---------- .../firestore/dev/src/pipelines/stage.ts | 89 ++++---- .../firestore/dev/system-test/pipeline.ts | 12 +- .../firestore/dev/test/pipelines/pipeline.ts | 40 +++- .../firestore/dev/test/pipelines/stage.ts | 60 ++++++ handwritten/firestore/types/firestore.d.ts | 70 +++--- 6 files changed, 270 insertions(+), 200 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 9eaeb384970e..75dcb470f783 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, @@ -125,6 +126,21 @@ import { ReflectionObject, } from 'protobufjs'; +/** + * Normalizes the arguments of the `literals(documents)` and + * `literals(options)` overloads into a single options object. + * @private + * @internal + */ +function toLiteralsStageOptions( + documentsOrOptions: + Array> | firestore.Pipelines.LiteralsStageOptions, +): InternalLiteralsStageOptions { + return Array.isArray(documentsOrOptions) + ? {documents: documentsOrOptions} + : documentsOrOptions; +} + /** * Represents the source of a Firestore `Pipeline`. */ @@ -135,37 +151,21 @@ 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)]); + return new Pipeline(this.db, [ + new LiteralsSource(toLiteralsStageOptions(documentsOrOptions)), + ]); } /** @@ -1788,8 +1788,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,39 +1827,40 @@ 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)); } /** @@ -1872,13 +1881,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 +1895,46 @@ 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[] + additionalFieldOrOptions?: + AliasedExpression | firestore.Pipelines.UpsertStageOptions, + ...additionalFields: AliasedExpression[] ): 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; + if (isAliasedExpr(additionalFieldOrOptions)) { + return this._addStage( + new UpsertStage({ + additionalFields: [additionalFieldOrOptions, ...additionalFields], + }), + ); } - - return this._addStage( - new UpsertStage({ - ...options, - ...(additionalFields ? {additionalFields} : {}), - }), - ); + return this._addStage(new UpsertStage(additionalFieldOrOptions ?? {})); } /** * Appends a literals stage to the pipeline. * * @param documents An array of objects/records specifying in-memory documents. + * @return A new {@code Pipeline} object with this stage appended to the stage list. + */ + literals(documents: Array>): Pipeline; + /** + * Appends a literals stage to the pipeline. + * * @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: + 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 this._addStage(new LiteralsSource(documents, opts)); + return this._addStage( + new LiteralsSource(toLiteralsStageOptions(documentsOrOptions)), + ); } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 4404c5d55f6e..3c3cb369c502 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -907,6 +907,11 @@ export class RawStage implements Stage { } } +/** + * Internal options for Delete stage. + */ +export type InternalDeleteStageOptions = firestore.Pipelines.DeleteStageOptions; + /** * Delete stage. */ @@ -914,7 +919,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 +927,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, + ); } } } @@ -1061,29 +1074,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[]; + []) as AliasedExpression[]; this.additionalFields = selectablesToMap(resolvedFields); if (opts.collection) { this.collectionPath = @@ -1147,13 +1143,10 @@ 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 => + const args: api.IValue[] = this.options.documents.map(doc => serializer.encodeValue(doc)!, ); return { @@ -1168,7 +1161,7 @@ export class LiteralsSource implements Stage { } _validateUserData(ignoreUndefinedProperties: boolean): void { - this.documents.forEach(doc => { + this.options.documents.forEach(doc => { Object.values(doc).forEach(val => { if (hasUserData(val)) { val._validateUserData(ignoreUndefinedProperties); diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 13ebb045a01c..dcc3324dd21b 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}); diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 51269f4e6bf9..3967083f0d7a 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -556,6 +556,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 +707,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 +716,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,7 +737,7 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[1].options).to.be.undefined; }); - it('serializes array of expressions with options upsert([expr], options)', async () => { + it('serializes additionalFields with other options upsert({additionalFields, collection})', async () => { const spy = sinon.fake.returns(stream()); const firestore = await createInstance({ executePipeline: spy, @@ -717,7 +746,10 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { await firestore .pipeline() .collection('foo') - .upsert([constant(true).as('active')], {collection: 'users'}) + .upsert({ + additionalFields: [constant(true).as('active')], + collection: 'users', + }) .execute(); const stages = diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index e1fd454e7402..b138f89e8991 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -249,6 +249,66 @@ describe('stage option serialization', () => { }), stageIndex: 1, }, + { + name: 'literals source stage', + pipeline: firestore => + firestore.pipeline().literals({ + documents: [{foo: 'bar'}], + rawOptions, + }), + }, + { + name: 'literals stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .literals({ + documents: [{foo: 'bar'}], + rawOptions, + }), + stageIndex: 1, + }, + { + 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 => diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 46388c383e52..710990718372 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,12 +12924,10 @@ 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. * @@ -12932,13 +12941,6 @@ declare namespace FirebaseFirestore { * @return A new {@link 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 {@link Pipeline} object with this stage appended to the stage list. - */ - upsert(options: UpsertStageOptions): Pipeline; /** * Performs an upsert operation on documents from previous stages. * @@ -12953,25 +12955,23 @@ 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; + upsert(options: UpsertStageOptions): Pipeline; /** * Appends a literals stage to the pipeline. * * @param documents An array of objects/records specifying in-memory documents. + * @return A new {@link Pipeline} object with this stage appended to the stage list. + */ + literals(documents: Array>): Pipeline; + /** + * Appends a literals stage to the pipeline. + * * @param options Options defining how this LiteralsSource stage is evaluated. - * @return A new {@code Pipeline} object with this stage appended to the stage list. + * @return A new {@link Pipeline} object with this stage appended to the stage list. */ - literals( - documents: Array>, - options?: LiteralsStageOptions, - ): Pipeline; literals(options: LiteralsStageOptions): Pipeline; /** * Filters the documents from previous stages to only include those matching the specified {@link @@ -14154,6 +14154,21 @@ 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. */ @@ -14179,7 +14194,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>; }; /** From 451a27e134dd237d0981a8aaba6446ea2f921fb9 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:44:04 +0000 Subject: [PATCH 08/17] refactor(firestore): split insert into insert() and insert(options) overloads Follow the precedent set by database() for stages with no required params, consistent with delete(). --- handwritten/firestore/dev/src/pipelines/pipelines.ts | 7 +++++++ handwritten/firestore/types/firestore.d.ts | 8 +++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 75dcb470f783..be38c3c8f650 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -1863,12 +1863,19 @@ export class Pipeline implements firestore.Pipelines.Pipeline { 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), diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 710990718372..f89f52be5c91 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12928,13 +12928,19 @@ declare namespace FirebaseFirestore { * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(options: UpdateStageOptions): Pipeline; + /** + * Performs an insert operation on documents from previous stages. + * + * @return A new {@link 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 {@link Pipeline} object with this stage appended to the stage list. */ - insert(options?: InsertStageOptions): Pipeline; + insert(options: InsertStageOptions): Pipeline; /** * Performs an upsert operation on documents from previous stages. * From 4d4da500f4911b11303d1c645d616e67775799dc Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:55:17 +0000 Subject: [PATCH 09/17] refactor(firestore): remove deprecated transforms option from UpsertStageOptions transforms was an earlier name for additionalFields that was never released in the Node or Web SDKs, so the deprecated alias is unnecessary API surface. --- .../firestore/dev/src/pipelines/stage.ts | 10 +++--- .../firestore/dev/test/pipelines/pipeline.ts | 35 ------------------- handwritten/firestore/types/firestore.d.ts | 4 --- 3 files changed, 4 insertions(+), 45 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index 3c3cb369c502..ea8c4d5bd017 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -1055,11 +1055,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; }; @@ -1077,10 +1076,9 @@ export class UpsertStage implements Stage { constructor(private readonly options: InternalUpsertStageOptions = {}) { const opts = options; - const resolvedFields = (opts.additionalFields ?? - opts.transforms ?? - []) as AliasedExpression[]; - this.additionalFields = selectablesToMap(resolvedFields); + this.additionalFields = selectablesToMap( + (opts.additionalFields ?? []) as AliasedExpression[], + ); if (opts.collection) { this.collectionPath = typeof opts.collection === 'string' diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 3967083f0d7a..d367f4060127 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -771,40 +771,5 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { collection: {referenceValue: '/users'}, }); }); - - it('supports deprecated transforms option for backward compatibility', async () => { - const spy = sinon.fake.returns(stream()); - const firestore = await createInstance({ - executePipeline: spy, - }); - - await firestore - .pipeline() - .collection('foo') - .upsert({ - collection: 'users', - transforms: [constant(true).as('active')], - }) - .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'}, - }); - }); }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index f89f52be5c91..0c3d57b25b96 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -14188,10 +14188,6 @@ declare namespace FirebaseFirestore { */ export type UpsertStageOptions = StageOptions & { additionalFields?: AliasedExpression[]; - /** - * @deprecated Use `additionalFields` instead. - */ - transforms?: AliasedExpression[]; collection?: string | CollectionReference; documentIdExpression?: string | Expression; }; From 55f8805ba998e5824f888c853aac81c9395ea9a5 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:00:00 +0000 Subject: [PATCH 10/17] refactor(firestore): remove literals from Pipeline, keep it on PipelineSource only literals is a source stage (LiteralsSource). It was only ever used and tested as the first stage via PipelineSource, and the Web SDK exposes it only on PipelineSource. --- .../firestore/dev/src/pipelines/pipelines.ts | 47 +++---------------- .../firestore/dev/test/pipelines/stage.ts | 12 ----- handwritten/firestore/types/firestore.d.ts | 14 ------ 3 files changed, 6 insertions(+), 67 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index be38c3c8f650..6cd1b8821cc3 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -126,21 +126,6 @@ import { ReflectionObject, } from 'protobufjs'; -/** - * Normalizes the arguments of the `literals(documents)` and - * `literals(options)` overloads into a single options object. - * @private - * @internal - */ -function toLiteralsStageOptions( - documentsOrOptions: - Array> | firestore.Pipelines.LiteralsStageOptions, -): InternalLiteralsStageOptions { - return Array.isArray(documentsOrOptions) - ? {documents: documentsOrOptions} - : documentsOrOptions; -} - /** * Represents the source of a Firestore `Pipeline`. */ @@ -163,9 +148,12 @@ export class PipelineSource implements firestore.Pipelines.PipelineSource { documentsOrOptions: Array> | firestore.Pipelines.LiteralsStageOptions, ): Pipeline { - return new Pipeline(this.db, [ - new LiteralsSource(toLiteralsStageOptions(documentsOrOptions)), - ]); + const options: InternalLiteralsStageOptions = Array.isArray( + documentsOrOptions, + ) + ? {documents: documentsOrOptions} + : documentsOrOptions; + return new Pipeline(this.db, [new LiteralsSource(options)]); } /** @@ -1921,29 +1909,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { return this._addStage(new UpsertStage(additionalFieldOrOptions ?? {})); } - /** - * Appends a literals stage to the pipeline. - * - * @param documents An array of objects/records specifying in-memory documents. - * @return A new {@code Pipeline} object with this stage appended to the stage list. - */ - literals(documents: Array>): Pipeline; - /** - * Appends a literals stage to the pipeline. - * - * @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(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; - literals( - documentsOrOptions: - Array> | firestore.Pipelines.LiteralsStageOptions, - ): Pipeline { - return this._addStage( - new LiteralsSource(toLiteralsStageOptions(documentsOrOptions)), - ); - } - /** * Adds a raw stage to the pipeline. * diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index b138f89e8991..3ced33a7ef2f 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -257,18 +257,6 @@ describe('stage option serialization', () => { rawOptions, }), }, - { - name: 'literals stage', - pipeline: firestore => - firestore - .pipeline() - .database() - .literals({ - documents: [{foo: 'bar'}], - rawOptions, - }), - stageIndex: 1, - }, { name: 'delete stage', pipeline: firestore => diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 0c3d57b25b96..a5acec77cd8b 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12965,20 +12965,6 @@ declare namespace FirebaseFirestore { * @return A new {@link Pipeline} object with this stage appended to the stage list. */ upsert(options: UpsertStageOptions): Pipeline; - /** - * Appends a literals stage to the pipeline. - * - * @param documents An array of objects/records specifying in-memory documents. - * @return A new {@link Pipeline} object with this stage appended to the stage list. - */ - literals(documents: Array>): Pipeline; - /** - * Appends a literals stage to the pipeline. - * - * @param options Options defining how this LiteralsSource stage is evaluated. - * @return A new {@link Pipeline} object with this stage appended to the stage list. - */ - literals(options: LiteralsStageOptions): Pipeline; /** * Filters the documents from previous stages to only include those matching the specified {@link * BooleanExpression}. From 66975bfa27bddfb37a224dca75e853e3edbb45c0 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:28:03 +0000 Subject: [PATCH 11/17] fix(firestore): throw on FieldValue sentinels in literals stage documents FieldValue sentinels (serverTimestamp(), increment(), arrayUnion(), arrayRemove(), delete(), ...) cannot be represented in an encoded pipeline. Previously they were silently dropped from literal documents (and a document containing only sentinels failed with an INTERNAL serialization error). Literal documents are now validated recursively and sentinels throw the same error as the Web SDK: Function literals() called with invalid data. () can only be used with update() and set() (found in field ) Nested values with user data are now also validated, not just top-level fields. --- .../firestore/dev/src/pipelines/stage.ts | 31 +++++++-- .../firestore/dev/test/pipelines/stage.ts | 66 ++++++++++++++++++- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index ea8c4d5bd017..d7218ca7be09 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, @@ -1159,12 +1161,27 @@ export class LiteralsSource implements Stage { } _validateUserData(ignoreUndefinedProperties: boolean): void { - this.options.documents.forEach(doc => { - Object.values(doc).forEach(val => { - if (hasUserData(val)) { - val._validateUserData(ignoreUndefinedProperties); - } - }); - }); + const validate = (value: unknown, path: string[]): void => { + 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. + const location = + path.length > 0 ? ` (found in field ${path.join('.')})` : ''; + throw new Error( + `Function literals() called with invalid data. ${value.methodName}() ` + + `can only be used with update() and set()${location}`, + ); + } 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/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index 3ced33a7ef2f..3417e0e54ba2 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; @@ -444,4 +444,68 @@ 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]}, c: constant(3)}]) + .execute(); + expect(spy.callCount).to.equal(1); + }); + }); }); From 276e15f4c39125d24622931d81bbcf0068a04a3d Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:45:27 +0000 Subject: [PATCH 12/17] fix(firestore): allow null values in literals stage validation hasUserData() dereferences its argument, so a null value in a literal document crashed _validateUserData with a TypeError. --- handwritten/firestore/dev/src/pipelines/stage.ts | 2 ++ handwritten/firestore/dev/test/pipelines/stage.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index d7218ca7be09..b95fc47d27bc 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -1172,6 +1172,8 @@ export class LiteralsSource implements Stage { `Function literals() called with invalid data. ${value.methodName}() ` + `can only be used with update() and set()${location}`, ); + } else if (value === null) { + // null is a valid literal value. } else if (hasUserData(value)) { value._validateUserData(ignoreUndefinedProperties); } else if (Array.isArray(value)) { diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index 3417e0e54ba2..aed3614556d9 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -503,7 +503,9 @@ describe('stage _validateUserData', () => { const firestore = await createInstance({executePipeline: spy}); await firestore .pipeline() - .literals([{a: 1, nested: {b: [1, 2]}, c: constant(3)}]) + .literals([ + {a: 1, nested: {b: [1, 2, null], n: null}, c: constant(3), d: null}, + ]) .execute(); expect(spy.callCount).to.equal(1); }); From 91f708cde4e551996856e3d276b7425f7826200f Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:45:32 +0000 Subject: [PATCH 13/17] fix(firestore): auto-wrap nested objects and arrays in literals documents The backend only evaluates expressions in the top-level fields of a literal document, so nested expressions (e.g. {a: {b: add(...)}}) failed with "Value type is not supported: FUNCTION_VALUE". Encode each document as a top-level mapValue whose fields are converted with valueToDefaultExpr, so nested objects and arrays become map() and array() expressions, matching rawStage() and expression arguments. Undefined values are now rejected unless ignoreUndefinedProperties is enabled, preserving the previous serializer behavior. --- .../firestore/dev/src/pipelines/stage.ts | 38 ++++++++-- .../firestore/dev/system-test/pipeline.ts | 38 ++++++++++ .../firestore/dev/test/pipelines/pipeline.ts | 70 +++++++++++++++++-- .../firestore/dev/test/pipelines/stage.ts | 36 ++++++++++ 4 files changed, 172 insertions(+), 10 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index b95fc47d27bc..ebc0eaf741f6 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -28,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'; /** @@ -1146,9 +1151,20 @@ export class LiteralsSource implements Stage { constructor(private options: InternalLiteralsStageOptions) {} _toProto(serializer: Serializer): api.Pipeline.IStage { - const args: api.IValue[] = this.options.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, @@ -1162,16 +1178,26 @@ export class LiteralsSource implements Stage { _validateUserData(ignoreUndefinedProperties: boolean): void { 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. - const location = - path.length > 0 ? ` (found in field ${path.join('.')})` : ''; 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)) { diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index dcc3324dd21b..6120c48bc2a0 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -828,6 +828,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 d367f4060127..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}], }, }, }, diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index aed3614556d9..6cb7ccb7e5f8 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -510,4 +510,40 @@ describe('stage _validateUserData', () => { 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); + }); + }); + }); }); From 83ed77d3fe541afa3ac2b836fc653f07fef6effb Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 7 Oct 2026 23:21:09 +0000 Subject: [PATCH 14/17] docs(firestore): document insert/upsert target documents and the atomic option Describe how collection and documentIdExpression determine the target document, that a string documentIdExpression is a field name (use constant() for a fixed ID), that upsert requires collection when documentIdExpression is set, and what atomic does. Behavior verified against nightly; adds system tests for the field-name semantics and the upsert collection requirement. --- .../firestore/dev/system-test/pipeline.ts | 39 +++++++++++ handwritten/firestore/types/firestore.d.ts | 68 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 6120c48bc2a0..13c79b91e9af 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -799,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() diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index a5acec77cd8b..394f4b2bd686 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -13666,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; /** @@ -14163,18 +14173,74 @@ declare namespace FirebaseFirestore { /** * 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[]; + /** + * 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. + */ 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; }; From 3d981aca76466518a30abef156a52ffff44acef2 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 14 Oct 2026 23:24:50 +0000 Subject: [PATCH 15/17] docs(firestore): clarify DML target documents for inputs without an ID Backport documentation refinements from the Web SDK (firebase-js-sdk#10200): - Upsert with only `collection` set auto-generates an ID when the input document has no ID (e.g. from literals()). - Insert/upsert with neither `collection` nor `documentIdExpression` set fail for input documents that have no ID. - update/insert/upsert method docs now point to the stage options types for how the target document is determined. --- .../firestore/dev/src/pipelines/pipelines.ts | 27 ++++++++++++++ handwritten/firestore/types/firestore.d.ts | 35 +++++++++++++++++-- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/pipelines.ts b/handwritten/firestore/dev/src/pipelines/pipelines.ts index 6cd1b8821cc3..0779a1576014 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -1791,12 +1791,18 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See `UpdateStageOptions` for details. + * * @return A new {@code Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See `UpdateStageOptions` for details. + * * @param transformedField - The first field transformation to apply. * @param additionalFields - Additional field transformations to apply. * @return A new {@code Pipeline} object with this stage appended to the stage list. @@ -1808,6 +1814,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See `UpdateStageOptions` for details. + * * @param transformedFields - The list of transformations to apply. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ @@ -1815,6 +1824,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See `UpdateStageOptions` for details. + * * @param options - Options defining how this Update stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ @@ -1854,12 +1866,18 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an insert operation on documents from previous stages. * + * The operation fails if a target document already exists. See + * `InsertStageOptions` for how the target document is determined. + * * @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. * + * The operation fails if a target document already exists. See + * `InsertStageOptions` for how the target document is determined. + * * @param options - Options defining how this Insert stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ @@ -1873,12 +1891,18 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an upsert operation on documents from previous stages. * + * See `UpsertStageOptions` for how the target document is + * determined. + * * @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. * + * See `UpsertStageOptions` for how the target document is + * determined. + * * @param additionalField - The first additional field to apply on upsert. * @param additionalFields - Additional fields to apply on upsert. * @return A new {@code Pipeline} object with this stage appended to the stage list. @@ -1890,6 +1914,9 @@ export class Pipeline implements firestore.Pipelines.Pipeline { /** * Performs an upsert operation on documents from previous stages. * + * See `UpsertStageOptions` for how the target document is + * determined. + * * @param options - Options defining how this Upsert stage is evaluated. * @return A new {@code Pipeline} object with this stage appended to the stage list. */ diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 394f4b2bd686..289095bfa904 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12900,12 +12900,18 @@ declare namespace FirebaseFirestore { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See {@link UpdateStageOptions} for details. + * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(): Pipeline; /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See {@link UpdateStageOptions} for details. + * * @param transformedField - The first field transformation to apply. * @param additionalFields - Additional field transformations to apply. * @return A new {@link Pipeline} object with this stage appended to the stage list. @@ -12917,6 +12923,9 @@ declare namespace FirebaseFirestore { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See {@link UpdateStageOptions} for details. + * * @param transformedFields - The list of transformations to apply. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ @@ -12924,6 +12933,9 @@ declare namespace FirebaseFirestore { /** * Performs an update operation using documents from previous stages. * + * Each input document is updated at the path in its `__name__` field. + * See {@link UpdateStageOptions} for details. + * * @param options - Options defining how this Update stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ @@ -12931,12 +12943,18 @@ declare namespace FirebaseFirestore { /** * Performs an insert operation on documents from previous stages. * + * The operation fails if a target document already exists. See + * {@link InsertStageOptions} for how the target document is determined. + * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ insert(): Pipeline; /** * Performs an insert operation on documents from previous stages. * + * The operation fails if a target document already exists. See + * {@link InsertStageOptions} for how the target document is determined. + * * @param options - Options defining how this Insert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ @@ -12944,12 +12962,18 @@ declare namespace FirebaseFirestore { /** * Performs an upsert operation on documents from previous stages. * + * See {@link UpsertStageOptions} for how the target document is + * determined. + * * @return A new {@link Pipeline} object with this stage appended to the stage list. */ upsert(): Pipeline; /** * Performs an upsert operation on documents from previous stages. * + * See {@link UpsertStageOptions} for how the target document is + * determined. + * * @param additionalField - The first additional field to apply on upsert. * @param additionalFields - Additional fields to apply on upsert. * @return A new {@link Pipeline} object with this stage appended to the stage list. @@ -12961,6 +12985,9 @@ declare namespace FirebaseFirestore { /** * Performs an upsert operation on documents from previous stages. * + * See {@link UpsertStageOptions} for how the target document is + * determined. + * * @param options - Options defining how this Upsert stage is evaluated. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ @@ -14187,7 +14214,8 @@ declare namespace FirebaseFirestore { * * 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. + * already exist. It also fails for input documents that have no ID (e.g. + * documents from `literals()`, or after `removeFields('__name__')`). */ export type InsertStageOptions = StageOptions & { /** @@ -14216,8 +14244,11 @@ declare namespace FirebaseFirestore { * and `documentIdExpression`: * * - Neither set: each document is written back to its input document's path. + * This fails for input documents that have no ID (e.g. documents from + * `literals()`, or after `removeFields('__name__')`). * - Only `collection` set: the input document's ID is reused under - * `collection`. + * `collection`. If the input document has no ID, an ID is generated + * automatically. * - Both set: the evaluated ID is used under `collection`. */ export type UpsertStageOptions = StageOptions & { From 8fe0f5c0a02db50e7c195c52d18a5a42efa55802 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:43:28 +0000 Subject: [PATCH 16/17] fix(firestore): reject empty collection path in insert and upsert stages Port of firebase-js-sdk a6948d73f. Passing `collection: ''` to insert() or upsert() now throws "Function insert()/upsert() cannot be called with an empty collection path." instead of silently falling back to the input document's parent collection. --- .../firestore/dev/src/pipelines/stage.ts | 47 ++++++++++++------- .../firestore/dev/test/pipelines/pipeline.ts | 15 ++++++ 2 files changed, 46 insertions(+), 16 deletions(-) diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index ebc0eaf741f6..521bc97205e4 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -990,6 +990,27 @@ export class UpdateStage implements Stage { } } +/** + * Converts the `collection` option of an insert or upsert stage to a + * collection path with a leading slash. Throws if the path is empty. + */ +function normalizeDmlCollectionPath( + methodName: string, + collection: string | CollectionReference | firestore.CollectionReference, +): string { + const path = + typeof collection === 'string' + ? collection + : (collection as CollectionReference).path; + if (!path) { + // The error matches the Web SDK. + throw new Error( + `Function ${methodName}() cannot be called with an empty collection path.`, + ); + } + return path.startsWith('/') ? path : '/' + path; +} + /** * Internal options for Insert stage. */ @@ -1011,14 +1032,11 @@ export class InsertStage implements Stage { private readonly documentIdExpr?: Expression; constructor(private options: InternalInsertStageOptions = {}) { - if (options.collection) { - this.collectionPath = - typeof options.collection === 'string' - ? options.collection - : (options.collection as CollectionReference).path; - if (!this.collectionPath.startsWith('/')) { - this.collectionPath = '/' + this.collectionPath; - } + if (options.collection !== undefined) { + this.collectionPath = normalizeDmlCollectionPath( + 'insert', + options.collection, + ); } if (options.documentIdExpression) { this.documentIdExpr = @@ -1086,14 +1104,11 @@ export class UpsertStage implements Stage { this.additionalFields = selectablesToMap( (opts.additionalFields ?? []) as AliasedExpression[], ); - if (opts.collection) { - this.collectionPath = - typeof opts.collection === 'string' - ? opts.collection - : (opts.collection as CollectionReference).path; - if (!this.collectionPath.startsWith('/')) { - this.collectionPath = '/' + this.collectionPath; - } + if (opts.collection !== undefined) { + this.collectionPath = normalizeDmlCollectionPath( + 'upsert', + opts.collection, + ); } if (opts.documentIdExpression) { this.documentIdExpr = diff --git a/handwritten/firestore/dev/test/pipelines/pipeline.ts b/handwritten/firestore/dev/test/pipelines/pipeline.ts index 2864cc19385c..2392a1fdd5ec 100644 --- a/handwritten/firestore/dev/test/pipelines/pipeline.ts +++ b/handwritten/firestore/dev/test/pipelines/pipeline.ts @@ -396,6 +396,21 @@ describe('execute(Pipeline|PipelineExecuteOptions)', () => { expect(stages[1].args).to.deep.equal([]); }); + it('throws on empty collection path in insert and upsert stages', async () => { + const firestore = await createInstance(); + + expect(() => + firestore.pipeline().collection('foo').insert({collection: ''}), + ).to.throw( + 'Function insert() cannot be called with an empty collection path.', + ); + expect(() => + firestore.pipeline().collection('foo').upsert({collection: ''}), + ).to.throw( + 'Function upsert() cannot be called with an empty collection path.', + ); + }); + it('serializes literals stage containing expressions and nested maps', async () => { const spy = sinon.fake.returns(stream()); const firestore = await createInstance({ From b0f04797651aef7ec803f91234a410a0535bac12 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:43:29 +0000 Subject: [PATCH 17/17] docs(firestore): document update stage behavior for missing documents Verified against the nightly backend: a non-atomic update skips input documents whose target does not exist (not counted in documents_modified), while an atomic update fails with NOT_FOUND and updates nothing. Adds system tests for both cases. --- .../firestore/dev/system-test/pipeline.ts | 34 +++++++++++++++++++ handwritten/firestore/types/firestore.d.ts | 7 ++++ 2 files changed, 41 insertions(+) diff --git a/handwritten/firestore/dev/system-test/pipeline.ts b/handwritten/firestore/dev/system-test/pipeline.ts index 13c79b91e9af..d3ae823f9296 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -466,6 +466,40 @@ describe.skipClassic('Pipeline class', () => { expectResults(res, {documents_modified: 0}); }); + it('update skips input documents whose target does not exist', async () => { + const res = await firestore + .pipeline() + .literals([ + {__name__: dmlCol.doc('book1'), status: 'Updated'}, + {__name__: dmlCol.doc('missing_update_target'), status: 'Updated'}, + ]) + .update() + .execute(); + + expectResults(res, {documents_modified: 1}); + const existing = await dmlCol.doc('book1').get(); + expect(existing.get('status')).to.equal('Updated'); + const missing = await dmlCol.doc('missing_update_target').get(); + expect(missing.exists).to.be.false; + }); + + it('atomic update fails when a target document does not exist', async () => { + const promise = firestore + .pipeline() + .literals([ + {__name__: dmlCol.doc('book1'), status: 'Updated'}, + {__name__: dmlCol.doc('missing_update_target'), status: 'Updated'}, + ]) + .update() + .execute({atomic: true}); + + await expect(promise).to.be.rejectedWith(/No document to update/); + const existing = await dmlCol.doc('book1').get(); + expect(existing.get('status')).to.be.undefined; + const missing = await dmlCol.doc('missing_update_target').get(); + expect(missing.exists).to.be.false; + }); + it('can execute insert stage', async () => { const newDocId = 'newBook_insert_1'; const res = await firestore diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 289095bfa904..58affcda8e3f 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -14190,6 +14190,13 @@ declare namespace FirebaseFirestore { /** * Options defining how an Update stage is evaluated. + * + * Each input document is written to the existing document at the path in + * its `__name__` field. The update stage never creates documents. If the + * document at that path does not exist, the input document is skipped and + * is not counted in `documents_modified`. When the pipeline is executed + * with `atomic: true`, the pipeline instead fails with a `NOT_FOUND` error + * and no documents are updated. */ export type UpdateStageOptions = StageOptions & { /**