Skip to content

Commit 09cd24f

Browse files
authored
Merge pull request #7537 from googleapis/dl/dml
feat: [firestore] Add support for DML
2 parents e8755b5 + e4f8ebc commit 09cd24f

4 files changed

Lines changed: 267 additions & 9 deletions

File tree

handwritten/firestore/dev/src/pipelines/pipelines.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import {isOptionalEqual, isPlainObject} from '../util';
5252
import {
5353
AggregateFunction,
5454
AliasedAggregate,
55+
AliasedExpression,
5556
Expression,
5657
Field,
5758
BooleanExpression,
@@ -81,6 +82,7 @@ import {
8182
Sample,
8283
Union,
8384
Unnest,
85+
DeleteStage,
8486
InternalWhereStageOptions,
8587
InternalOffsetStageOptions,
8688
InternalLimitStageOptions,
@@ -95,6 +97,7 @@ import {
9597
InternalDocumentsStageOptions,
9698
InternalCollectionGroupStageOptions,
9799
InternalCollectionStageOptions,
100+
UpdateStage,
98101
} from './stage';
99102
import {StructuredPipeline} from './structured-pipeline';
100103
import Selectable = FirebaseFirestore.Pipelines.Selectable;
@@ -1486,6 +1489,42 @@ export class Pipeline implements firestore.Pipelines.Pipeline {
14861489
return this._addStage(new Sort(internalOptions));
14871490
}
14881491

1492+
/**
1493+
* @beta
1494+
* Performs a delete operation on documents from previous stages.
1495+
*
1496+
* @example
1497+
* ```typescript
1498+
* // Deletes all documents in the "books" collection.
1499+
* firestore.pipeline().collection("books")
1500+
* .delete();
1501+
* ```
1502+
*
1503+
* @return A new {@code Pipeline} object with this stage appended to the stage list.
1504+
*/
1505+
delete(): Pipeline {
1506+
return this._addStage(new DeleteStage());
1507+
}
1508+
1509+
/**
1510+
* @beta
1511+
* Performs an update operation using documents from previous stages.
1512+
*
1513+
* @return A new {@code Pipeline} object with this stage appended to the stage list.
1514+
*/
1515+
update(): Pipeline;
1516+
/**
1517+
* @beta
1518+
* Performs an update operation using documents from previous stages.
1519+
*
1520+
* @param transformedFields - The list of transformations to apply.
1521+
* @return A new {@code Pipeline} object with this stage appended to the stage list.
1522+
*/
1523+
update(transformedFields: AliasedExpression[]): Pipeline;
1524+
update(transformedFields?: AliasedExpression[]): Pipeline {
1525+
return this._addStage(new UpdateStage(transformedFields));
1526+
}
1527+
14891528
/**
14901529
* @beta
14911530
* Adds a raw stage to the pipeline.

handwritten/firestore/dev/src/pipelines/stage.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {ProtoSerializable, Serializer} from '../serializer';
2121

2222
import {
2323
AggregateFunction,
24+
AliasedExpression,
2425
BooleanExpression,
2526
Expression,
2627
Field,
@@ -29,7 +30,7 @@ import {
2930
} from './expression';
3031
import {OptionsUtil} from './options-util';
3132
import {CollectionReference} from '../reference/collection-reference';
32-
import {validateUserDataHelper} from './pipeline-util';
33+
import {validateUserDataHelper, selectablesToMap} from './pipeline-util';
3334
import {Pipeline} from './pipelines';
3435

3536
/**
@@ -759,3 +760,58 @@ export class RawStage implements Stage {
759760
validateUserDataHelper(this.params, ignoreUndefinedProperties);
760761
}
761762
}
763+
764+
/**
765+
* Delete stage.
766+
*/
767+
export class DeleteStage implements Stage {
768+
name = 'delete';
769+
readonly optionsUtil = new OptionsUtil({});
770+
771+
constructor() {}
772+
773+
_toProto(serializer: Serializer): api.Pipeline.IStage {
774+
const args: api.IValue[] = [];
775+
776+
return {
777+
name: this.name,
778+
args,
779+
options: this.optionsUtil.getOptionsProto(serializer, {}, {}),
780+
};
781+
}
782+
783+
_validateUserData(ignoreUndefinedProperties: boolean): void {}
784+
}
785+
786+
/**
787+
* Update stage.
788+
*/
789+
export class UpdateStage implements Stage {
790+
name = 'update';
791+
readonly optionsUtil = new OptionsUtil({});
792+
793+
constructor(private transformedFields?: AliasedExpression[]) {}
794+
795+
_toProto(serializer: Serializer): api.Pipeline.IStage {
796+
const args: api.IValue[] = [];
797+
798+
if (this.transformedFields && this.transformedFields.length > 0) {
799+
const mapped = selectablesToMap(this.transformedFields);
800+
args.push(serializer.encodeValue(mapped)!);
801+
} else {
802+
args.push(serializer.encodeValue(new Map())!);
803+
}
804+
805+
return {
806+
name: this.name,
807+
args,
808+
options: this.optionsUtil.getOptionsProto(serializer, {}, {}),
809+
};
810+
}
811+
812+
_validateUserData(ignoreUndefinedProperties: boolean): void {
813+
if (this.transformedFields) {
814+
validateUserDataHelper(this.transformedFields, ignoreUndefinedProperties);
815+
}
816+
}
817+
}

handwritten/firestore/dev/system-test/pipeline.ts

Lines changed: 148 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,19 @@ describe.skipClassic('Pipeline class', () => {
189189
let beginDocCreation = 0;
190190
let endDocCreation = 0;
191191

192-
async function testCollectionWithDocs(docs: {
193-
[id: string]: DocumentData;
194-
}): Promise<CollectionReference<DocumentData>> {
192+
async function testCollectionWithDocs(
193+
targetCol: CollectionReference,
194+
docs: {
195+
[id: string]: DocumentData;
196+
},
197+
): Promise<CollectionReference<DocumentData>> {
195198
beginDocCreation = new Date().valueOf();
196199
for (const id in docs) {
197-
const ref = randomCol.doc(id);
200+
const ref = targetCol.doc(id);
198201
await ref.set(docs[id]);
199202
}
200203
endDocCreation = new Date().valueOf();
201-
return randomCol;
204+
return targetCol;
202205
}
203206

204207
function expectResults(result: PipelineSnapshot, ...docs: string[]): void;
@@ -224,7 +227,9 @@ describe.skipClassic('Pipeline class', () => {
224227
}
225228
}
226229

227-
async function setupBookDocs(): Promise<CollectionReference<DocumentData>> {
230+
async function setupBookDocs(
231+
targetCol: CollectionReference,
232+
): Promise<CollectionReference<DocumentData>> {
228233
const bookDocs: {[id: string]: DocumentData} = {
229234
book1: {
230235
title: "The Hitchhiker's Guide to the Galaxy",
@@ -334,18 +339,153 @@ describe.skipClassic('Pipeline class', () => {
334339
embedding: FieldValue.vector([1, 1, 1, 1, 1, 1, 1, 1, 1, 10]),
335340
},
336341
};
337-
return testCollectionWithDocs(bookDocs);
342+
return testCollectionWithDocs(targetCol, bookDocs);
338343
}
339344

340345
before(async () => {
341346
randomCol = getTestRoot();
342-
await setupBookDocs();
347+
await setupBookDocs(randomCol);
343348
firestore = randomCol.firestore;
344349
});
345350

346351
afterEach(() => verifyInstance(firestore as unknown as InternalFirestore));
347352

348353
describe('pipeline results', () => {
354+
describe('DML stages', () => {
355+
let dmlCol: CollectionReference;
356+
357+
beforeEach(async () => {
358+
dmlCol = getTestRoot();
359+
await setupBookDocs(dmlCol);
360+
});
361+
362+
it('can execute delete stage multiple documents', async () => {
363+
const deletePpl = firestore
364+
.pipeline()
365+
.collection(dmlCol.path)
366+
.where(equal(field('genre'), 'Science Fiction'))
367+
.delete();
368+
369+
const promise = deletePpl.execute();
370+
371+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
372+
const deleteRes = await promise;
373+
expectResults(deleteRes, {documents_modified: 2});
374+
375+
const docSnap1 = await dmlCol.doc('book1').get();
376+
expect(docSnap1.exists).to.be.false;
377+
378+
const docSnap10 = await dmlCol.doc('book10').get();
379+
expect(docSnap10.exists).to.be.false;
380+
} else {
381+
await expect(promise).to.be.rejected;
382+
}
383+
});
384+
385+
it('can execute delete stage within a transaction', async () => {
386+
const promise = firestore.runTransaction(async transaction => {
387+
const deletePpl = firestore
388+
.pipeline()
389+
.collection(dmlCol.path)
390+
.where(equal(field('__name__').documentId(), 'book2'))
391+
.delete();
392+
393+
const deleteRes = await transaction.execute(deletePpl);
394+
expectResults(deleteRes, {documents_modified: 1});
395+
});
396+
397+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
398+
await promise;
399+
const docSnap = await dmlCol.doc('book2').get();
400+
expect(docSnap.exists).to.be.false;
401+
} else {
402+
await expect(promise).to.be.rejected;
403+
}
404+
});
405+
406+
it('can execute update stage with addFields', async () => {
407+
const ppl = firestore
408+
.pipeline()
409+
.collection(dmlCol.path)
410+
.where(equal(field('__name__').documentId(), 'book3'))
411+
.addFields(field('__name__').documentId().as('id'))
412+
.update([constant('baz').as('foo')]);
413+
414+
const promise = ppl.execute();
415+
416+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
417+
const res = await promise;
418+
expectResults(res, {documents_modified: 1});
419+
420+
const docSnap = await dmlCol.doc('book3').get();
421+
expect(docSnap.get('foo')).to.equal('baz');
422+
expect(docSnap.get('id')).to.equal('book3');
423+
} else {
424+
await expect(promise).to.be.rejected;
425+
}
426+
});
427+
428+
it('can update multiple documents and remove fields', async () => {
429+
const promise = firestore
430+
.pipeline()
431+
.collection(dmlCol.path)
432+
.where(equal(field('genre'), 'Science Fiction'))
433+
.removeFields('awards')
434+
.update([constant('Updated').as('status')])
435+
.execute();
436+
437+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
438+
const res = await promise;
439+
expectResults(res, {documents_modified: 2});
440+
441+
const docSnap1 = await dmlCol.doc('book1').get();
442+
expect(docSnap1.get('status')).to.equal('Updated');
443+
expect(docSnap1.get('awards')).to.be.undefined;
444+
445+
const docSnap10 = await dmlCol.doc('book10').get();
446+
expect(docSnap10.get('status')).to.equal('Updated');
447+
expect(docSnap10.get('awards')).to.be.undefined;
448+
} else {
449+
await expect(promise).to.be.rejected;
450+
}
451+
});
452+
453+
it('can update with expressions', async () => {
454+
const promise = firestore
455+
.pipeline()
456+
.collection(dmlCol.path)
457+
.where(equal(field('__name__').documentId(), 'book1'))
458+
.update([add(field('rating'), constant(1.0)).as('rating')])
459+
.execute();
460+
461+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
462+
const res = await promise;
463+
expectResults(res, {documents_modified: 1});
464+
465+
const docSnap = await dmlCol.doc('book1').get();
466+
expect(docSnap.get('rating')).to.equal(5.2);
467+
} else {
468+
await expect(promise).to.be.rejected;
469+
}
470+
});
471+
472+
it('can update non existing document modifies zero documents', async () => {
473+
const nonExistingId = 'nonExistingId_123';
474+
const promise = firestore
475+
.pipeline()
476+
.documents([dmlCol.doc(nonExistingId)])
477+
.update([constant('Updated').as('status')])
478+
.execute();
479+
480+
if (process.env.FIRESTORE_TARGET_BACKEND?.toUpperCase() === 'NIGHTLY') {
481+
const res = await promise;
482+
expectResults(res, {documents_modified: 0});
483+
} else {
484+
await expect(promise).to.be.rejected;
485+
}
486+
});
487+
});
488+
349489
it('empty snapshot as expected', async () => {
350490
const snapshot = await firestore
351491
.pipeline()

handwritten/firestore/types/firestore.d.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12314,6 +12314,28 @@ declare namespace FirebaseFirestore {
1231412314
* @returns A new `Pipeline` object with this select stage appended to its list of stages.
1231512315
*/
1231612316
select(options: SelectStageOptions): Pipeline;
12317+
/**
12318+
* @beta
12319+
* Performs a delete operation on documents from previous stages.
12320+
*
12321+
* @return A new {@link Pipeline} object with this stage appended to the stage list.
12322+
*/
12323+
delete(): Pipeline;
12324+
/**
12325+
* @beta
12326+
* Performs an update operation using documents from previous stages.
12327+
*
12328+
* @return A new {@link Pipeline} object with this stage appended to the stage list.
12329+
*/
12330+
update(): Pipeline;
12331+
/**
12332+
* @beta
12333+
* Performs an update operation using documents from previous stages.
12334+
*
12335+
* @param transformedFields - The list of transformations to apply.
12336+
* @return A new {@link Pipeline} object with this stage appended to the stage list.
12337+
*/
12338+
update(transformedFields: AliasedExpression[]): Pipeline;
1231712339
/**
1231812340
* @beta
1231912341
* Filters the documents from previous stages to only include those matching the specified {@link
@@ -13178,6 +13200,7 @@ declare namespace FirebaseFirestore {
1317813200
*/
1317913201
docs: Array<string | DocumentReference>;
1318013202
};
13203+
1318113204
/**
1318213205
* @beta
1318313206
* Options defining how an AddFieldsStage is evaluated. See {@link Pipeline.addFields}.

0 commit comments

Comments
 (0)