Skip to content

Commit 8940a74

Browse files
committed
feat(firestore): implement withConverter
1 parent 5d27948 commit 8940a74

14 files changed

+1310
-60
lines changed
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
/*
2+
* Copyright (c) 2021-present Invertase Limited & Contributors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this library except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
*/
17+
const COLLECTION = 'firestore';
18+
const { wipe } = require('./helpers');
19+
20+
const {
21+
getFirestore,
22+
doc,
23+
collection,
24+
refEqual,
25+
addDoc,
26+
setDoc,
27+
getDoc,
28+
query,
29+
where,
30+
getDocs,
31+
writeBatch,
32+
increment,
33+
} = firestoreModular;
34+
35+
// Used for testing the FirestoreDataConverter.
36+
class Post {
37+
constructor(title, author, id = 1) {
38+
this.title = title;
39+
this.author = author;
40+
this.id = id;
41+
}
42+
byline() {
43+
return this.title + ', by ' + this.author;
44+
}
45+
}
46+
47+
const postConverter = {
48+
toFirestore(post) {
49+
return { title: post.title, author: post.author };
50+
},
51+
fromFirestore(snapshot) {
52+
const data = snapshot.data();
53+
return new Post(data.title, data.author);
54+
},
55+
};
56+
57+
const postConverterMerge = {
58+
toFirestore(post, options) {
59+
if (
60+
options &&
61+
((options && options.merge === true) ||
62+
(options && Array.isArray(options.mergeFields) && options.mergeFields.length > 0))
63+
) {
64+
post.should.not.be.an.instanceof(Post);
65+
} else {
66+
post.should.be.an.instanceof(Post);
67+
}
68+
const result = {};
69+
if (post.title) {
70+
result.title = post.title;
71+
}
72+
if (post.author) {
73+
result.author = post.author;
74+
}
75+
return result;
76+
},
77+
fromFirestore(snapshot) {
78+
const data = snapshot.data();
79+
return new Post(data.title, data.author);
80+
},
81+
};
82+
83+
function withTestDb(fn) {
84+
return fn(getFirestore());
85+
}
86+
87+
function withTestCollection(fn) {
88+
return withTestDb(db => fn(collection(db, COLLECTION)));
89+
}
90+
function withTestDoc(fn) {
91+
return withTestDb(db => fn(doc(db, `${COLLECTION}/doc`)));
92+
}
93+
94+
function withTestCollectionAndInitialData(data, fn) {
95+
return withTestDb(async db => {
96+
const coll = collection(db, COLLECTION);
97+
for (const element of data) {
98+
const ref = doc(coll);
99+
await setDoc(ref, element);
100+
}
101+
return fn(coll);
102+
});
103+
}
104+
105+
describe('withConverter() support', function () {
106+
before(function () {
107+
return wipe();
108+
});
109+
110+
it('for collection references', function () {
111+
return withTestDb(firestore => {
112+
const coll1a = collection(firestore, 'a');
113+
const coll1b = doc(firestore, 'a/b').parent;
114+
const coll2 = collection(firestore, 'c');
115+
116+
refEqual(coll1a, coll1b).should.be.true();
117+
refEqual(coll1a, coll2).should.be.false();
118+
119+
const coll1c = collection(firestore, 'a').withConverter({
120+
toFirestore: data => data,
121+
fromFirestore: snap => snap.data(),
122+
});
123+
refEqual(coll1a, coll1c).should.be.false();
124+
125+
try {
126+
refEqual(coll1a, doc(firestore, 'a/b'));
127+
return Promise.reject(new Error('Did not throw an Error.'));
128+
} catch (error) {
129+
error.message.should.containEql('expected a Query instance.');
130+
return Promise.resolve();
131+
}
132+
});
133+
});
134+
135+
it('for document references', function () {
136+
return withTestDb(firestore => {
137+
const doc1a = doc(firestore, 'a/b');
138+
const doc1b = doc(collection(firestore, 'a'), 'b');
139+
const doc2 = doc(firestore, 'a/c');
140+
141+
refEqual(doc1a, doc1b).should.be.true();
142+
refEqual(doc1a, doc2).should.be.false();
143+
144+
try {
145+
const doc1c = collection(firestore, 'a').withConverter({
146+
toFirestore: data => data,
147+
fromFirestore: snap => snap.data(),
148+
});
149+
refEqual(doc1a, doc1c);
150+
return Promise.reject(new Error('Did not throw an Error.'));
151+
} catch (error) {
152+
error.message.should.containEql('expected a DocumentReference instance.');
153+
}
154+
155+
try {
156+
refEqual(doc1a, collection(firestore, 'a'));
157+
return Promise.reject(new Error('Did not throw an Error.'));
158+
} catch (error) {
159+
error.message.should.containEql('expected a DocumentReference instance.');
160+
}
161+
return Promise.resolve();
162+
});
163+
});
164+
165+
it('for DocumentReference.withConverter()', function () {
166+
return withTestDoc(async docRef => {
167+
docRef = docRef.withConverter(postConverter);
168+
await setDoc(docRef, new Post('post', 'author'));
169+
const postData = await getDoc(docRef);
170+
const post = postData.data();
171+
post.should.not.be.undefined();
172+
post.byline().should.equal('post, by author');
173+
});
174+
});
175+
176+
it('for DocumentReference.withConverter(null) applies default converter', function () {
177+
return withTestCollection(async coll => {
178+
coll = coll.withConverter(postConverter).withConverter(null);
179+
try {
180+
await setDoc(doc(coll, 'post1'), 10);
181+
return Promise.reject(new Error('Did not throw an Error.'));
182+
} catch (error) {
183+
error.message.should.containEql(
184+
`firebase.firestore().doc().set(*) 'data' must be an object.`,
185+
);
186+
return Promise.resolve();
187+
}
188+
});
189+
});
190+
191+
it('for CollectionReference.withConverter()', function () {
192+
return withTestCollection(async coll => {
193+
coll = coll.withConverter(postConverter);
194+
const docRef = await addDoc(coll, new Post('post', 'author'));
195+
const postData = await getDoc(docRef);
196+
const post = postData.data();
197+
post.should.not.be.undefined();
198+
post.byline().should.equal('post, by author');
199+
});
200+
});
201+
202+
it('for CollectionReference.withConverter(null) applies default converter', function () {
203+
return withTestDoc(async doc => {
204+
try {
205+
doc = doc.withConverter(postConverter).withConverter(null);
206+
await setDoc(doc, 10);
207+
return Promise.reject(new Error('Did not throw an Error.'));
208+
} catch (error) {
209+
error.message.should.containEql(
210+
`firebase.firestore().doc().set(*) 'data' must be an object.`,
211+
);
212+
return Promise.resolve();
213+
}
214+
});
215+
});
216+
217+
it('for Query.withConverter()', function () {
218+
return withTestCollectionAndInitialData(
219+
[{ title: 'post', author: 'author' }],
220+
async collRef => {
221+
let query1 = query(collRef, where('title', '==', 'post'));
222+
query1 = query1.withConverter(postConverter);
223+
const result = await getDocs(query1);
224+
result.docs[0].data().should.be.an.instanceOf(Post);
225+
result.docs[0].data().byline().should.equal('post, by author');
226+
},
227+
);
228+
});
229+
230+
it('for Query.withConverter(null) applies default converter', function () {
231+
return withTestCollectionAndInitialData(
232+
[{ title: 'post', author: 'author' }],
233+
async collRef => {
234+
let query1 = query(collRef, where('title', '==', 'post'));
235+
query1 = query1.withConverter(postConverter).withConverter(null);
236+
const result = await getDocs(query1);
237+
result.docs[0].should.not.be.an.instanceOf(Post);
238+
},
239+
);
240+
});
241+
242+
it('keeps the converter when calling parent() with a DocumentReference', function () {
243+
return withTestDb(async db => {
244+
const coll = doc(db, 'root/doc').withConverter(postConverter);
245+
const typedColl = coll.parent;
246+
refEqual(typedColl, collection(db, 'root').withConverter(postConverter)).should.be.true();
247+
});
248+
});
249+
250+
it('drops the converter when calling parent() with a CollectionReference', function () {
251+
return withTestDb(async db => {
252+
const coll = collection(db, 'root/doc/parent').withConverter(postConverter);
253+
const untypedDoc = coll.parent;
254+
refEqual(untypedDoc, doc(db, 'root/doc')).should.be.true();
255+
});
256+
});
257+
258+
it('checks converter when comparing with isEqual()', function () {
259+
return withTestDb(async db => {
260+
const postConverter2 = { ...postConverter };
261+
262+
const postsCollection = collection(db, 'users/user1/posts').withConverter(postConverter);
263+
const postsCollection2 = collection(db, 'users/user1/posts').withConverter(postConverter2);
264+
refEqual(postsCollection, postsCollection2).should.be.false();
265+
266+
const docRef = doc(db, 'some/doc').withConverter(postConverter);
267+
const docRef2 = doc(db, 'some/doc').withConverter(postConverter2);
268+
refEqual(docRef, docRef2).should.be.false();
269+
});
270+
});
271+
272+
it('requires the correct converter for Partial usage', async function () {
273+
return withTestDb(async db => {
274+
const coll = collection(db, 'posts');
275+
const ref = doc(coll, 'post').withConverter(postConverter);
276+
const batch = writeBatch(db);
277+
278+
try {
279+
batch.set(ref, { title: 'olive' }, { merge: true });
280+
return Promise.reject(new Error('Did not throw an Error.'));
281+
} catch (error) {
282+
error.message.should.containEql('Unsupported field value: undefined');
283+
}
284+
return Promise.resolve();
285+
});
286+
});
287+
288+
it('supports primitive types with valid converter', function () {
289+
const primitiveConverter = {
290+
toFirestore(value) {
291+
return { value };
292+
},
293+
fromFirestore(snapshot) {
294+
const data = snapshot.data();
295+
return data.value;
296+
},
297+
};
298+
299+
const arrayConverter = {
300+
toFirestore(value) {
301+
return { values: value };
302+
},
303+
fromFirestore(snapshot) {
304+
const data = snapshot.data();
305+
return data.values;
306+
},
307+
};
308+
309+
return withTestCollection(async coll => {
310+
const ref = doc(coll, 'number').withConverter(primitiveConverter);
311+
await setDoc(ref, 3);
312+
const result = await getDoc(ref);
313+
result.data().should.equal(3);
314+
315+
const ref2 = doc(coll, 'array').withConverter(arrayConverter);
316+
await setDoc(ref2, [1, 2, 3]);
317+
const result2 = await getDoc(ref2);
318+
result2.data().should.deepEqual([1, 2, 3]);
319+
});
320+
});
321+
322+
it('supports partials with merge', async function () {
323+
return withTestCollection(async coll => {
324+
const ref = doc(coll, 'post').withConverter(postConverterMerge);
325+
await setDoc(ref, new Post('walnut', 'author'));
326+
await setDoc(ref, { title: 'olive', id: increment(2) }, { merge: true });
327+
const postDoc = await getDoc(ref);
328+
postDoc.get('title').should.equal('olive');
329+
postDoc.get('author').should.equal('author');
330+
});
331+
});
332+
333+
it('supports partials with mergeFields', async function () {
334+
return withTestCollection(async coll => {
335+
const ref = doc(coll, 'post').withConverter(postConverterMerge);
336+
await setDoc(ref, new Post('walnut', 'author'));
337+
await setDoc(ref, { title: 'olive' }, { mergeFields: ['title'] });
338+
const postDoc = await getDoc(ref);
339+
postDoc.get('title').should.equal('olive');
340+
postDoc.get('author').should.equal('author');
341+
});
342+
});
343+
});

packages/firestore/lib/FirestoreCollectionReference.js

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,22 @@
1515
*
1616
*/
1717

18-
import { generateFirestoreId, isObject } from '@react-native-firebase/app/lib/common';
18+
import {
19+
generateFirestoreId,
20+
isObject,
21+
isNull,
22+
isUndefined,
23+
} from '@react-native-firebase/app/lib/common';
1924
import FirestoreDocumentReference, {
2025
provideCollectionReferenceClass,
2126
} from './FirestoreDocumentReference';
2227
import FirestoreQuery from './FirestoreQuery';
2328
import FirestoreQueryModifiers from './FirestoreQueryModifiers';
29+
import { validateWithConverter } from './utils';
2430

2531
export default class FirestoreCollectionReference extends FirestoreQuery {
26-
constructor(firestore, collectionPath) {
27-
super(firestore, collectionPath, new FirestoreQueryModifiers());
32+
constructor(firestore, collectionPath, converter) {
33+
super(firestore, collectionPath, new FirestoreQueryModifiers(), undefined, converter);
2834
}
2935

3036
get id() {
@@ -62,7 +68,21 @@ export default class FirestoreCollectionReference extends FirestoreQuery {
6268
);
6369
}
6470

65-
return new FirestoreDocumentReference(this._firestore, path);
71+
return new FirestoreDocumentReference(this._firestore, path, this._converter);
72+
}
73+
74+
withConverter(converter) {
75+
if (isUndefined(converter) || isNull(converter)) {
76+
return new FirestoreCollectionReference(this._firestore, this._collectionPath, null);
77+
}
78+
79+
try {
80+
validateWithConverter(converter);
81+
} catch (e) {
82+
throw new Error(`firebase.firestore().collection().withConverter() ${e.message}`);
83+
}
84+
85+
return new FirestoreCollectionReference(this._firestore, this._collectionPath, converter);
6686
}
6787
}
6888

0 commit comments

Comments
 (0)