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..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'; @@ -270,6 +270,9 @@ export class ExecutionUtil { 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; @@ -300,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 924d597722f9..0779a1576014 100644 --- a/handwritten/firestore/dev/src/pipelines/pipelines.ts +++ b/handwritten/firestore/dev/src/pipelines/pipelines.ts @@ -107,8 +107,14 @@ import { InternalDefineStageOptions, InternalSubcollectionStageOptions, UpdateStage, + InternalUpdateStageOptions, Search, InternalSearchStageOptions, + InsertStage, + InternalInsertStageOptions, + UpsertStage, + LiteralsSource, + InternalLiteralsStageOptions, } from './stage'; import {StructuredPipeline} from './structured-pipeline'; import Selectable = FirebaseFirestore.Pipelines.Selectable; @@ -126,6 +132,30 @@ 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. + */ + 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(options: firestore.Pipelines.LiteralsStageOptions): Pipeline; + literals( + documentsOrOptions: + Array> | firestore.Pipelines.LiteralsStageOptions, + ): Pipeline { + const options: InternalLiteralsStageOptions = Array.isArray( + documentsOrOptions, + ) + ? {documents: documentsOrOptions} + : documentsOrOptions; + return new Pipeline(this.db, [new LiteralsSource(options)]); + } + /** * 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. @@ -1735,7 +1765,6 @@ export class Pipeline implements firestore.Pipelines.Pipeline { } /** - * @beta * Performs a delete operation on documents from previous stages. * * @example @@ -1747,27 +1776,164 @@ 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 ?? {})); } /** - * @beta * 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; /** - * @beta * 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. + */ + update( + transformedField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): 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. */ update(transformedFields: AliasedExpression[]): Pipeline; - update(transformedFields?: AliasedExpression[]): Pipeline { - return this._addStage(new UpdateStage(transformedFields)); + /** + * 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. + */ + update(options: firestore.Pipelines.UpdateStageOptions): Pipeline; + update( + transformedFieldOrOptions?: + | AliasedExpression + | AliasedExpression[] + | firestore.Pipelines.UpdateStageOptions, + ...additionalFields: AliasedExpression[] + ): Pipeline { + let options: firestore.Pipelines.UpdateStageOptions; + if (isAliasedExpr(transformedFieldOrOptions)) { + options = { + transformedFields: [transformedFieldOrOptions, ...additionalFields], + }; + } else if (Array.isArray(transformedFieldOrOptions)) { + options = {transformedFields: transformedFieldOrOptions}; + } else { + options = transformedFieldOrOptions ?? {}; + } + + const {transformedFields, ...rest} = options; + const internalOptions: InternalUpdateStageOptions = { + ...rest, + ...(transformedFields + ? { + transformedFields: selectablesToMap( + transformedFields as AliasedExpression[], + ), + } + : {}), + }; + return this._addStage(new UpdateStage(internalOptions)); + } + + /** + * Performs an insert operation on documents from previous stages. + * + * 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. + */ + insert(options: firestore.Pipelines.InsertStageOptions): Pipeline; + insert(options?: firestore.Pipelines.InsertStageOptions): Pipeline { + return this._addStage( + new InsertStage((options ?? {}) as InternalInsertStageOptions), + ); + } + + /** + * 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. + */ + upsert( + additionalField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): 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. + */ + upsert(options: firestore.Pipelines.UpsertStageOptions): Pipeline; + upsert( + additionalFieldOrOptions?: + AliasedExpression | firestore.Pipelines.UpsertStageOptions, + ...additionalFields: AliasedExpression[] + ): Pipeline { + if (isAliasedExpr(additionalFieldOrOptions)) { + return this._addStage( + new UpsertStage({ + additionalFields: [additionalFieldOrOptions, ...additionalFields], + }), + ); + } + return this._addStage(new UpsertStage(additionalFieldOrOptions ?? {})); } /** diff --git a/handwritten/firestore/dev/src/pipelines/stage.ts b/handwritten/firestore/dev/src/pipelines/stage.ts index c1153a03ad7e..521bc97205e4 100644 --- a/handwritten/firestore/dev/src/pipelines/stage.ts +++ b/handwritten/firestore/dev/src/pipelines/stage.ts @@ -17,7 +17,9 @@ 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 {FieldTransform} from '../field-value'; +import {isPlainObject} from '../util'; import { AggregateFunction, @@ -26,11 +28,16 @@ import { Expression, Field, field, + MapValue, Ordering, } from './expression'; import {OptionsUtil} from './options-util'; import {CollectionReference} from '../reference/collection-reference'; -import {validateUserDataHelper, selectablesToMap} from './pipeline-util'; +import { + validateUserDataHelper, + selectablesToMap, + valueToDefaultExpr, +} from './pipeline-util'; import {Pipeline} from './pipelines'; /** @@ -907,6 +914,11 @@ export class RawStage implements Stage { } } +/** + * Internal options for Delete stage. + */ +export type InternalDeleteStageOptions = firestore.Pipelines.DeleteStageOptions; + /** * Delete stage. */ @@ -914,7 +926,7 @@ export class DeleteStage implements Stage { name = 'delete'; readonly optionsUtil = new OptionsUtil({}); - constructor() {} + constructor(private options: InternalDeleteStageOptions = {}) {} _toProto(serializer: Serializer): api.Pipeline.IStage { const args: api.IValue[] = []; @@ -922,13 +934,27 @@ 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. */ @@ -936,28 +962,269 @@ export class UpdateStage implements Stage { name = 'update'; readonly optionsUtil = new OptionsUtil({}); - constructor(private transformedFields?: AliasedExpression[]) {} + constructor(private options: InternalUpdateStageOptions = {}) {} _toProto(serializer: Serializer): api.Pipeline.IStage { - const args: api.IValue[] = []; + const args: api.IValue[] = [ + serializer.encodeValue(this.options.transformedFields ?? new Map())!, + ]; + + return { + name: this.name, + args, + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _validateUserData(ignoreUndefinedProperties: boolean): void { + if (this.options.transformedFields) { + validateUserDataHelper( + this.options.transformedFields, + ignoreUndefinedProperties, + ); + } + } +} + +/** + * 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. + */ +export type InternalInsertStageOptions = Omit< + firestore.Pipelines.InsertStageOptions, + 'collection' | 'documentIdExpression' +> & { + collection?: string | CollectionReference | firestore.CollectionReference; + documentIdExpression?: 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 !== undefined) { + this.collectionPath = normalizeDmlCollectionPath( + 'insert', + options.collection, + ); + } + if (options.documentIdExpression) { + this.documentIdExpr = + typeof options.documentIdExpression === 'string' + ? field(options.documentIdExpression) + : (options.documentIdExpression 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, + 'additionalFields' | 'collection' | 'documentIdExpression' +> & { + additionalFields?: + AliasedExpression[] | firestore.Pipelines.AliasedExpression[]; + collection?: string | CollectionReference | firestore.CollectionReference; + documentIdExpression?: 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 additionalFields: Map; + + constructor(private readonly options: InternalUpsertStageOptions = {}) { + const opts = options; + + this.additionalFields = selectablesToMap( + (opts.additionalFields ?? []) as AliasedExpression[], + ); + if (opts.collection !== undefined) { + this.collectionPath = normalizeDmlCollectionPath( + 'upsert', + opts.collection, + ); + } + if (opts.documentIdExpression) { + this.documentIdExpr = + typeof opts.documentIdExpression === 'string' + ? field(opts.documentIdExpression) + : (opts.documentIdExpression as Expression); + } + } + + _toProto(serializer: Serializer): api.Pipeline.IStage { + const options = + this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ) || {}; - if (this.transformedFields && this.transformedFields.length > 0) { - const mapped = selectablesToMap(this.transformedFields); - args.push(serializer.encodeValue(mapped)!); - } else { - args.push(serializer.encodeValue(new Map())!); + 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.additionalFields)!]; + return { name: this.name, args, - options: this.optionsUtil.getOptionsProto(serializer, {}, {}), + options: Object.keys(options).length > 0 ? options : undefined, }; } _validateUserData(ignoreUndefinedProperties: boolean): void { - if (this.transformedFields) { - validateUserDataHelper(this.transformedFields, ignoreUndefinedProperties); + validateUserDataHelper(this.additionalFields, 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(private options: InternalLiteralsStageOptions) {} + + _toProto(serializer: Serializer): api.Pipeline.IStage { + // 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, + options: this.optionsUtil.getOptionsProto( + serializer, + this.options, + this.options.rawOptions, + ), + }; + } + + _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. + throw new Error( + `Function literals() called with invalid data. ${value.methodName}() ` + + `can only be used with update() and set()${location}`, + ); + } else if (value === undefined) { + // Undefined values are omitted from the encoded document, which is + // only allowed when `ignoreUndefinedProperties` is enabled. + if (!ignoreUndefinedProperties) { + throw new Error( + 'Function literals() called with invalid data. Cannot use ' + + `"undefined" as a Firestore value${location}. If you want to ` + + 'ignore undefined values, enable `ignoreUndefinedProperties`.', + ); + } + } else if (value === null) { + // null is a valid literal value. + } else if (hasUserData(value)) { + value._validateUserData(ignoreUndefinedProperties); + } else if (Array.isArray(value)) { + value.forEach(element => validate(element, path)); + } else if (isPlainObject(value)) { + Object.entries(value as Record).forEach( + ([key, fieldValue]) => validate(fieldValue, [...path, key]), + ); + } + }; + this.options.documents.forEach(doc => validate(doc, [])); + } +} diff --git a/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts b/handwritten/firestore/dev/src/pipelines/structured-pipeline.ts index bd74d236997a..ac990ef29c23 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'; @@ -40,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. @@ -72,7 +71,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..d3ae823f9296 100644 --- a/handwritten/firestore/dev/system-test/pipeline.ts +++ b/handwritten/firestore/dev/system-test/pipeline.ts @@ -465,6 +465,480 @@ describe.skipClassic('Pipeline class', () => { const res = await promise; 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 + .pipeline() + .literals([{title: 'New Book', author: 'Author 1'}]) + .insert({ + collection: dmlCol.path, + documentIdExpression: constant(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 additionalFields', async () => { + const upsertDocId = 'upsertBook_1'; + const res = await firestore + .pipeline() + .literals([{title: 'Upserted Book', count: 1}]) + .upsert({ + collection: dmlCol.path, + documentIdExpression: constant(upsertDocId), + additionalFields: [add(field('count'), constant(1)).as('count')], + }) + .execute({atomic: true}); + + 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, + documentIdExpression: constant(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, + documentIdExpression: constant(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('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 additionalFields option', async () => { + const res = await firestore + .pipeline() + .collection(dmlCol.path) + .where(equal(field('__name__').documentId(), 'book1')) + .upsert({ + additionalFields: [ + 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('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() + .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('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 db31d6cd398f..2392a1fdd5ec 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; @@ -242,4 +244,609 @@ 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('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({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .literals([{foo: 'bar'}]) + .insert({collection: 'foo', documentIdExpression: 'doc1'}) + .update() + .upsert({collection: 'bar', additionalFields: []}) + .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'); + }); + + 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('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({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .literals([ + { + base: 10, + 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(); + + const stages = + spy.args[FIRST_CALL][EXECUTE_PIPELINE_REQUEST]['structuredPipeline'][ + '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([ + { + mapValue: { + fields: { + base: {integerValue: 10}, + doubled: { + functionValue: { + name: 'multiply', + args: [{integerValue: 10}, {integerValue: 2}], + }, + }, + nested: { + 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}], + }, + }, + }, + }, + }, + ]); + }); + + 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}, + }, + }, + }, + ]); + }); + + 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', () => { + 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 additionalFields option upsert({additionalFields})', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert({additionalFields: [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 additionalFields with other options upsert({additionalFields, collection})', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({ + executePipeline: spy, + }); + + await firestore + .pipeline() + .collection('foo') + .upsert({ + additionalFields: [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'}, + }); + }); + }); }); diff --git a/handwritten/firestore/dev/test/pipelines/stage.ts b/handwritten/firestore/dev/test/pipelines/stage.ts index e1fd454e7402..6cb7ccb7e5f8 100644 --- a/handwritten/firestore/dev/test/pipelines/stage.ts +++ b/handwritten/firestore/dev/test/pipelines/stage.ts @@ -19,7 +19,7 @@ import {expect} from 'chai'; import * as sinon from 'sinon'; import {createInstance, stream} from '../util/helpers'; import {google} from '../../protos/firestore_v1_proto_api'; -import {Pipelines, Firestore} from '../../src'; +import {Pipelines, Firestore, FieldValue} from '../../src'; import Pipeline = Pipelines.Pipeline; import field = Pipelines.field; import sum = Pipelines.sum; @@ -249,6 +249,54 @@ describe('stage option serialization', () => { }), stageIndex: 1, }, + { + name: 'literals source stage', + pipeline: firestore => + firestore.pipeline().literals({ + documents: [{foo: 'bar'}], + rawOptions, + }), + }, + { + name: 'delete stage', + pipeline: firestore => + firestore.pipeline().database().delete({ + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'update stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .update({ + transformedFields: [constant(true).as('foo')], + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'insert stage', + pipeline: firestore => + firestore.pipeline().database().insert({ + rawOptions, + }), + stageIndex: 1, + }, + { + name: 'upsert stage', + pipeline: firestore => + firestore + .pipeline() + .database() + .upsert({ + additionalFields: [constant(true).as('foo')], + rawOptions, + }), + stageIndex: 1, + }, { name: 'search stage', pipeline: firestore => @@ -396,4 +444,106 @@ describe('stage _validateUserData', () => { }) .execute(); }); + + describe('literals stage rejects FieldValue sentinels', () => { + const cases: Array<{ + name: string; + doc: Record; + expected: string; + }> = [ + { + name: 'top-level serverTimestamp()', + doc: {a: 1, ts: FieldValue.serverTimestamp()}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field ts)', + }, + { + name: 'increment()', + doc: {n: FieldValue.increment(1)}, + expected: + 'Function literals() called with invalid data. FieldValue.increment() can only be used with update() and set() (found in field n)', + }, + { + name: 'arrayUnion()', + doc: {arr: FieldValue.arrayUnion('x')}, + expected: + 'Function literals() called with invalid data. FieldValue.arrayUnion() can only be used with update() and set() (found in field arr)', + }, + { + name: 'delete()', + doc: {gone: FieldValue.delete()}, + expected: + 'Function literals() called with invalid data. FieldValue.delete() can only be used with update() and set() (found in field gone)', + }, + { + name: 'nested in a map', + doc: {a: {b: FieldValue.serverTimestamp()}}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field a.b)', + }, + { + name: 'inside an array', + doc: {arr: [1, FieldValue.serverTimestamp()]}, + expected: + 'Function literals() called with invalid data. FieldValue.serverTimestamp() can only be used with update() and set() (found in field arr)', + }, + ]; + + cases.forEach(({name, doc, expected}) => { + it(name, async () => { + const firestore = await createInstance(); + expect(() => { + void firestore.pipeline().literals([doc]).execute(); + }).to.throw(expected); + }); + }); + + it('accepts literals without sentinels', async () => { + const spy = sinon.fake.returns(stream()); + const firestore = await createInstance({executePipeline: spy}); + await firestore + .pipeline() + .literals([ + {a: 1, nested: {b: [1, 2, null], n: null}, c: constant(3), d: null}, + ]) + .execute(); + expect(spy.callCount).to.equal(1); + }); + }); + + describe('literals stage rejects undefined by default', () => { + const cases: Array<{ + name: string; + doc: Record; + expected: string; + }> = [ + { + name: 'top-level undefined', + doc: {a: 1, b: undefined}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field b).', + }, + { + name: 'nested undefined', + doc: {a: {b: undefined}}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field a.b).', + }, + { + name: 'undefined inside an array', + doc: {arr: [1, undefined]}, + expected: + 'Function literals() called with invalid data. Cannot use "undefined" as a Firestore value (found in field arr).', + }, + ]; + + cases.forEach(({name, doc, expected}) => { + it(name, async () => { + const firestore = await createInstance(); + expect(() => { + void firestore.pipeline().literals([doc]).execute(); + }).to.throw(expected); + }); + }); + }); }); diff --git a/handwritten/firestore/types/firestore.d.ts b/handwritten/firestore/types/firestore.d.ts index 9720e6a2f802..8de26041265e 100644 --- a/handwritten/firestore/types/firestore.d.ts +++ b/handwritten/firestore/types/firestore.d.ts @@ -12449,6 +12449,18 @@ 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. + * + * @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(options: LiteralsStageOptions): Pipeline; } /** @@ -12867,27 +12879,113 @@ 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 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. * + * 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; /** - * @beta * 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. + */ + update( + transformedField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): 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 transformedFields - The list of transformations to apply. * @return A new {@link Pipeline} object with this stage appended to the stage list. */ update(transformedFields: AliasedExpression[]): 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 options - Options defining how this Update stage is evaluated. + * @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. + * + * 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. + */ + insert(options: InsertStageOptions): Pipeline; + /** + * 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. + */ + upsert( + additionalField: AliasedExpression, + ...additionalFields: AliasedExpression[] + ): Pipeline; + /** + * 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. + */ + upsert(options: UpsertStageOptions): Pipeline; /** * Filters the documents from previous stages to only include those matching the specified {@link * BooleanExpression}. @@ -13588,6 +13686,20 @@ declare namespace FirebaseFirestore { * Specifies the index mode for the query. */ indexMode?: 'recommended'; + /** + * 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; /** * @beta Options used to configure explain queries. */ explainOptions?: { @@ -14065,6 +14177,115 @@ 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. + * + * 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 & { + /** + * The field transformations to apply to each document being updated. + */ + transformedFields?: AliasedExpression[]; + }; + + /** + * Options defining how an Insert stage is evaluated. + * + * Each input document is written as a new document; the insert fails if the + * target document already exists. The target is determined by `collection` + * and `documentIdExpression`: + * + * - Only `collection` set: the input document's ID is reused under + * `collection`. If the input document has no ID (e.g. it came from + * `literals()`), an ID is generated automatically. + * - Only `documentIdExpression` set: the evaluated ID is used under the input + * document's parent collection. + * - Both set: the evaluated ID is used under `collection`. + * + * If neither is set, each document is written back to its input document's + * path, which fails for documents read from the database because they + * already exist. It also fails for input documents that have no ID (e.g. + * documents from `literals()`, or after `removeFields('__name__')`). + */ + 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. + * 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`. 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 & { + /** + * 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; + }; + + /** + * Options defining how a LiteralsSource stage is evaluated. + */ + export type LiteralsStageOptions = StageOptions & { + /** + * An array of objects specifying the in-memory documents that will be the source of this pipeline. + */ + 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";