Skip to content

Commit 885e0b3

Browse files
author
Brian Chen
authored
Add verify support (#2436)
1 parent 8504da9 commit 885e0b3

File tree

7 files changed

+134
-121
lines changed

7 files changed

+134
-121
lines changed

packages/firestore/src/core/transaction.ts

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,12 @@ import { documentVersionMap } from '../model/collections';
2020
import { Document, NoDocument, MaybeDocument } from '../model/document';
2121

2222
import { DocumentKey } from '../model/document_key';
23-
import { DeleteMutation, Mutation, Precondition } from '../model/mutation';
23+
import {
24+
DeleteMutation,
25+
Mutation,
26+
Precondition,
27+
VerifyMutation
28+
} from '../model/mutation';
2429
import { Datastore } from '../remote/datastore';
2530
import { fail, assert } from '../util/assert';
2631
import { Code, FirestoreError } from '../util/error';
@@ -46,7 +51,7 @@ export class Transaction {
4651
* Set of documents that have been written in the transaction.
4752
*
4853
* When there's more than one write to the same key in a transaction, any
49-
* writes after hte first are handled differently.
54+
* writes after the first are handled differently.
5055
*/
5156
private writtenDocs: Set<DocumentKey> = new Set();
5257

@@ -102,12 +107,11 @@ export class Transaction {
102107
this.mutations.forEach(mutation => {
103108
unwritten = unwritten.remove(mutation.key);
104109
});
105-
if (!unwritten.isEmpty()) {
106-
throw new FirestoreError(
107-
Code.INVALID_ARGUMENT,
108-
'Every document read in a transaction must also be written.'
109-
);
110-
}
110+
// For each document that was read but not written to, we want to perform
111+
// a `verify` operation.
112+
unwritten.forEach((key, _version) => {
113+
this.mutations.push(new VerifyMutation(key, this.precondition(key)));
114+
});
111115
await this.datastore.commit(this.mutations);
112116
this.committed = true;
113117
}

packages/firestore/src/model/mutation.ts

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import { Timestamp } from '../api/timestamp';
1919
import { SnapshotVersion } from '../core/snapshot_version';
20-
import { assert } from '../util/assert';
20+
import { assert, fail } from '../util/assert';
2121
import * as misc from '../util/misc';
2222
import { SortedSet } from '../util/sorted_set';
2323

@@ -121,7 +121,8 @@ export enum MutationType {
121121
Set,
122122
Patch,
123123
Transform,
124-
Delete
124+
Delete,
125+
Verify
125126
}
126127

127128
/**
@@ -187,7 +188,7 @@ export class Precondition {
187188
* A mutation describes a self-contained change to a document. Mutations can
188189
* create, replace, delete, and update subsets of documents.
189190
*
190-
* Mutations not only act on the value of the document but also it version.
191+
* Mutations not only act on the value of the document but also its version.
191192
*
192193
* For local mutations (mutations that haven't been committed yet), we preserve
193194
* the existing version for Set, Patch, and Transform mutations. For Delete
@@ -283,7 +284,7 @@ export abstract class Mutation {
283284
*
284285
* The base value is a sparse object that consists of only the document
285286
* fields for which this mutation contains a non-idempotent transformation
286-
* (e.g. a numeric increment). The provided alue guarantees consistent
287+
* (e.g. a numeric increment). The provided value guarantees consistent
287288
* behavior for non-idempotent transforms and allow us to return the same
288289
* latency-compensated value even if the backend has already applied the
289290
* mutation. The base value is null for idempotent mutations, as they can be
@@ -817,3 +818,45 @@ export class DeleteMutation extends Mutation {
817818
);
818819
}
819820
}
821+
822+
/**
823+
* A mutation that verifies the existence of the document at the given key with
824+
* the provided precondition.
825+
*
826+
* The `verify` operation is only used in Transactions, and this class serves
827+
* primarily to facilitate serialization into protos.
828+
*/
829+
export class VerifyMutation extends Mutation {
830+
constructor(readonly key: DocumentKey, readonly precondition: Precondition) {
831+
super();
832+
}
833+
834+
readonly type: MutationType = MutationType.Verify;
835+
836+
applyToRemoteDocument(
837+
maybeDoc: MaybeDocument | null,
838+
mutationResult: MutationResult
839+
): MaybeDocument {
840+
fail('VerifyMutation should only be used in Transactions.');
841+
}
842+
843+
applyToLocalView(
844+
maybeDoc: MaybeDocument | null,
845+
baseDoc: MaybeDocument | null,
846+
localWriteTime: Timestamp
847+
): MaybeDocument | null {
848+
fail('VerifyMutation should only be used in Transactions.');
849+
}
850+
851+
extractBaseValue(maybeDoc: MaybeDocument | null): null {
852+
fail('VerifyMutation should only be used in Transactions.');
853+
}
854+
855+
isEqual(other: Mutation): boolean {
856+
return (
857+
other instanceof VerifyMutation &&
858+
this.key.isEqual(other.key) &&
859+
this.precondition.isEqual(other.precondition)
860+
);
861+
}
862+
}

packages/firestore/src/protos/firestore_proto_api.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,7 @@ export declare namespace firestoreV1ApiClientInterfaces {
374374
interface Write {
375375
update?: Document;
376376
delete?: string;
377+
verify?: string;
377378
transform?: DocumentTransform;
378379
updateMask?: DocumentMask;
379380
currentDocument?: Precondition;

packages/firestore/src/protos/google/firestore/v1/write.proto

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ message Write {
4242
// `projects/{project_id}/databases/{database_id}/documents/{document_path}`.
4343
string delete = 2;
4444

45+
// The name of a document on which to verify the `current_document`
46+
// precondition.
47+
// This only requires read access to the document.
48+
string verify = 5;
49+
4550
// Applies a tranformation to a document.
4651
// At most one `transform` per document is allowed in a given request.
4752
// An `update` cannot follow a `transform` on the same document in a given

packages/firestore/src/remote/serializer.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ import {
4545
PatchMutation,
4646
Precondition,
4747
SetMutation,
48-
TransformMutation
48+
TransformMutation,
49+
VerifyMutation
4950
} from '../model/mutation';
5051
import { FieldPath, ResourcePath } from '../model/path';
5152
import * as api from '../protos/firestore_proto_api';
@@ -844,6 +845,10 @@ export class JsonProtoSerializer {
844845
)
845846
}
846847
};
848+
} else if (mutation instanceof VerifyMutation) {
849+
result = {
850+
verify: this.toName(mutation.key)
851+
};
847852
} else {
848853
return fail('Unknown mutation type ' + mutation.type);
849854
}
@@ -883,6 +888,9 @@ export class JsonProtoSerializer {
883888
'Transforms only support precondition "exists == true"'
884889
);
885890
return new TransformMutation(key, fieldTransforms);
891+
} else if (proto.verify) {
892+
const key = this.fromName(proto.verify);
893+
return new VerifyMutation(key, precondition);
886894
} else {
887895
return fail('unknown mutation proto: ' + JSON.stringify(proto));
888896
}

packages/firestore/test/integration/api/transactions.test.ts

Lines changed: 44 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -205,45 +205,6 @@ apiDescribe('Database transactions', (persistence: boolean) => {
205205
}
206206
}
207207

208-
// TODO(klimt): Test that transactions don't see latency compensation
209-
// changes, using the other kind of integration test.
210-
// We currently require every document read to also be written.
211-
it('get documents', () => {
212-
return integrationHelpers.withTestDb(persistence, db => {
213-
const doc = db.collection('spaces').doc();
214-
return doc
215-
.set({
216-
foo: 1,
217-
desc: 'Stuff related to Firestore project...',
218-
owner: 'Jonny'
219-
})
220-
.then(() => {
221-
return (
222-
db
223-
.runTransaction(transaction => {
224-
return transaction.get(doc);
225-
})
226-
// We currently require every document read to also be
227-
// written.
228-
// TODO(b/34879758): Add this check back once we drop that.
229-
// .then((snapshot) => {
230-
// expect(snapshot).to.exist;
231-
// expect(snapshot.data()['owner']).to.equal('Jonny');
232-
// });
233-
.then(() => expect.fail('transaction should fail'))
234-
.catch((err: firestore.FirestoreError) => {
235-
expect(err).to.exist;
236-
expect(err.code).to.equal('invalid-argument');
237-
expect(err.message).to.contain(
238-
'Every document read in a transaction must also be' +
239-
' written'
240-
);
241-
})
242-
);
243-
});
244-
});
245-
});
246-
247208
it('runs transactions after getting existing document', async () => {
248209
return integrationHelpers.withTestDb(persistence, async db => {
249210
const tt = new TransactionTester(db);
@@ -484,54 +445,40 @@ apiDescribe('Database transactions', (persistence: boolean) => {
484445
});
485446
});
486447

487-
it('handle reading one doc and writing another', () => {
448+
it('retry when a document that was read without being written changes', () => {
488449
return integrationHelpers.withTestDb(persistence, db => {
489450
const doc1 = db.collection('counters').doc();
490451
const doc2 = db.collection('counters').doc();
491-
// let tries = 0;
492-
return (
493-
doc1
494-
.set({
495-
count: 15
496-
})
497-
.then(() => {
498-
return db.runTransaction(transaction => {
499-
// ++tries;
500-
501-
// Get the first doc.
502-
return (
503-
transaction
504-
.get(doc1)
505-
// Do a write outside of the transaction. The first time the
506-
// transaction is tried, this will bump the version, which
507-
// will cause the write to doc2 to fail. The second time, it
508-
// will be a no-op and not bump the version.
509-
.then(() => doc1.set({ count: 1234 }))
510-
// Now try to update the other doc from within the
511-
// transaction.
512-
// This should fail once, because we read 15 earlier.
513-
.then(() => transaction.set(doc2, { count: 16 }))
514-
);
515-
});
516-
})
517-
// We currently require every document read to also be written.
518-
// TODO(b/34879758): Add this check back once we drop that.
519-
// .then(() => doc1.get())
520-
// .then(snapshot => {
521-
// expect(tries).to.equal(2);
522-
// expect(snapshot.data()['count']).to.equal(1234);
523-
// return doc2.get();
524-
// })
525-
// .then(snapshot => expect(snapshot.data()['count']).to.equal(16));
526-
.then(() => expect.fail('transaction should fail'))
527-
.catch((err: firestore.FirestoreError) => {
528-
expect(err).to.exist;
529-
expect(err.code).to.equal('invalid-argument');
530-
expect(err.message).to.contain(
531-
'Every document read in a transaction must also be ' + 'written'
452+
let tries = 0;
453+
return doc1
454+
.set({
455+
count: 15
456+
})
457+
.then(() => {
458+
return db.runTransaction(transaction => {
459+
++tries;
460+
461+
// Get the first doc.
462+
return (
463+
transaction
464+
.get(doc1)
465+
// Do a write outside of the transaction. The first time the
466+
// transaction is tried, this will bump the version, which
467+
// will cause the write to doc2 to fail. The second time, it
468+
// will be a no-op and not bump the version.
469+
.then(() => doc1.set({ count: 1234 }))
470+
// Now try to update the other doc from within the
471+
// transaction.
472+
// This should fail once, because we read 15 earlier.
473+
.then(() => transaction.set(doc2, { count: 16 }))
532474
);
533-
})
534-
);
475+
});
476+
})
477+
.then(async () => {
478+
const snapshot = await doc1.get();
479+
expect(tries).to.equal(2);
480+
expect(snapshot.data()!['count']).to.equal(1234);
481+
});
535482
});
536483
});
537484

@@ -621,32 +568,22 @@ apiDescribe('Database transactions', (persistence: boolean) => {
621568
}
622569
});
623570

624-
it('cannot have a get without mutations', () => {
571+
it('can have gets without mutations', () => {
625572
return integrationHelpers.withTestDb(persistence, db => {
626573
const docRef = db.collection('foo').doc();
627-
return (
628-
docRef
629-
.set({ foo: 'bar' })
630-
.then(() => {
631-
return db.runTransaction(txn => {
632-
return txn.get(docRef);
633-
});
634-
})
635-
// We currently require every document read to also be written.
636-
// TODO(b/34879758): Add this check back once we drop that.
637-
// .then((snapshot) => {
638-
// expect(snapshot).to.exist;
639-
// expect(snapshot.data()['foo']).to.equal('bar');
640-
// });
641-
.then(() => expect.fail('transaction should fail'))
642-
.catch((err: firestore.FirestoreError) => {
643-
expect(err).to.exist;
644-
expect(err.code).to.equal('invalid-argument');
645-
expect(err.message).to.contain(
646-
'Every document read in a transaction must also be ' + 'written'
647-
);
648-
})
649-
);
574+
const docRef2 = db.collection('foo').doc();
575+
return docRef
576+
.set({ foo: 'bar' })
577+
.then(() => {
578+
return db.runTransaction(async txn => {
579+
await txn.get(docRef2);
580+
return txn.get(docRef);
581+
});
582+
})
583+
.then(snapshot => {
584+
expect(snapshot).to.exist;
585+
expect(snapshot.data()!['foo']).to.equal('bar');
586+
});
650587
});
651588
});
652589

packages/firestore/test/unit/remote/node/serializer.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@ import {
4444
FieldMask,
4545
Mutation,
4646
Precondition,
47-
SetMutation
47+
SetMutation,
48+
VerifyMutation
4849
} from '../../../../src/model/mutation';
4950
import { DOCUMENT_KEY_NAME, FieldPath } from '../../../../src/model/path';
5051
import {
@@ -696,6 +697,20 @@ describe('Serializer', () => {
696697
};
697698
verifyMutation(mutation, proto);
698699
});
700+
701+
it('VerifyMutation', () => {
702+
const mutation = new VerifyMutation(
703+
key('foo/bar'),
704+
Precondition.updateTime(version(4))
705+
);
706+
const proto = {
707+
verify: s.toName(mutation.key),
708+
currentDocument: {
709+
updateTime: { seconds: '0', nanos: 4000 }
710+
}
711+
};
712+
verifyMutation(mutation, proto);
713+
});
699714
});
700715

701716
it('toDocument() / fromDocument', () => {

0 commit comments

Comments
 (0)