Skip to content

Commit 0fac7bd

Browse files
Add Transaction (#3153)
1 parent 0abf211 commit 0fac7bd

File tree

5 files changed

+298
-11
lines changed

5 files changed

+298
-11
lines changed

packages/firestore/lite/index.node.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ export { DocumentSnapshot, QueryDocumentSnapshot } from './src/api/snapshot';
6060

6161
export { WriteBatch, writeBatch } from './src/api/write_batch';
6262

63+
export { Transaction, runTransaction } from './src/api/transaction';
64+
6365
export { setLogLevel } from '../src/util/log';
6466

6567
export function registerFirestore(): void {
Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* @license
3+
* Copyright 2020 Google LLC
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
import * as firestore from '../../';
19+
20+
import { UserDataReader } from '../../../src/api/user_data_reader';
21+
import { Transaction as InternalTransaction } from '../../../src/core/transaction';
22+
import {
23+
Document,
24+
MaybeDocument,
25+
NoDocument
26+
} from '../../../src/model/document';
27+
import { fail } from '../../../src/util/assert';
28+
import { applyFirestoreDataConverter } from '../../../src/api/database';
29+
import { DocumentSnapshot } from './snapshot';
30+
import { Firestore } from './database';
31+
import { TransactionRunner } from '../../../src/core/transaction_runner';
32+
import { AsyncQueue } from '../../../src/util/async_queue';
33+
import { Deferred } from '../../../src/util/promise';
34+
import { FieldPath as ExternalFieldPath } from '../../../src/api/field_path';
35+
import { validateReference } from './write_batch';
36+
import { newUserDataReader } from './reference';
37+
import { FieldPath } from './field_path';
38+
import { cast } from './util';
39+
40+
export class Transaction implements firestore.Transaction {
41+
// This is the lite version of the Transaction API used in the legacy SDK. The
42+
// class is a close copy but takes different input types.
43+
44+
private readonly _dataReader: UserDataReader;
45+
46+
constructor(
47+
private readonly _firestore: Firestore,
48+
private readonly _transaction: InternalTransaction
49+
) {
50+
// Kick off configuring the client, which freezes the settings.
51+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
52+
_firestore._ensureClientConfigured();
53+
this._dataReader = newUserDataReader(
54+
_firestore._databaseId,
55+
_firestore._settings!
56+
);
57+
}
58+
59+
get<T>(
60+
documentRef: firestore.DocumentReference<T>
61+
): Promise<firestore.DocumentSnapshot<T>> {
62+
const ref = validateReference(documentRef, this._firestore);
63+
return this._transaction
64+
.lookup([ref._key])
65+
.then((docs: MaybeDocument[]) => {
66+
if (!docs || docs.length !== 1) {
67+
return fail('Mismatch in docs returned from document lookup.');
68+
}
69+
const doc = docs[0];
70+
if (doc instanceof NoDocument) {
71+
return new DocumentSnapshot<T>(
72+
this._firestore,
73+
ref._key,
74+
null,
75+
ref._converter
76+
);
77+
} else if (doc instanceof Document) {
78+
return new DocumentSnapshot<T>(
79+
this._firestore,
80+
doc.key,
81+
doc,
82+
ref._converter
83+
);
84+
} else {
85+
throw fail(
86+
`BatchGetDocumentsRequest returned unexpected document type: ${doc.constructor.name}`
87+
);
88+
}
89+
});
90+
}
91+
92+
set<T>(documentRef: firestore.DocumentReference<T>, value: T): Transaction;
93+
set<T>(
94+
documentRef: firestore.DocumentReference<T>,
95+
value: Partial<T>,
96+
options: firestore.SetOptions
97+
): Transaction;
98+
set<T>(
99+
documentRef: firestore.DocumentReference<T>,
100+
value: T,
101+
options?: firestore.SetOptions
102+
): Transaction {
103+
const ref = validateReference(documentRef, this._firestore);
104+
const [convertedValue] = applyFirestoreDataConverter(
105+
ref._converter,
106+
value,
107+
'Transaction.set'
108+
);
109+
const parsed = this._dataReader.parseSetData(
110+
'Transaction.set',
111+
convertedValue,
112+
options
113+
);
114+
this._transaction.set(ref._key, parsed);
115+
return this;
116+
}
117+
118+
update(
119+
documentRef: firestore.DocumentReference<unknown>,
120+
value: firestore.UpdateData
121+
): Transaction;
122+
update(
123+
documentRef: firestore.DocumentReference<unknown>,
124+
field: string | ExternalFieldPath,
125+
value: unknown,
126+
...moreFieldsAndValues: unknown[]
127+
): Transaction;
128+
update(
129+
documentRef: firestore.DocumentReference<unknown>,
130+
fieldOrUpdateData: string | ExternalFieldPath | firestore.UpdateData,
131+
value?: unknown,
132+
...moreFieldsAndValues: unknown[]
133+
): Transaction {
134+
const ref = validateReference(documentRef, this._firestore);
135+
136+
let parsed;
137+
if (
138+
typeof fieldOrUpdateData === 'string' ||
139+
fieldOrUpdateData instanceof FieldPath
140+
) {
141+
parsed = this._dataReader.parseUpdateVarargs(
142+
'Transaction.update',
143+
fieldOrUpdateData,
144+
value,
145+
moreFieldsAndValues
146+
);
147+
} else {
148+
parsed = this._dataReader.parseUpdateData(
149+
'Transaction.update',
150+
fieldOrUpdateData
151+
);
152+
}
153+
154+
this._transaction.update(ref._key, parsed);
155+
return this;
156+
}
157+
158+
delete(documentRef: firestore.DocumentReference<unknown>): Transaction {
159+
const ref = validateReference(documentRef, this._firestore);
160+
this._transaction.delete(ref._key);
161+
return this;
162+
}
163+
}
164+
165+
export function runTransaction<T>(
166+
firestore: firestore.FirebaseFirestore,
167+
updateFunction: (transaction: firestore.Transaction) => Promise<T>
168+
): Promise<T> {
169+
const firestoreClient = cast(firestore, Firestore);
170+
return firestoreClient._ensureClientConfigured().then(async datastore => {
171+
const deferred = new Deferred<T>();
172+
new TransactionRunner<T>(
173+
new AsyncQueue(),
174+
datastore,
175+
internalTransaction =>
176+
updateFunction(new Transaction(firestoreClient, internalTransaction)),
177+
deferred
178+
).run();
179+
return deferred.promise;
180+
});
181+
}

packages/firestore/lite/src/api/write_batch.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,11 @@ export class WriteBatch implements firestore.WriteBatch {
3737
// This is the lite version of the WriteBatch API used in the legacy SDK. The
3838
// class is a close copy but takes different input types.
3939

40+
private readonly _dataReader: UserDataReader;
4041
private _mutations = [] as Mutation[];
4142
private _committed = false;
42-
private _dataReader: UserDataReader;
4343

44-
constructor(private _firestore: Firestore) {
44+
constructor(private readonly _firestore: Firestore) {
4545
// Kick off configuring the client, which freezes the settings.
4646
// eslint-disable-next-line @typescript-eslint/no-floating-promises
4747
_firestore._ensureClientConfigured();

packages/firestore/lite/test/integration.test.ts

Lines changed: 112 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,9 @@ import {
5252
DEFAULT_SETTINGS
5353
} from '../../test/integration/util/settings';
5454
import { writeBatch } from '../src/api/write_batch';
55+
import { runTransaction } from '../src/api/transaction';
5556
import { expectEqual, expectNotEqual } from '../../test/util/helpers';
5657
import { FieldValue } from '../../src/api/field_value';
57-
5858
use(chaiAsPromised);
5959

6060
describe('Firestore', () => {
@@ -324,7 +324,95 @@ describe('WriteBatch', () => {
324324
});
325325
});
326326

327-
function genericMutationTests(op: MutationTester): void {
327+
describe('Transaction', () => {
328+
class TransactionTester implements MutationTester {
329+
delete(ref: firestore.DocumentReference<unknown>): Promise<void> {
330+
return runTransaction(ref.firestore, async transaction => {
331+
transaction.delete(ref);
332+
});
333+
}
334+
335+
set<T>(
336+
ref: firestore.DocumentReference<T>,
337+
data: T | Partial<T>,
338+
options?: firestore.SetOptions
339+
): Promise<void> {
340+
const args = Array.from(arguments);
341+
return runTransaction(ref.firestore, async transaction => {
342+
// TODO(mrschmidt): Find a way to remove the `any` cast here
343+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
344+
(transaction.set as any).apply(transaction, args);
345+
});
346+
}
347+
348+
update(
349+
ref: firestore.DocumentReference<unknown>,
350+
dataOrField: firestore.UpdateData | string | firestore.FieldPath,
351+
value?: unknown,
352+
...moreFieldsAndValues: unknown[]
353+
): Promise<void> {
354+
const args = Array.from(arguments);
355+
return runTransaction(ref.firestore, async transaction => {
356+
// TODO(mrschmidt): Find a way to remove the `any` cast here
357+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
358+
(transaction.update as any).apply(transaction, args);
359+
});
360+
}
361+
}
362+
363+
genericMutationTests(
364+
new TransactionTester(),
365+
/* validationUsesPromises= */ true
366+
);
367+
368+
it('can read and then write', () => {
369+
return withTestDocAndInitialData({ counter: 1 }, async doc => {
370+
await runTransaction(doc.firestore, async transaction => {
371+
const snap = await transaction.get(doc);
372+
transaction.update(doc, 'counter', snap.get('counter') + 1);
373+
});
374+
const result = await getDoc(doc);
375+
expect(result.get('counter')).to.equal(2);
376+
});
377+
});
378+
379+
it('can read non-existing doc then write', () => {
380+
return withTestDoc(async doc => {
381+
await runTransaction(doc.firestore, async transaction => {
382+
const snap = await transaction.get(doc);
383+
expect(snap.exists()).to.be.false;
384+
transaction.set(doc, { counter: 1 });
385+
});
386+
const result = await getDoc(doc);
387+
expect(result.get('counter')).to.equal(1);
388+
});
389+
});
390+
391+
it('retries when document is modified', () => {
392+
return withTestDoc(async doc => {
393+
let retryCounter = 0;
394+
await runTransaction(doc.firestore, async transaction => {
395+
++retryCounter;
396+
await transaction.get(doc);
397+
398+
if (retryCounter === 1) {
399+
// Out of band modification that doesn't use the transaction
400+
await setDoc(doc, { counter: 'invalid' });
401+
}
402+
403+
transaction.set(doc, { counter: 1 });
404+
});
405+
expect(retryCounter).to.equal(2);
406+
const result = await getDoc(doc);
407+
expect(result.get('counter')).to.equal(1);
408+
});
409+
});
410+
});
411+
412+
function genericMutationTests(
413+
op: MutationTester,
414+
validationUsesPromises: boolean = false
415+
): void {
328416
const setDoc = op.set;
329417
const updateDoc = op.update;
330418
const deleteDoc = op.delete;
@@ -379,9 +467,17 @@ function genericMutationTests(op: MutationTester): void {
379467

380468
it('throws when user input fails validation', () => {
381469
return withTestDoc(async docRef => {
382-
expect(() => setDoc(docRef, { val: undefined })).to.throw(
383-
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
384-
);
470+
if (validationUsesPromises) {
471+
return expect(
472+
setDoc(docRef, { val: undefined })
473+
).to.eventually.be.rejectedWith(
474+
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
475+
);
476+
} else {
477+
expect(() => setDoc(docRef, { val: undefined })).to.throw(
478+
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
479+
);
480+
}
385481
});
386482
});
387483

@@ -418,9 +514,17 @@ function genericMutationTests(op: MutationTester): void {
418514

419515
it('throws when user input fails validation', () => {
420516
return withTestDoc(async docRef => {
421-
expect(() => updateDoc(docRef, { val: undefined })).to.throw(
422-
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
423-
);
517+
if (validationUsesPromises) {
518+
return expect(
519+
updateDoc(docRef, { val: undefined })
520+
).to.eventually.be.rejectedWith(
521+
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
522+
);
523+
} else {
524+
expect(() => updateDoc(docRef, { val: undefined })).to.throw(
525+
/Function .* called with invalid data. Unsupported field value: undefined \(found in field val\)/
526+
);
527+
}
424528
});
425529
});
426530
});

packages/firestore/src/util/promise.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export interface Rejecter {
2323
(reason?: Error): void;
2424
}
2525

26-
export class Deferred<R> {
26+
export class Deferred<R = void> {
2727
promise: Promise<R>;
2828
// Assigned synchronously in constructor by Promise constructor callback.
2929
resolve!: Resolver<R>;

0 commit comments

Comments
 (0)