Skip to content

Commit a4ff5fd

Browse files
Add setDoc()
1 parent 518631d commit a4ff5fd

File tree

9 files changed

+206
-56
lines changed

9 files changed

+206
-56
lines changed

packages/firestore/lite/index.node.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ export {
3636
doc,
3737
parent,
3838
getDoc,
39-
deleteDoc
39+
deleteDoc,
40+
setDoc
4041
} from './src/api/reference';
4142

4243
export { FieldPath } from './src/api/field_path';

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

Lines changed: 3 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,50 +18,28 @@
1818
import * as firestore from '../../index';
1919

2020
import { tryCast } from './util';
21-
import { FieldPath as InternalFieldPath } from '../../../src/model/path';
22-
import { validateNamedArrayAtLeastNumberOfElements } from '../../../src/util/input_validation';
23-
import { Code, FirestoreError } from '../../../src/util/error';
21+
import { BaseFieldPath } from '../../../src/api/field_path';
2422

2523
/**
2624
* A FieldPath refers to a field in a document. The path may consist of a single
2725
* field name (referring to a top-level field in the document), or a list of
2826
* field names (referring to a nested field in the document).
2927
*/
30-
export class FieldPath implements firestore.FieldPath {
28+
export class FieldPath extends BaseFieldPath implements firestore.FieldPath {
3129
// Note: This class is stripped down a copy of the FieldPath class in the
3230
// legacy SDK. The changes are:
3331
// - The `documentId()` static method has been removed
3432
// - Input validation is limited to errors that cannot be caught by the
3533
// TypeScript transpiler.
3634

37-
/** Internal representation of a Firestore field path. */
38-
_internalPath: InternalFieldPath;
39-
4035
/**
4136
* Creates a FieldPath from the provided field names. If more than one field
4237
* name is provided, the path will point to a nested field in a document.
4338
*
4439
* @param fieldNames A list of field names.
4540
*/
4641
constructor(...fieldNames: string[]) {
47-
validateNamedArrayAtLeastNumberOfElements(
48-
'FieldPath',
49-
fieldNames,
50-
'fieldNames',
51-
1
52-
);
53-
54-
for (let i = 0; i < fieldNames.length; ++i) {
55-
if (fieldNames[i].length === 0) {
56-
throw new FirestoreError(
57-
Code.INVALID_ARGUMENT,
58-
`Invalid field name at argument $(i + 1). ` +
59-
'Field names must not be empty.'
60-
);
61-
}
62-
}
63-
64-
this._internalPath = new InternalFieldPath(fieldNames);
42+
super(fieldNames);
6543
}
6644

6745
isEqual(other: firestore.FieldPath): boolean {

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

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@ import * as firestore from '../../index';
2020
import { Document } from '../../../src/model/document';
2121
import { DocumentKey } from '../../../src/model/document_key';
2222
import { Firestore } from './database';
23-
import { DocumentKeyReference } from '../../../src/api/user_data_reader';
23+
import {
24+
DocumentKeyReference,
25+
UserDataReader
26+
} from '../../../src/api/user_data_reader';
2427
import { Query as InternalQuery } from '../../../src/core/query';
2528
import { FirebaseFirestore, FirestoreDataConverter } from '../../index';
2629
import { ResourcePath } from '../../../src/model/path';
@@ -34,6 +37,8 @@ import {
3437
} from '../../../src/remote/datastore';
3538
import { hardAssert } from '../../../src/util/assert';
3639
import { DeleteMutation, Precondition } from '../../../src/model/mutation';
40+
import { PlatformSupport } from '../../../src/platform/platform';
41+
import { applyFirestoreDataConverter } from '../../../src/api/database';
3742

3843
/**
3944
* A reference to a particular document in a collection in the database.
@@ -301,6 +306,34 @@ export function getDoc<T>(
301306
});
302307
}
303308

309+
export function setDoc<T>(
310+
reference: firestore.DocumentReference<T>,
311+
data: T,
312+
options?: firestore.SetOptions
313+
): Promise<void> {
314+
const ref = tryCast(reference, DocumentReference);
315+
return ref.firestore._ensureClientConfigured().then(firestore => {
316+
const dataReader = newUserDataReader(firestore);
317+
318+
const [convertedValue] = applyFirestoreDataConverter(
319+
ref._converter,
320+
data,
321+
'setDoc'
322+
);
323+
324+
const parsed = isMerge(options)
325+
? dataReader.parseMergeData('setDoc', convertedValue)
326+
: isMergeFields(options)
327+
? dataReader.parseMergeData('setDoc', convertedValue, options.mergeFields)
328+
: dataReader.parseSetData('setDoc', convertedValue);
329+
330+
return invokeCommitRpc(
331+
firestore._datastore,
332+
parsed.toMutations(ref._key, Precondition.none())
333+
);
334+
});
335+
}
336+
304337
export function deleteDoc(
305338
reference: firestore.DocumentReference
306339
): Promise<void> {
@@ -313,3 +346,30 @@ export function deleteDoc(
313346
])
314347
);
315348
}
349+
350+
/** Returns true if options.merge is true. */
351+
function isMerge(options?: firestore.SetOptions): options is { merge: true } {
352+
return !!options && (options as { merge: true }).merge;
353+
}
354+
355+
/** Returns true if options.mergeFields is set. */
356+
function isMergeFields(
357+
options?: firestore.SetOptions
358+
): options is { mergeFields: Array<string | firestore.FieldPath> } {
359+
return (
360+
!!options &&
361+
!!(options as { mergeFields: Array<string | firestore.FieldPath> })
362+
.mergeFields
363+
);
364+
}
365+
366+
function newUserDataReader(firestore: Required<Firestore>): UserDataReader {
367+
const serializer = PlatformSupport.getPlatform().newSerializer(
368+
firestore._databaseId
369+
);
370+
return new UserDataReader(
371+
firestore._databaseId,
372+
!!firestore._settings.ignoreUndefinedProperties,
373+
serializer
374+
);
375+
}

packages/firestore/lite/test/helpers.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { initializeApp } from '@firebase/app-exp';
2020
import * as firestore from '../index';
2121

2222
import { initializeFirestore } from '../src/api/database';
23-
import { doc, collection } from '../src/api/reference';
23+
import { doc, collection, setDoc } from '../src/api/reference';
2424
import {
2525
DEFAULT_PROJECT_ID,
2626
DEFAULT_SETTINGS
@@ -55,3 +55,14 @@ export function withTestDoc(
5555
return fn(doc(collection(db, 'test-collection')));
5656
});
5757
}
58+
59+
export function withTestDocAndInitialData(
60+
data: firestore.DocumentData,
61+
fn: (doc: firestore.DocumentReference) => void | Promise<void>
62+
): Promise<void> {
63+
return withTestDb(async db => {
64+
const ref = doc(collection(db, 'test-collection'));
65+
await setDoc(ref, data);
66+
return fn(ref);
67+
});
68+
}

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

Lines changed: 97 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,18 @@ import {
2323
getFirestore,
2424
initializeFirestore
2525
} from '../src/api/database';
26-
import { withTestDb, withTestDoc } from './helpers';
26+
import { withTestDb, withTestDoc, withTestDocAndInitialData } from './helpers';
2727
import {
2828
parent,
2929
collection,
3030
CollectionReference,
3131
doc,
3232
DocumentReference,
3333
getDoc,
34-
deleteDoc
34+
deleteDoc,
35+
setDoc
3536
} from '../src/api/reference';
37+
import { FieldPath } from '../src/api/field_path';
3638

3739
describe('Firestore', () => {
3840
it('can provide setting', () => {
@@ -165,13 +167,104 @@ describe('getDoc()', () => {
165167
});
166168
});
167169

168-
// TODO(firestorelite): Expand test coverage once we can write docs
170+
it('can get an existing document', () => {
171+
return withTestDocAndInitialData({ val: 1 }, async docRef => {
172+
const docSnap = await getDoc(docRef);
173+
expect(docSnap.exists()).to.be.true;
174+
});
175+
});
169176
});
170177

171178
describe('deleteDoc()', () => {
172179
it('can delete a non-existing document', () => {
173180
return withTestDoc(docRef => deleteDoc(docRef));
174181
});
175182

176-
// TODO(firestorelite): Expand test coverage once we can write docs
183+
it('can delete an existing document', () => {
184+
return withTestDoc(async docRef => {
185+
await setDoc(docRef, {});
186+
await deleteDoc(docRef);
187+
const docSnap = await getDoc(docRef);
188+
expect(docSnap.exists()).to.be.false;
189+
});
190+
});
191+
});
192+
193+
describe('setDoc()', () => {
194+
it('can set a new document', () => {
195+
return withTestDoc(async docRef => {
196+
await setDoc(docRef, { val: 1 });
197+
const docSnap = await getDoc(docRef);
198+
expect(docSnap.data()).to.deep.equal({ val: 1 });
199+
});
200+
});
201+
202+
it('can merge a document', () => {
203+
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
204+
await setDoc(docRef, { bar: 2 }, { merge: true });
205+
const docSnap = await getDoc(docRef);
206+
expect(docSnap.data()).to.deep.equal({ foo: 1, bar: 2 });
207+
});
208+
});
209+
210+
it('can merge a document with mergeFields', () => {
211+
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
212+
await setDoc(
213+
docRef,
214+
{ foo: 2, bar: 2, baz: { foobar: 3 } },
215+
{ mergeFields: ['bar', new FieldPath('baz', 'foobar')] }
216+
);
217+
const docSnap = await getDoc(docRef);
218+
expect(docSnap.data()).to.deep.equal({
219+
foo: 1,
220+
bar: 2,
221+
baz: { foobar: 3 }
222+
});
223+
});
224+
});
225+
});
226+
227+
describe('DocumentSnapshot', () => {
228+
it('can represent missing data', () => {
229+
return withTestDoc(async docRef => {
230+
const docSnap = await getDoc(docRef);
231+
expect(docSnap.exists()).to.be.false;
232+
expect(docSnap.data()).to.be.undefined;
233+
});
234+
});
235+
236+
it('can return data', () => {
237+
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
238+
const docSnap = await getDoc(docRef);
239+
expect(docSnap.exists()).to.be.true;
240+
expect(docSnap.data()).to.deep.equal({ foo: 1 });
241+
});
242+
});
243+
244+
it('can return single field', () => {
245+
return withTestDocAndInitialData({ foo: 1, bar: 2 }, async docRef => {
246+
const docSnap = await getDoc(docRef);
247+
expect(docSnap.get('foo')).to.equal(1);
248+
expect(docSnap.get(new FieldPath('bar'))).to.equal(2);
249+
});
250+
});
251+
252+
it('can return nested field', () => {
253+
return withTestDocAndInitialData({ foo: { bar: 1 } }, async docRef => {
254+
const docSnap = await getDoc(docRef);
255+
expect(docSnap.get('foo.bar')).to.equal(1);
256+
expect(docSnap.get(new FieldPath('foo', 'bar'))).to.equal(1);
257+
});
258+
});
259+
260+
it('is properly typed', () => {
261+
return withTestDocAndInitialData({ foo: 1 }, async docRef => {
262+
const docSnap = await getDoc(docRef);
263+
let documentData = docSnap.data()!; // "data" is typed as nullable
264+
if (docSnap.exists()) {
265+
documentData = docSnap.data(); // "data" is typed as non-null
266+
}
267+
expect(documentData).to.deep.equal({ foo: 1 });
268+
});
269+
});
177270
});

packages/firestore/src/api/database.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
import {
9292
DocumentKeyReference,
9393
fieldPathFromArgument,
94+
UntypedFirestoreDataConverter,
9495
UserDataReader
9596
} from './user_data_reader';
9697
import { UserDataWriter } from './user_data_writer';
@@ -2535,8 +2536,8 @@ function resultChangeType(type: ChangeType): firestore.DocumentChangeType {
25352536
* their set() or fails due to invalid data originating from a toFirestore()
25362537
* call.
25372538
*/
2538-
function applyFirestoreDataConverter<T>(
2539-
converter: firestore.FirestoreDataConverter<T> | undefined,
2539+
export function applyFirestoreDataConverter<T>(
2540+
converter: UntypedFirestoreDataConverter<T> | undefined,
25402541
value: T,
25412542
functionName: string
25422543
): [firestore.DocumentData, string] {

packages/firestore/src/api/field_path.ts

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,14 @@ import {
3030
// underscore to discourage their use.
3131

3232
/**
33-
* A FieldPath refers to a field in a document. The path may consist of a single
34-
* field name (referring to a top-level field in the document), or a list of
35-
* field names (referring to a nested field in the document).
33+
* A field class base class that is shared by the lite, full and legacy SDK,
34+
* which supports shared code that deals with FieldPaths.
3635
*/
37-
export class FieldPath implements firestore.FieldPath {
36+
export abstract class BaseFieldPath {
3837
/** Internal representation of a Firestore field path. */
39-
_internalPath: InternalFieldPath;
38+
readonly _internalPath: InternalFieldPath;
4039

41-
/**
42-
* Creates a FieldPath from the provided field names. If more than one field
43-
* name is provided, the path will point to a nested field in a document.
44-
*
45-
* @param fieldNames A list of field names.
46-
*/
47-
constructor(...fieldNames: string[]) {
40+
constructor(fieldNames: string[]) {
4841
validateNamedArrayAtLeastNumberOfElements(
4942
'FieldPath',
5043
fieldNames,
@@ -65,6 +58,22 @@ export class FieldPath implements firestore.FieldPath {
6558

6659
this._internalPath = new InternalFieldPath(fieldNames);
6760
}
61+
}
62+
/**
63+
* A FieldPath refers to a field in a document. The path may consist of a single
64+
* field name (referring to a top-level field in the document), or a list of
65+
* field names (referring to a nested field in the document).
66+
*/
67+
export class FieldPath extends BaseFieldPath implements firestore.FieldPath {
68+
/**
69+
* Creates a FieldPath from the provided field names. If more than one field
70+
* name is provided, the path will point to a nested field in a document.
71+
*
72+
* @param fieldNames A list of field names.
73+
*/
74+
constructor(...fieldNames: string[]) {
75+
super(fieldNames);
76+
}
6877

6978
/**
7079
* Internal Note: The backend doesn't technically support querying by

0 commit comments

Comments
 (0)