diff --git a/packages/firestore/__tests__/firestore.test.ts b/packages/firestore/__tests__/firestore.test.ts index b408478d35..cf361a035d 100644 --- a/packages/firestore/__tests__/firestore.test.ts +++ b/packages/firestore/__tests__/firestore.test.ts @@ -946,6 +946,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => getCountFromServer(query), + // @ts-expect-error Combines modular and namespace imports () => query.count(), 'count', ); @@ -958,6 +959,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => getCountFromServer(query), + // @ts-expect-error Combines modular and namespace API () => query.countFromServer(), 'countFromServer', ); @@ -970,6 +972,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => endAt('foo'), + // @ts-expect-error Combines modular and namespace API () => query.endAt('foo'), 'endAt', ); @@ -982,6 +985,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => endBefore('foo'), + // @ts-expect-error Combines modular and namespace API () => query.endBefore('foo'), 'endBefore', ); @@ -994,6 +998,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => getDocs(query), + // @ts-expect-error Combines modular and namespace API () => query.get(), 'get', ); @@ -1007,6 +1012,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( // no equivalent method () => {}, + // @ts-expect-error Combines modular and namespace API () => query.isEqual(query), 'isEqual', ); @@ -1019,6 +1025,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => limit(9), + // @ts-expect-error Combines modular and namespace API () => query.limit(9), 'limit', ); @@ -1031,6 +1038,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => limitToLast(9), + // @ts-expect-error Combines modular and namespace API () => query.limitToLast(9), 'limitToLast', ); @@ -1043,6 +1051,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => onSnapshot(query, () => {}), + // @ts-expect-error Combines modular and namespace API () => query.onSnapshot(() => {}), 'onSnapshot', ); @@ -1055,6 +1064,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => orderBy('foo', 'asc'), + // @ts-expect-error Combines modular and namespace API () => query.orderBy('foo', 'asc'), 'orderBy', ); @@ -1067,6 +1077,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => startAfter('foo'), + // @ts-expect-error Combines modular and namespace API () => query.startAfter('foo'), 'startAfter', ); @@ -1079,6 +1090,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => startAt('foo'), + // @ts-expect-error Combines modular and namespace API () => query.startAt('foo'), 'startAt', ); @@ -1091,6 +1103,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => where('foo', '==', 'bar'), + // @ts-expect-error Combines modular and namespace API () => query.where('foo', '==', 'bar'), 'where', ); @@ -1103,6 +1116,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => addDoc(query, { foo: 'bar' }), + // @ts-expect-error Combines modular and namespace API () => query.add({ foo: 'bar' }), 'add', ); @@ -1115,6 +1129,7 @@ describe('Firestore', function () { collectionRefV9Deprecation( () => doc(query, 'bar'), + // @ts-expect-error Combines modular and namespace API () => query.doc('foo'), 'doc', ); @@ -1140,6 +1155,7 @@ describe('Firestore', function () { const docRef = firestore.doc('some/foo'); docRefV9Deprecation( + // @ts-expect-error Combines modular and namespace imports () => deleteDoc(docRef), () => docRef.delete(), 'delete', @@ -1152,6 +1168,7 @@ describe('Firestore', function () { const docRef = firestore.doc('some/foo'); docRefV9Deprecation( + // @ts-expect-error Combines modular and namespace imports () => getDoc(docRef), () => docRef.get(), 'get', @@ -1177,6 +1194,7 @@ describe('Firestore', function () { const docRef = firestore.doc('some/foo'); docRefV9Deprecation( + // @ts-expect-error Combines modular and namespace imports () => onSnapshot(docRef, () => {}), () => docRef.onSnapshot(() => {}), 'onSnapshot', @@ -1189,6 +1207,7 @@ describe('Firestore', function () { const docRef = firestore.doc('some/foo'); docRefV9Deprecation( + // @ts-expect-error Combines modular and namespace imports () => setDoc(docRef, { foo: 'bar' }), () => docRef.set({ foo: 'bar' }), 'set', @@ -1201,6 +1220,7 @@ describe('Firestore', function () { const docRef = firestore.doc('some/foo'); docRefV9Deprecation( + // @ts-expect-error Combines modular and namespace imports () => updateDoc(docRef, { foo: 'bar' }), () => docRef.update({ foo: 'bar' }), 'update', diff --git a/packages/firestore/e2e/withConverter.e2e.js b/packages/firestore/e2e/withConverter.e2e.js new file mode 100644 index 0000000000..2084c00f9c --- /dev/null +++ b/packages/firestore/e2e/withConverter.e2e.js @@ -0,0 +1,595 @@ +/* + * Copyright (c) 2021-present Invertase Limited & Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this library except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +const COLLECTION = 'firestore'; +const { wipe } = require('./helpers'); + +const { + getFirestore, + doc, + collection, + refEqual, + addDoc, + setDoc, + getDoc, + query, + where, + getDocs, + writeBatch, + increment, +} = firestoreModular; + +// Used for testing the FirestoreDataConverter. +class Post { + constructor(title, author, id = 1) { + this.title = title; + this.author = author; + this.id = id; + } + byline() { + return this.title + ', by ' + this.author; + } +} + +const postConverter = { + toFirestore(post) { + return { title: post.title, author: post.author }; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return new Post(data.title, data.author); + }, +}; + +const postConverterMerge = { + toFirestore(post, options) { + if ( + options && + ((options && options.merge === true) || + (options && Array.isArray(options.mergeFields) && options.mergeFields.length > 0)) + ) { + post.should.not.be.an.instanceof(Post); + } else { + post.should.be.an.instanceof(Post); + } + const result = {}; + if (post.title) { + result.title = post.title; + } + if (post.author) { + result.author = post.author; + } + return result; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return new Post(data.title, data.author); + }, +}; + +function withTestDb(fn) { + return fn(getFirestore()); +} + +function withTestCollection(fn) { + return withTestDb(db => fn(collection(db, COLLECTION))); +} +function withTestDoc(fn) { + return withTestDb(db => fn(doc(db, `${COLLECTION}/doc`))); +} + +function withTestCollectionAndInitialData(data, fn) { + return withTestDb(async db => { + const coll = collection(db, COLLECTION); + for (const element of data) { + const ref = doc(coll); + await setDoc(ref, element); + } + return fn(coll); + }); +} + +describe('firestore.Transaction', function () { + describe('v8 compatibility', function () { + beforeEach(async function beforeEachTest() { + // @ts-ignore + globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = true; + }); + + afterEach(async function afterEachTest() { + // @ts-ignore + globalThis.RNFB_SILENCE_MODULAR_DEPRECATION_WARNINGS = false; + }); + + before(function () { + return wipe(); + }); + + it('for collection references', function () { + const firestore = firebase.firestore(); + const coll1a = firestore.collection('a'); + const coll1b = firestore.doc('a/b').parent; + const coll2 = firestore.collection('c'); + + coll1a.isEqual(coll1b).should.be.true(); + coll1a.isEqual(coll2).should.be.false(); + + const coll1c = firestore.collection('a').withConverter({ + toFirestore: data => data, + fromFirestore: snap => snap.data(), + }); + coll1a.isEqual(coll1c).should.be.false(); + + try { + coll1a.isEqual(firestore.doc('a/b')); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a Query instance.'); + return Promise.resolve(); + } + }); + + it('for document references', function () { + const firestore = firebase.firestore(); + const doc1a = firestore.doc('a/b'); + const doc1b = firestore.collection('a').doc('b'); + const doc2 = firestore.doc('a/c'); + + doc1a.isEqual(doc1b).should.be.true(); + doc1a.isEqual(doc2).should.be.false(); + + try { + const doc1c = firestore.collection('a').withConverter({ + toFirestore: data => data, + fromFirestore: snap => snap.data(), + }); + doc1a.isEqual(doc1c); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a DocumentReference instance.'); + } + + try { + doc1a.isEqual(firestore.collection('a')); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a DocumentReference instance.'); + } + return Promise.resolve(); + }); + + it('for DocumentReference.withConverter()', async function () { + const firestore = firebase.firestore(); + let docRef = firestore.doc(`${COLLECTION}/doc`); + docRef = docRef.withConverter(postConverter); + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + post.should.not.be.undefined(); + post.byline().should.equal('post, by author'); + }); + + it('for DocumentReference.withConverter(null) applies default converter', function () { + const firestore = firebase.firestore(); + const coll = firestore + .collection(COLLECTION) + .withConverter(postConverter) + .withConverter(null); + try { + return coll + .doc('post1') + .set(10) + .then(() => Promise.reject(new Error('Did not throw an Error.'))); + } catch (error) { + error.message.should.containEql( + `firebase.firestore().doc().set(*) 'data' must be an object.`, + ); + return Promise.resolve(); + } + }); + + it('for CollectionReference.withConverter()', async function () { + const firestore = firebase.firestore(); + let coll = firestore.collection(COLLECTION); + coll = coll.withConverter(postConverter); + const docRef = await coll.add(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + post.should.not.be.undefined(); + post.byline().should.equal('post, by author'); + }); + + it('for CollectionReference.withConverter(null) applies default converter', function () { + const firestore = firebase.firestore(); + let docRef = firestore.doc(`${COLLECTION}/doc`); + try { + docRef = docRef.withConverter(postConverter).withConverter(null); + return docRef.set(10).then(() => Promise.reject(new Error('Did not throw an Error.'))); + } catch (error) { + error.message.should.containEql( + `firebase.firestore().doc().set(*) 'data' must be an object.`, + ); + return Promise.resolve(); + } + }); + + it('for Query.withConverter()', async function () { + const firestore = firebase.firestore(); + const collRef = firestore.collection(COLLECTION); + await collRef.add({ title: 'post', author: 'author' }); + let query1 = collRef.where('title', '==', 'post'); + query1 = query1.withConverter(postConverter); + const result = await query1.get(); + result.docs[0].data().should.be.an.instanceOf(Post); + result.docs[0].data().byline().should.equal('post, by author'); + }); + + it('for Query.withConverter(null) applies default converter', async function () { + const firestore = firebase.firestore(); + const collRef = firestore.collection(COLLECTION); + await collRef.add({ title: 'post', author: 'author' }); + let query1 = collRef.where('title', '==', 'post'); + query1 = query1.withConverter(postConverter).withConverter(null); + const result = await query1.get(); + result.docs[0].should.not.be.an.instanceOf(Post); + }); + + it('keeps the converter when calling parent() with a DocumentReference', function () { + const db = firebase.firestore(); + const coll = db.doc('root/doc').withConverter(postConverter); + const typedColl = coll.parent; + typedColl.isEqual(db.collection('root').withConverter(postConverter)).should.be.true(); + }); + + it('drops the converter when calling parent() with a CollectionReference', function () { + const db = firebase.firestore(); + const coll = db.collection('root/doc/parent').withConverter(postConverter); + const untypedDoc = coll.parent; + untypedDoc.isEqual(db.doc('root/doc')).should.be.true(); + }); + + it('checks converter when comparing with isEqual()', function () { + const db = firebase.firestore(); + const postConverter2 = { ...postConverter }; + + const postsCollection = db.collection('users/user1/posts').withConverter(postConverter); + const postsCollection2 = db.collection('users/user1/posts').withConverter(postConverter2); + postsCollection.isEqual(postsCollection2).should.be.false(); + + const docRef = db.doc('some/doc').withConverter(postConverter); + const docRef2 = db.doc('some/doc').withConverter(postConverter2); + docRef.isEqual(docRef2).should.be.false(); + }); + + it('requires the correct converter for Partial usage', async function () { + const db = firebase.firestore(); + db._settings.ignoreUndefinedProperties = false; + const coll = db.collection('posts'); + const ref = coll.doc('post').withConverter(postConverter); + const batch = db.batch(); + + try { + batch.set(ref, { title: 'olive' }, { merge: true }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('Unsupported field value: undefined'); + } + db._settings.ignoreUndefinedProperties = true; + return Promise.resolve(); + }); + + it('supports primitive types with valid converter', async function () { + const firestore = firebase.firestore(); + const primitiveConverter = { + toFirestore(value) { + return { value }; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return data.value; + }, + }; + + const arrayConverter = { + toFirestore(value) { + return { values: value }; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return data.values; + }, + }; + + const coll = firestore.collection(COLLECTION); + const ref = coll.doc('number').withConverter(primitiveConverter); + await ref.set(3); + const result = await ref.get(); + result.data().should.equal(3); + + const ref2 = coll.doc('array').withConverter(arrayConverter); + await ref2.set([1, 2, 3]); + const result2 = await ref2.get(); + result2.data().should.deepEqual([1, 2, 3]); + }); + + it('supports partials with merge', async function () { + const firestore = firebase.firestore(); + const coll = firestore.collection(COLLECTION); + const ref = coll.doc('post').withConverter(postConverterMerge); + await ref.set(new Post('walnut', 'author')); + await ref.set( + { title: 'olive', id: firebase.firestore.FieldValue.increment(2) }, + { merge: true }, + ); + const postDoc = await ref.get(); + postDoc.get('title').should.equal('olive'); + postDoc.get('author').should.equal('author'); + }); + + it('supports partials with mergeFields', async function () { + const firestore = firebase.firestore(); + const coll = firestore.collection(COLLECTION); + const ref = coll.doc('post').withConverter(postConverterMerge); + await ref.set(new Post('walnut', 'author')); + await ref.set({ title: 'olive' }, { mergeFields: ['title'] }); + const postDoc = await ref.get(); + postDoc.get('title').should.equal('olive'); + postDoc.get('author').should.equal('author'); + }); + }); + + describe('modular', function () { + before(function () { + return wipe(); + }); + + it('for collection references', function () { + return withTestDb(firestore => { + const coll1a = collection(firestore, 'a'); + const coll1b = doc(firestore, 'a/b').parent; + const coll2 = collection(firestore, 'c'); + + refEqual(coll1a, coll1b).should.be.true(); + refEqual(coll1a, coll2).should.be.false(); + + const coll1c = collection(firestore, 'a').withConverter({ + toFirestore: data => data, + fromFirestore: snap => snap.data(), + }); + refEqual(coll1a, coll1c).should.be.false(); + + try { + refEqual(coll1a, doc(firestore, 'a/b')); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a Query instance.'); + return Promise.resolve(); + } + }); + }); + + it('for document references', function () { + return withTestDb(firestore => { + const doc1a = doc(firestore, 'a/b'); + const doc1b = doc(collection(firestore, 'a'), 'b'); + const doc2 = doc(firestore, 'a/c'); + + refEqual(doc1a, doc1b).should.be.true(); + refEqual(doc1a, doc2).should.be.false(); + + try { + const doc1c = collection(firestore, 'a').withConverter({ + toFirestore: data => data, + fromFirestore: snap => snap.data(), + }); + refEqual(doc1a, doc1c); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a DocumentReference instance.'); + } + + try { + refEqual(doc1a, collection(firestore, 'a')); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('expected a DocumentReference instance.'); + } + return Promise.resolve(); + }); + }); + + it('for DocumentReference.withConverter()', function () { + return withTestDoc(async docRef => { + docRef = docRef.withConverter(postConverter); + await setDoc(docRef, new Post('post', 'author')); + const postData = await getDoc(docRef); + const post = postData.data(); + post.should.not.be.undefined(); + post.byline().should.equal('post, by author'); + }); + }); + + it('for DocumentReference.withConverter(null) applies default converter', function () { + return withTestCollection(async coll => { + coll = coll.withConverter(postConverter).withConverter(null); + try { + await setDoc(doc(coll, 'post1'), 10); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + `firebase.firestore().doc().set(*) 'data' must be an object.`, + ); + return Promise.resolve(); + } + }); + }); + + it('for CollectionReference.withConverter()', function () { + return withTestCollection(async coll => { + coll = coll.withConverter(postConverter); + const docRef = await addDoc(coll, new Post('post', 'author')); + const postData = await getDoc(docRef); + const post = postData.data(); + post.should.not.be.undefined(); + post.byline().should.equal('post, by author'); + }); + }); + + it('for CollectionReference.withConverter(null) applies default converter', function () { + return withTestDoc(async doc => { + try { + doc = doc.withConverter(postConverter).withConverter(null); + await setDoc(doc, 10); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql( + `firebase.firestore().doc().set(*) 'data' must be an object.`, + ); + return Promise.resolve(); + } + }); + }); + + it('for Query.withConverter()', function () { + return withTestCollectionAndInitialData( + [{ title: 'post', author: 'author' }], + async collRef => { + let query1 = query(collRef, where('title', '==', 'post')); + query1 = query1.withConverter(postConverter); + const result = await getDocs(query1); + result.docs[0].data().should.be.an.instanceOf(Post); + result.docs[0].data().byline().should.equal('post, by author'); + }, + ); + }); + + it('for Query.withConverter(null) applies default converter', function () { + return withTestCollectionAndInitialData( + [{ title: 'post', author: 'author' }], + async collRef => { + let query1 = query(collRef, where('title', '==', 'post')); + query1 = query1.withConverter(postConverter).withConverter(null); + const result = await getDocs(query1); + result.docs[0].should.not.be.an.instanceOf(Post); + }, + ); + }); + + it('keeps the converter when calling parent() with a DocumentReference', function () { + return withTestDb(async db => { + const coll = doc(db, 'root/doc').withConverter(postConverter); + const typedColl = coll.parent; + refEqual(typedColl, collection(db, 'root').withConverter(postConverter)).should.be.true(); + }); + }); + + it('drops the converter when calling parent() with a CollectionReference', function () { + return withTestDb(async db => { + const coll = collection(db, 'root/doc/parent').withConverter(postConverter); + const untypedDoc = coll.parent; + refEqual(untypedDoc, doc(db, 'root/doc')).should.be.true(); + }); + }); + + it('checks converter when comparing with isEqual()', function () { + return withTestDb(async db => { + const postConverter2 = { ...postConverter }; + + const postsCollection = collection(db, 'users/user1/posts').withConverter(postConverter); + const postsCollection2 = collection(db, 'users/user1/posts').withConverter(postConverter2); + refEqual(postsCollection, postsCollection2).should.be.false(); + + const docRef = doc(db, 'some/doc').withConverter(postConverter); + const docRef2 = doc(db, 'some/doc').withConverter(postConverter2); + refEqual(docRef, docRef2).should.be.false(); + }); + }); + + it('requires the correct converter for Partial usage', async function () { + return withTestDb(async db => { + db._settings.ignoreUndefinedProperties = false; + const coll = collection(db, 'posts'); + const ref = doc(coll, 'post').withConverter(postConverter); + const batch = writeBatch(db); + + try { + batch.set(ref, { title: 'olive' }, { merge: true }); + return Promise.reject(new Error('Did not throw an Error.')); + } catch (error) { + error.message.should.containEql('Unsupported field value: undefined'); + } + db._settings.ignoreUndefinedProperties = true; + return Promise.resolve(); + }); + }); + + it('supports primitive types with valid converter', function () { + const primitiveConverter = { + toFirestore(value) { + return { value }; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return data.value; + }, + }; + + const arrayConverter = { + toFirestore(value) { + return { values: value }; + }, + fromFirestore(snapshot) { + const data = snapshot.data(); + return data.values; + }, + }; + + return withTestCollection(async coll => { + const ref = doc(coll, 'number').withConverter(primitiveConverter); + await setDoc(ref, 3); + const result = await getDoc(ref); + result.data().should.equal(3); + + const ref2 = doc(coll, 'array').withConverter(arrayConverter); + await setDoc(ref2, [1, 2, 3]); + const result2 = await getDoc(ref2); + result2.data().should.deepEqual([1, 2, 3]); + }); + }); + + it('supports partials with merge', async function () { + return withTestCollection(async coll => { + const ref = doc(coll, 'post').withConverter(postConverterMerge); + await setDoc(ref, new Post('walnut', 'author')); + await setDoc(ref, { title: 'olive', id: increment(2) }, { merge: true }); + const postDoc = await getDoc(ref); + postDoc.get('title').should.equal('olive'); + postDoc.get('author').should.equal('author'); + }); + }); + + it('supports partials with mergeFields', async function () { + return withTestCollection(async coll => { + const ref = doc(coll, 'post').withConverter(postConverterMerge); + await setDoc(ref, new Post('walnut', 'author')); + await setDoc(ref, { title: 'olive' }, { mergeFields: ['title'] }); + const postDoc = await getDoc(ref); + postDoc.get('title').should.equal('olive'); + postDoc.get('author').should.equal('author'); + }); + }); + }); +}); diff --git a/packages/firestore/lib/FirestoreCollectionReference.js b/packages/firestore/lib/FirestoreCollectionReference.js index 2dac9fa841..bb0a9fbba0 100644 --- a/packages/firestore/lib/FirestoreCollectionReference.js +++ b/packages/firestore/lib/FirestoreCollectionReference.js @@ -15,16 +15,22 @@ * */ -import { generateFirestoreId, isObject } from '@react-native-firebase/app/lib/common'; +import { + generateFirestoreId, + isObject, + isNull, + isUndefined, +} from '@react-native-firebase/app/lib/common'; import FirestoreDocumentReference, { provideCollectionReferenceClass, } from './FirestoreDocumentReference'; import FirestoreQuery from './FirestoreQuery'; import FirestoreQueryModifiers from './FirestoreQueryModifiers'; +import { validateWithConverter } from './utils'; export default class FirestoreCollectionReference extends FirestoreQuery { - constructor(firestore, collectionPath) { - super(firestore, collectionPath, new FirestoreQueryModifiers()); + constructor(firestore, collectionPath, converter) { + super(firestore, collectionPath, new FirestoreQueryModifiers(), undefined, converter); } get id() { @@ -62,7 +68,21 @@ export default class FirestoreCollectionReference extends FirestoreQuery { ); } - return new FirestoreDocumentReference(this._firestore, path); + return new FirestoreDocumentReference(this._firestore, path, this._converter); + } + + withConverter(converter) { + if (isUndefined(converter) || isNull(converter)) { + return new FirestoreCollectionReference(this._firestore, this._collectionPath, null); + } + + try { + validateWithConverter(converter); + } catch (e) { + throw new Error(`firebase.firestore().collection().withConverter() ${e.message}`); + } + + return new FirestoreCollectionReference(this._firestore, this._collectionPath, converter); } } diff --git a/packages/firestore/lib/FirestoreDocumentChange.js b/packages/firestore/lib/FirestoreDocumentChange.js index 9aa66833b6..27dd6aec1a 100644 --- a/packages/firestore/lib/FirestoreDocumentChange.js +++ b/packages/firestore/lib/FirestoreDocumentChange.js @@ -24,15 +24,16 @@ const TYPE_MAP = { }; export default class FirestoreDocumentChange { - constructor(firestore, nativeData) { + constructor(firestore, nativeData, converter) { this._firestore = firestore; this._nativeData = nativeData; this._isMetadataChange = nativeData.isMetadataChange; + this._converter = converter; } get doc() { return createDeprecationProxy( - new FirestoreDocumentSnapshot(this._firestore, this._nativeData.doc), + new FirestoreDocumentSnapshot(this._firestore, this._nativeData.doc, this._converter), ); } diff --git a/packages/firestore/lib/FirestoreDocumentReference.js b/packages/firestore/lib/FirestoreDocumentReference.js index 094239acb9..3e85719501 100644 --- a/packages/firestore/lib/FirestoreDocumentReference.js +++ b/packages/firestore/lib/FirestoreDocumentReference.js @@ -19,11 +19,18 @@ import { isObject, isString, isUndefined, + isNull, createDeprecationProxy, filterModularArgument, } from '@react-native-firebase/app/lib/common'; import NativeError from '@react-native-firebase/app/lib/internal/NativeFirebaseError'; -import { parseSetOptions, parseSnapshotArgs, parseUpdateArgs } from './utils'; +import { + parseSetOptions, + parseSnapshotArgs, + parseUpdateArgs, + validateWithConverter, + applyFirestoreDataConverter, +} from './utils'; import { buildNativeMap, provideDocumentReferenceClass } from './utils/serialize'; // To avoid React Native require cycle warnings @@ -40,22 +47,27 @@ export function provideDocumentSnapshotClass(documentSnapshot) { let _id = 0; export default class FirestoreDocumentReference { - constructor(firestore, documentPath) { + constructor(firestore, documentPath, converter) { this._firestore = firestore; this._documentPath = documentPath; + this._converter = converter; } get firestore() { return this._firestore; } + get converter() { + return this._converter; + } + get id() { return this._documentPath.id; } get parent() { const parentPath = this._documentPath.parent(); - return new FirestoreCollectionReference(this._firestore, parentPath); + return new FirestoreCollectionReference(this._firestore, parentPath, this._converter); } get path() { @@ -109,7 +121,11 @@ export default class FirestoreDocumentReference { return this._firestore.native .documentGet(this.path, options) - .then(data => createDeprecationProxy(new FirestoreDocumentSnapshot(this._firestore, data))); + .then(data => + createDeprecationProxy( + new FirestoreDocumentSnapshot(this._firestore, data, this._converter), + ), + ); } isEqual(other) { @@ -122,7 +138,8 @@ export default class FirestoreDocumentReference { return !( this.path !== other.path || this.firestore.app.name !== other.firestore.app.name || - this.firestore.app.options.projectId !== other.firestore.app.options.projectId + this.firestore.app.options.projectId !== other.firestore.app.options.projectId || + this.converter !== other.converter ); } @@ -161,7 +178,7 @@ export default class FirestoreDocumentReference { handleError(NativeError.fromEvent(event.body.error, 'firestore')); } else { const documentSnapshot = createDeprecationProxy( - new FirestoreDocumentSnapshot(this._firestore, event.body.snapshot), + new FirestoreDocumentSnapshot(this._firestore, event.body.snapshot, this._converter), ); handleSuccess(documentSnapshot); } @@ -179,10 +196,6 @@ export default class FirestoreDocumentReference { } set(data, options) { - if (!isObject(data)) { - throw new Error("firebase.firestore().doc().set(*) 'data' must be an object."); - } - let setOptions; try { setOptions = parseSetOptions(options); @@ -190,9 +203,22 @@ export default class FirestoreDocumentReference { throw new Error(`firebase.firestore().doc().set(_, *) ${e.message}.`); } + let converted = data; + try { + converted = applyFirestoreDataConverter(data, this._converter, setOptions); + } catch (e) { + throw new Error( + `firebase.firestore().doc().set(*) 'withConverter.toFirestore' threw an error: ${e.message}.`, + ); + } + + if (!isObject(converted)) { + throw new Error("firebase.firestore().doc().set(*) 'data' must be an object."); + } + return this._firestore.native.documentSet( this.path, - buildNativeMap(data, this._firestore._settings.ignoreUndefinedProperties), + buildNativeMap(converted, this._firestore._settings.ignoreUndefinedProperties), setOptions, ); } @@ -217,6 +243,20 @@ export default class FirestoreDocumentReference { buildNativeMap(data, this._firestore._settings.ignoreUndefinedProperties), ); } + + withConverter(converter) { + if (isUndefined(converter) || isNull(converter)) { + return new FirestoreDocumentReference(this._firestore, this._documentPath, null); + } + + try { + validateWithConverter(converter); + } catch (e) { + throw new Error(`firebase.firestore().doc().withConverter() ${e.message}`); + } + + return new FirestoreDocumentReference(this._firestore, this._documentPath, converter); + } } provideDocumentReferenceClass(FirestoreDocumentReference); // serialize diff --git a/packages/firestore/lib/FirestoreDocumentSnapshot.js b/packages/firestore/lib/FirestoreDocumentSnapshot.js index 6dfa0379b9..fb87ed9e9d 100644 --- a/packages/firestore/lib/FirestoreDocumentSnapshot.js +++ b/packages/firestore/lib/FirestoreDocumentSnapshot.js @@ -26,11 +26,13 @@ import { extractFieldPathData } from './utils'; import { parseNativeMap } from './utils/serialize'; export default class FirestoreDocumentSnapshot { - constructor(firestore, nativeData) { + constructor(firestore, nativeData, converter) { + this._nativeData = nativeData; this._data = parseNativeMap(firestore, nativeData.data); this._metadata = new FirestoreSnapshotMetadata(nativeData.metadata); this._ref = new FirestoreDocumentReference(firestore, FirestorePath.fromName(nativeData.path)); this._exists = nativeData.exists; + this._converter = converter; } get id() { @@ -72,6 +74,17 @@ export default class FirestoreDocumentSnapshot { // } // } + if (this._converter && this._converter.fromFirestore) { + try { + return this._converter.fromFirestore( + new FirestoreDocumentSnapshot(this._firestore, this._nativeData, null), + ); + } catch (e) { + throw new Error( + `firebase.firestore() DocumentSnapshot.data(*) 'withConverter.fromFirestore' threw an error: ${e.message}.`, + ); + } + } return this._data; } diff --git a/packages/firestore/lib/FirestoreQuery.js b/packages/firestore/lib/FirestoreQuery.js index dbade4d90b..525f87e140 100644 --- a/packages/firestore/lib/FirestoreQuery.js +++ b/packages/firestore/lib/FirestoreQuery.js @@ -16,36 +16,41 @@ */ import { + createDeprecationProxy, + filterModularArgument, isArray, isNull, isObject, isString, isUndefined, - filterModularArgument, - createDeprecationProxy, } from '@react-native-firebase/app/lib/common'; import NativeError from '@react-native-firebase/app/lib/internal/NativeFirebaseError'; import { FirestoreAggregateQuery } from './FirestoreAggregate'; import FirestoreDocumentSnapshot from './FirestoreDocumentSnapshot'; import FirestoreFieldPath, { fromDotSeparatedString } from './FirestoreFieldPath'; -import FirestoreQuerySnapshot from './FirestoreQuerySnapshot'; -import { parseSnapshotArgs } from './utils'; import { _Filter, generateFilters } from './FirestoreFilter'; +import FirestoreQuerySnapshot from './FirestoreQuerySnapshot'; +import { parseSnapshotArgs, validateWithConverter } from './utils'; let _id = 0; export default class FirestoreQuery { - constructor(firestore, collectionPath, modifiers, queryName) { + constructor(firestore, collectionPath, modifiers, queryName, converter) { this._firestore = firestore; this._collectionPath = collectionPath; this._modifiers = modifiers; this._queryName = queryName; + this._converter = converter; } get firestore() { return this._firestore; } + get converter() { + return this._converter; + } + _handleQueryCursor(cursor, docOrField, fields) { const modifiers = this._modifiers._copy(); @@ -152,6 +157,7 @@ export default class FirestoreQuery { this._collectionPath, this._handleQueryCursor('endAt', docOrField, filterModularArgument(fields)), this._queryName, + this._converter, ), ); } @@ -163,6 +169,7 @@ export default class FirestoreQuery { this._collectionPath, this._handleQueryCursor('endBefore', docOrField, filterModularArgument(fields)), this._queryName, + this._converter, ), ); } @@ -196,7 +203,7 @@ export default class FirestoreQuery { this._modifiers.options, options, ) - .then(data => new FirestoreQuerySnapshot(this._firestore, this, data)); + .then(data => new FirestoreQuerySnapshot(this._firestore, this, data, this._converter)); } this._modifiers.validatelimitToLast(); @@ -210,7 +217,7 @@ export default class FirestoreQuery { this._modifiers.options, options, ) - .then(data => new FirestoreQuerySnapshot(this._firestore, this, data)); + .then(data => new FirestoreQuerySnapshot(this._firestore, this, data, this._converter)); } isEqual(other) { @@ -227,6 +234,7 @@ export default class FirestoreQuery { this._modifiers.filters.length !== other._modifiers.filters.length || this._modifiers.orders.length !== other._modifiers.orders.length || this._collectionPath.relativeName !== other._collectionPath.relativeName || + this._converter !== other._converter || Object.keys(this._modifiers.options).length !== Object.keys(other._modifiers.options).length ) { return false; @@ -255,7 +263,13 @@ export default class FirestoreQuery { const modifiers = this._modifiers._copy().limit(limit); return createDeprecationProxy( - new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName), + new FirestoreQuery( + this._firestore, + this._collectionPath, + modifiers, + this._queryName, + this._converter, + ), ); } @@ -269,7 +283,13 @@ export default class FirestoreQuery { const modifiers = this._modifiers._copy().limitToLast(limitToLast); return createDeprecationProxy( - new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName), + new FirestoreQuery( + this._firestore, + this._collectionPath, + modifiers, + this._queryName, + this._converter, + ), ); } @@ -313,6 +333,7 @@ export default class FirestoreQuery { this._firestore, this, event.body.snapshot, + this._converter, ); handleSuccess(querySnapshot); } @@ -395,7 +416,13 @@ export default class FirestoreQuery { } return createDeprecationProxy( - new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName), + new FirestoreQuery( + this._firestore, + this._collectionPath, + modifiers, + this._queryName, + this._converter, + ), ); } @@ -406,6 +433,7 @@ export default class FirestoreQuery { this._collectionPath, this._handleQueryCursor('startAfter', docOrField, filterModularArgument(fields)), this._queryName, + this._converter, ), ); } @@ -417,6 +445,7 @@ export default class FirestoreQuery { this._collectionPath, this._handleQueryCursor('startAt', docOrField, filterModularArgument(fields)), this._queryName, + this._converter, ), ); } @@ -502,7 +531,39 @@ export default class FirestoreQuery { } return createDeprecationProxy( - new FirestoreQuery(this._firestore, this._collectionPath, modifiers, this._queryName), + new FirestoreQuery( + this._firestore, + this._collectionPath, + modifiers, + this._queryName, + this._converter, + ), + ); + } + + withConverter(converter) { + if (isUndefined(converter) || isNull(converter)) { + return new FirestoreQuery( + this._firestore, + this._collectionPath, + this._modifiers, + this._queryName, + null, + ); + } + + try { + validateWithConverter(converter); + } catch (e) { + throw new Error(`firebase.firestore().collection().withConverter() ${e.message}`); + } + + return new FirestoreQuery( + this._firestore, + this._collectionPath, + this._modifiers, + this._queryName, + converter, ); } } diff --git a/packages/firestore/lib/FirestoreQuerySnapshot.js b/packages/firestore/lib/FirestoreQuerySnapshot.js index c5db85635c..a23ab07b04 100644 --- a/packages/firestore/lib/FirestoreQuerySnapshot.js +++ b/packages/firestore/lib/FirestoreQuerySnapshot.js @@ -16,24 +16,26 @@ */ import { + createDeprecationProxy, isBoolean, isFunction, isObject, isUndefined, - createDeprecationProxy, } from '@react-native-firebase/app/lib/common'; import FirestoreDocumentChange from './FirestoreDocumentChange'; import FirestoreDocumentSnapshot from './FirestoreDocumentSnapshot'; import FirestoreSnapshotMetadata from './FirestoreSnapshotMetadata'; export default class FirestoreQuerySnapshot { - constructor(firestore, query, nativeData) { + constructor(firestore, query, nativeData, converter) { this._query = query; this._source = nativeData.source; this._excludesMetadataChanges = nativeData.excludesMetadataChanges; - this._changes = nativeData.changes.map($ => new FirestoreDocumentChange(firestore, $)); + this._changes = nativeData.changes.map( + $ => new FirestoreDocumentChange(firestore, $, converter), + ); this._docs = nativeData.documents.map($ => - createDeprecationProxy(new FirestoreDocumentSnapshot(firestore, $)), + createDeprecationProxy(new FirestoreDocumentSnapshot(firestore, $, converter)), ); this._metadata = new FirestoreSnapshotMetadata(nativeData.metadata); } diff --git a/packages/firestore/lib/FirestoreTransaction.js b/packages/firestore/lib/FirestoreTransaction.js index f496ddb6b8..6091e486d7 100644 --- a/packages/firestore/lib/FirestoreTransaction.js +++ b/packages/firestore/lib/FirestoreTransaction.js @@ -18,7 +18,7 @@ import { isObject, createDeprecationProxy } from '@react-native-firebase/app/lib/common'; import FirestoreDocumentReference from './FirestoreDocumentReference'; import FirestoreDocumentSnapshot from './FirestoreDocumentSnapshot'; -import { parseSetOptions, parseUpdateArgs } from './utils'; +import { parseSetOptions, parseUpdateArgs, applyFirestoreDataConverter } from './utils'; import { buildNativeMap } from './utils/serialize'; export default class FirestoreTransaction { @@ -52,7 +52,9 @@ export default class FirestoreTransaction { this._calledGetCount++; return this._firestore.native .transactionGetDocument(this._meta.id, documentRef.path) - .then(data => createDeprecationProxy(new FirestoreDocumentSnapshot(this._firestore, data))); + .then(data => + createDeprecationProxy(new FirestoreDocumentSnapshot(this._firestore, data, null)), + ); } /** @@ -67,25 +69,34 @@ export default class FirestoreTransaction { ); } - if (!isObject(data)) { + let setOptions; + try { + setOptions = parseSetOptions(options); + } catch (e) { throw new Error( - "firebase.firestore().runTransaction() Transaction.set(_, *) 'data' must be an object..", + `firebase.firestore().runTransaction() Transaction.set(_, _, *) ${e.message}.`, ); } - let setOptions; + let converted = data; try { - setOptions = parseSetOptions(options); + converted = applyFirestoreDataConverter(data, documentRef._converter, setOptions); } catch (e) { throw new Error( - `firebase.firestore().runTransaction() Transaction.set(_, _, *) ${e.message}.`, + `firebase.firestore().runTransaction() Transaction.set(_, *) 'withConverter.toFirestore' threw an error: ${e.message}.`, + ); + } + + if (!isObject(converted)) { + throw new Error( + "firebase.firestore().runTransaction() Transaction.set(_, *) 'data' must be an object..", ); } this._commandBuffer.push({ type: 'SET', path: documentRef.path, - data: buildNativeMap(data, this._firestore._settings.ignoreUndefinedProperties), + data: buildNativeMap(converted, this._firestore._settings.ignoreUndefinedProperties), options: setOptions, }); diff --git a/packages/firestore/lib/FirestoreWriteBatch.js b/packages/firestore/lib/FirestoreWriteBatch.js index 257b05fed9..875099b3bf 100644 --- a/packages/firestore/lib/FirestoreWriteBatch.js +++ b/packages/firestore/lib/FirestoreWriteBatch.js @@ -17,7 +17,7 @@ import { isObject } from '@react-native-firebase/app/lib/common'; import FirestoreDocumentReference from './FirestoreDocumentReference'; -import { parseSetOptions, parseUpdateArgs } from './utils'; +import { parseSetOptions, parseUpdateArgs, applyFirestoreDataConverter } from './utils'; import { buildNativeMap } from './utils/serialize'; export default class FirestoreWriteBatch { @@ -80,21 +80,30 @@ export default class FirestoreWriteBatch { ); } - if (!isObject(data)) { - throw new Error("firebase.firestore.batch().set(_, *) 'data' must be an object."); - } - let setOptions; try { setOptions = parseSetOptions(options); } catch (e) { - throw new Error(`firebase.firestore().doc().set(_, *) ${e.message}.`); + throw new Error(`firebase.firestore().batch().set(_, *) ${e.message}.`); + } + + let converted = data; + try { + converted = applyFirestoreDataConverter(data, documentRef._converter, setOptions); + } catch (e) { + throw new Error( + `firebase.firestore().batch().set(_, *) 'withConverter.toFirestore' threw an error: ${e.message}.`, + ); + } + + if (!isObject(converted)) { + throw new Error("firebase.firestore.batch().set(_, *) 'data' must be an object."); } this._writes.push({ path: documentRef.path, type: 'SET', - data: buildNativeMap(data, this._firestore._settings.ignoreUndefinedProperties), + data: buildNativeMap(converted, this._firestore._settings.ignoreUndefinedProperties), options: setOptions, }); diff --git a/packages/firestore/lib/index.d.ts b/packages/firestore/lib/index.d.ts index 2fe76bc3c5..c3538f2189 100644 --- a/packages/firestore/lib/index.d.ts +++ b/packages/firestore/lib/index.d.ts @@ -194,6 +194,36 @@ export namespace FirebaseFirestoreTypes { * @param documentPath A slash-separated path to a document. */ doc(documentPath?: string): DocumentReference; + + /** + * Applies a custom data converter to this CollectionReference, allowing you + * to use your own custom model objects with Firestore. When you call add() + * on the returned CollectionReference instance, the provided converter will + * convert between Firestore data and your custom type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A CollectionReference that uses the provided converter. + */ + withConverter(converter: null): CollectionReference; + + /** + * Applies a custom data converter to this CollectionReference, allowing you + * to use your own custom model objects with Firestore. When you call add() + * on the returned CollectionReference instance, the provided converter will + * convert between Firestore data and your custom type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A CollectionReference that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): CollectionReference; } /** @@ -516,6 +546,37 @@ export namespace FirebaseFirestoreTypes { * @param moreFieldsAndValues Additional key value pairs. */ update(field: keyof T | FieldPath, value: any, ...moreFieldsAndValues: any[]): Promise; + + /** + * Applies a custom data converter to this DocumentReference, allowing you + * to use your own custom model objects with Firestore. When you call + * set(), get(), etc. on the returned DocumentReference instance, the + * provided converter will convert between Firestore data and your custom + * type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A DocumentReference that uses the provided converter. + */ + withConverter(converter: null): DocumentReference; + /** + * Applies a custom data converter to this DocumentReference, allowing you + * to use your own custom model objects with Firestore. When you call + * set(), get(), etc. on the returned DocumentReference instance, the + * provided converter will convert between Firestore data and your custom + * type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A DocumentReference that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): DocumentReference; } /** @@ -1424,6 +1485,35 @@ export namespace FirebaseFirestoreTypes { * @param filter The filter to apply to the query. */ where(filter: QueryFilterConstraint): Query; + + /** + * Applies a custom data converter to this Query, allowing you to use your + * own custom model objects with Firestore. When you call get() on the + * returned Query, the provided converter will convert between Firestore + * data and your custom type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A Query that uses the provided converter. + */ + withConverter(converter: null): Query; + /** + * Applies a custom data converter to this Query, allowing you to use your + * own custom model objects with Firestore. When you call get() on the + * returned Query, the provided converter will convert between Firestore + * data and your custom type U. + * + * Passing in `null` as the converter parameter removes the current + * converter. + * + * @param converter Converts objects to and from Firestore. Passing in + * `null` removes the current converter. + * @return A Query that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): Query; } /** @@ -2128,6 +2218,68 @@ export namespace FirebaseFirestoreTypes { setLogLevel(logLevel: 'debug' | 'error' | 'silent'): void; } + /** + * Converter used by `withConverter()` to transform user objects of type T + * into Firestore data. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * @example + * ```typescript + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): firebase.firestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * snapshot: firebase.firestore.QueryDocumentSnapshot, + * options: firebase.firestore.SnapshotOptions + * ): Post { + * const data = snapshot.data(options)!; + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await firebase.firestore() + * .collection('posts') + * .withConverter(postConverter) + * .doc().get(); + * const post = postSnap.data(); + * if (post !== undefined) { + * post.title; // string + * post.toString(); // Should be defined + * post.someNonExistentProperty; // TS error + * } + * ``` + */ + export interface FirestoreDataConverter { + /** + * Called by the Firestore SDK to convert a custom model object of type T + * into a plain JavaScript object (suitable for writing directly to the + * Firestore database). To use `set()` with `merge` and `mergeFields`, + * `toFirestore()` must be defined with `Partial`. + */ + toFirestore(modelObject: T): DocumentData; + toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + + /** + * Called by the Firestore SDK to convert Firestore data into an object of + * type T. You can access your data by calling: `snapshot.data(options)`. + * + * @param snapshot A QueryDocumentSnapshot containing your data and metadata. + * @param options The SnapshotOptions from the initial call to `data()`. + */ + fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): T; + } + /** * The Firebase Cloud Firestore service is available for the default app or a given app. * diff --git a/packages/firestore/lib/modular/index.d.ts b/packages/firestore/lib/modular/index.d.ts index 0dff797a08..f6ffce6378 100644 --- a/packages/firestore/lib/modular/index.d.ts +++ b/packages/firestore/lib/modular/index.d.ts @@ -3,14 +3,12 @@ import { FirebaseFirestoreTypes } from '../index'; import FirebaseApp = ReactNativeFirebase.FirebaseApp; import Firestore = FirebaseFirestoreTypes.Module; -import CollectionReference = FirebaseFirestoreTypes.CollectionReference; -import DocumentReference = FirebaseFirestoreTypes.DocumentReference; import DocumentData = FirebaseFirestoreTypes.DocumentData; -import Query = FirebaseFirestoreTypes.Query; import FieldValue = FirebaseFirestoreTypes.FieldValue; import FieldPath = FirebaseFirestoreTypes.FieldPath; import PersistentCacheIndexManager = FirebaseFirestoreTypes.PersistentCacheIndexManager; import AggregateQuerySnapshot = FirebaseFirestoreTypes.AggregateQuerySnapshot; +import SetOptions = FirebaseFirestoreTypes.SetOptions; /** Primitive types. */ export type Primitive = string | number | boolean | undefined | null; @@ -152,6 +150,626 @@ export declare function connectFirestoreEmulator( mockUserToken?: EmulatorMockTokenOptions | string; }, ): void; + +/** + * Converter used by `withConverter()` to transform user objects of type + * `AppModelType` into Firestore data of type `DbModelType`. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * In this context, an "AppModel" is a class that is used in an application to + * package together related information and functionality. Such a class could, + * for example, have properties with complex, nested data types, properties used + * for memoization, properties of types not supported by Firestore (such as + * `symbol` and `bigint`), and helper functions that perform compound + * operations. Such classes are not suitable and/or possible to store into a + * Firestore database. Instead, instances of such classes need to be converted + * to "plain old JavaScript objects" (POJOs) with exclusively primitive + * properties, potentially nested inside other POJOs or arrays of POJOs. In this + * context, this type is referred to as the "DbModel" and would be an object + * suitable for persisting into Firestore. For convenience, applications can + * implement `FirestoreDataConverter` and register the converter with Firestore + * objects, such as `DocumentReference` or `Query`, to automatically convert + * `AppModel` to `DbModel` when storing into Firestore, and convert `DbModel` + * to `AppModel` when retrieving from Firestore. + * + * @example + * + * Simple Example + * + * ```typescript + * const numberConverter = { + * toFirestore(value: WithFieldValue) { + * return { value }; + * }, + * fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions) { + * return snapshot.data(options).value as number; + * } + * }; + * + * async function simpleDemo(db: Firestore): Promise { + * const documentRef = doc(db, 'values/value123').withConverter(numberConverter); + * + * // converters are used with `setDoc`, `addDoc`, and `getDoc` + * await setDoc(documentRef, 42); + * const snapshot1 = await getDoc(documentRef); + * assertEqual(snapshot1.data(), 42); + * + * // converters are not used when writing data with `updateDoc` + * await updateDoc(documentRef, { value: 999 }); + * const snapshot2 = await getDoc(documentRef); + * assertEqual(snapshot2.data(), 999); + * } + * ``` + * + * Advanced Example + * + * ```typescript + * // The Post class is a model that is used by our application. + * // This class may have properties and methods that are specific + * // to our application execution, which do not need to be persisted + * // to Firestore. + * class Post { + * constructor( + * readonly title: string, + * readonly author: string, + * readonly lastUpdatedMillis: number + * ) {} + * toString(): string { + * return `${this.title} by ${this.author}`; + * } + * } + * + * // The PostDbModel represents how we want our posts to be stored + * // in Firestore. This DbModel has different properties (`ttl`, + * // `aut`, and `lut`) from the Post class we use in our application. + * interface PostDbModel { + * ttl: string; + * aut: { firstName: string; lastName: string }; + * lut: Timestamp; + * } + * + * // The `PostConverter` implements `FirestoreDataConverter` and specifies + * // how the Firestore SDK can convert `Post` objects to `PostDbModel` + * // objects and vice versa. + * class PostConverter implements FirestoreDataConverter { + * toFirestore(post: WithFieldValue): WithFieldValue { + * return { + * ttl: post.title, + * aut: this._autFromAuthor(post.author), + * lut: this._lutFromLastUpdatedMillis(post.lastUpdatedMillis) + * }; + * } + * + * fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): Post { + * const data = snapshot.data(options) as PostDbModel; + * const author = `${data.aut.firstName} ${data.aut.lastName}`; + * return new Post(data.ttl, author, data.lut.toMillis()); + * } + * + * _autFromAuthor( + * author: string | FieldValue + * ): { firstName: string; lastName: string } | FieldValue { + * if (typeof author !== 'string') { + * // `author` is a FieldValue, so just return it. + * return author; + * } + * const [firstName, lastName] = author.split(' '); + * return {firstName, lastName}; + * } + * + * _lutFromLastUpdatedMillis( + * lastUpdatedMillis: number | FieldValue + * ): Timestamp | FieldValue { + * if (typeof lastUpdatedMillis !== 'number') { + * // `lastUpdatedMillis` must be a FieldValue, so just return it. + * return lastUpdatedMillis; + * } + * return Timestamp.fromMillis(lastUpdatedMillis); + * } + * } + * + * async function advancedDemo(db: Firestore): Promise { + * // Create a `DocumentReference` with a `FirestoreDataConverter`. + * const documentRef = doc(db, 'posts/post123').withConverter(new PostConverter()); + * + * // The `data` argument specified to `setDoc()` is type checked by the + * // TypeScript compiler to be compatible with `Post`. Since the `data` + * // argument is typed as `WithFieldValue` rather than just `Post`, + * // this allows properties of the `data` argument to also be special + * // Firestore values that perform server-side mutations, such as + * // `arrayRemove()`, `deleteField()`, and `serverTimestamp()`. + * await setDoc(documentRef, { + * title: 'My Life', + * author: 'Foo Bar', + * lastUpdatedMillis: serverTimestamp() + * }); + * + * // The TypeScript compiler will fail to compile if the `data` argument to + * // `setDoc()` is _not_ compatible with `WithFieldValue`. This + * // type checking prevents the caller from specifying objects with incorrect + * // properties or property values. + * // @ts-expect-error "Argument of type { ttl: string; } is not assignable + * // to parameter of type WithFieldValue" + * await setDoc(documentRef, { ttl: 'The Title' }); + * + * // When retrieving a document with `getDoc()` the `DocumentSnapshot` + * // object's `data()` method returns a `Post`, rather than a generic object, + * // which would have been returned if the `DocumentReference` did _not_ have a + * // `FirestoreDataConverter` attached to it. + * const snapshot1: DocumentSnapshot = await getDoc(documentRef); + * const post1: Post = snapshot1.data()!; + * if (post1) { + * assertEqual(post1.title, 'My Life'); + * assertEqual(post1.author, 'Foo Bar'); + * } + * + * // The `data` argument specified to `updateDoc()` is type checked by the + * // TypeScript compiler to be compatible with `PostDbModel`. Note that + * // unlike `setDoc()`, whose `data` argument must be compatible with `Post`, + * // the `data` argument to `updateDoc()` must be compatible with + * // `PostDbModel`. Similar to `setDoc()`, since the `data` argument is typed + * // as `WithFieldValue` rather than just `PostDbModel`, this + * // allows properties of the `data` argument to also be those special + * // Firestore values, like `arrayRemove()`, `deleteField()`, and + * // `serverTimestamp()`. + * await updateDoc(documentRef, { + * 'aut.firstName': 'NewFirstName', + * lut: serverTimestamp() + * }); + * + * // The TypeScript compiler will fail to compile if the `data` argument to + * // `updateDoc()` is _not_ compatible with `WithFieldValue`. + * // This type checking prevents the caller from specifying objects with + * // incorrect properties or property values. + * // @ts-expect-error "Argument of type { title: string; } is not assignable + * // to parameter of type WithFieldValue" + * await updateDoc(documentRef, { title: 'New Title' }); + * const snapshot2: DocumentSnapshot = await getDoc(documentRef); + * const post2: Post = snapshot2.data()!; + * if (post2) { + * assertEqual(post2.title, 'My Life'); + * assertEqual(post2.author, 'NewFirstName Bar'); + * } + * } + * ``` + */ +export interface FirestoreDataConverter< + AppModelType, + DbModelType extends DocumentData = DocumentData, +> { + /** + * Called by the Firestore SDK to convert a custom model object of type + * `AppModelType` into a plain JavaScript object (suitable for writing + * directly to the Firestore database) of type `DbModelType`. To use `set()` + * with `merge` and `mergeFields`, `toFirestore()` must be defined with + * `PartialWithFieldValue`. + * + * The `WithFieldValue` type extends `T` to also allow FieldValues such as + * {@link (deleteField:1)} to be used as property values. + */ + toFirestore(modelObject: WithFieldValue): WithFieldValue; + + /** + * Called by the Firestore SDK to convert a custom model object of type + * `AppModelType` into a plain JavaScript object (suitable for writing + * directly to the Firestore database) of type `DbModelType`. Used with + * {@link (setDoc:1)}, {@link (WriteBatch.set:1)} and + * {@link (Transaction.set:1)} with `merge:true` or `mergeFields`. + * + * The `PartialWithFieldValue` type extends `Partial` to allow + * FieldValues such as {@link (arrayUnion:1)} to be used as property values. + * It also supports nested `Partial` by allowing nested fields to be + * omitted. + */ + toFirestore( + modelObject: PartialWithFieldValue, + options: SetOptions, + ): PartialWithFieldValue; + + /** + * Called by the Firestore SDK to convert Firestore data into an object of + * type `AppModelType`. You can access your data by calling: + * `snapshot.data(options)`. + * + * Generally, the data returned from `snapshot.data()` can be cast to + * `DbModelType`; however, this is not guaranteed because Firestore does not + * enforce a schema on the database. For example, writes from a previous + * version of the application or writes from another client that did not use a + * type converter could have written data with different properties and/or + * property types. The implementation will need to choose whether to + * gracefully recover from non-conforming data or throw an error. + * + * To override this method, see {@link (FirestoreDataConverter.fromFirestore:1)}. + * + * @param snapshot - A `QueryDocumentSnapshot` containing your data and metadata. + * @param options - The `SnapshotOptions` from the initial call to `data()`. + */ + fromFirestore( + snapshot: QueryDocumentSnapshot, + options?: SnapshotOptions, + ): AppModelType; +} + +/** + * A Query refers to a `Query` which you can read or listen to. You can also construct refined `Query` objects by + * adding filters and ordering. + */ +export interface Query< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> { + /** + * The Firestore instance the document is in. This is useful for performing transactions, for example. + */ + firestore: Firestore; + + /** + * If provided, the {@link FirestoreDataConverter} associated with this instance. + */ + converter: FirestoreDataConverter | null; + + /** + * Removes the current converter. + * + * @param converter - `null` removes the current converter. + * @returns A `Query` that does not use a + * converter. + */ + withConverter(converter: null): Query; + /** + * Applies a custom data converter to this query, allowing you to use your own + * custom model objects with Firestore. When you call {@link getDocs} with + * the returned query, the provided converter will convert between Firestore + * data of type `NewDbModelType` and your custom type `NewAppModelType`. + *z + * @param converter - Converts objects to and from Firestore. + * @returns A `Query` that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter, + ): Query; +} + +/** + * A `CollectionReference` object can be used for adding documents, getting document references, and querying for + * documents (using the methods inherited from `Query`). + */ +export interface CollectionReference< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> extends Query { + /** + * The collection's identifier. + */ + id: string; + + /** + * A reference to the containing `DocumentReference` if this is a subcollection. If this isn't a + * subcollection, the reference is null. + */ + parent: DocumentReference | null; + + /** + * A string representing the path of the referenced collection (relative to the root of the database). + */ + path: string; + + /** + * Removes the current converter. + * + * @param converter - `null` removes the current converter. + * @returns A `CollectionReference` that does not + * use a converter. + */ + withConverter(converter: null): CollectionReference; + /** + * Applies a custom data converter to this `CollectionReference`, allowing you + * to use your own custom model objects with Firestore. When you call {@link + * addDoc} with the returned `CollectionReference` instance, the provided + * converter will convert between Firestore data of type `NewDbModelType` and + * your custom type `NewAppModelType`. + * + * @param converter - Converts objects to and from Firestore. + * @returns A `CollectionReference` that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter, + ): CollectionReference; +} + +/** + * A `DocumentReference` refers to a document location in a Firestore database and can be used to write, read, or listen + * to the location. The document at the referenced location may or may not exist. A `DocumentReference` can also be used + * to create a `CollectionReference` to a subcollection. + */ +export interface DocumentReference< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> { + /** + * The Firestore instance the document is in. This is useful for performing transactions, for example. + */ + firestore: Firestore; + + /** + * If provided, the {@link FirestoreDataConverter} associated with this instance. + */ + converter: FirestoreDataConverter | null; + + /** + * The document's identifier within its collection. + */ + id: string; + + /** + * The Collection this `DocumentReference` belongs to. + */ + parent: CollectionReference; + + /** + * A string representing the path of the referenced document (relative to the root of the database). + */ + path: string; + + /** + * Removes the current converter. + * + * @param converter - `null` removes the current converter. + * @returns A `DocumentReference` that does not + * use a converter. + */ + withConverter(converter: null): DocumentReference; + /** + * Applies a custom data converter to this `DocumentReference`, allowing you + * to use your own custom model objects with Firestore. When you call + * {@link setDoc:1}, {@link getDoc:1}, etc. with the returned `DocumentReference` + * instance, the provided converter will convert between Firestore data of + * type `NewDbModelType` and your custom type `NewAppModelType`. + * + * @param converter - Converts objects to and from Firestore. + * @returns A `DocumentReference` that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter, + ): DocumentReference; +} + +/** + * A write batch, used to perform multiple writes as a single atomic unit. + * + * A `WriteBatch` object can be acquired by calling {@link writeBatch}. It + * provides methods for adding writes to the write batch. None of the writes + * will be committed (or visible locally) until {@link WriteBatch.commit} is + * called. + */ +export class WriteBatch { + /** + * Writes to the document referred to by the provided {@link + * DocumentReference}. If the document does not exist yet, it will be created. + * + * @param documentRef - A reference to the document to be set. + * @param data - An object of the fields and values for the document. + * @returns This `WriteBatch` instance. Used for chaining method calls. + */ + set( + documentRef: DocumentReference, + data: WithFieldValue, + ): WriteBatch; + /** + * Writes to the document referred to by the provided {@link + * DocumentReference}. If the document does not exist yet, it will be created. + * If you provide `merge` or `mergeFields`, the provided data can be merged + * into an existing document. + * + * @param documentRef - A reference to the document to be set. + * @param data - An object of the fields and values for the document. + * @param options - An object to configure the set behavior. + * @throws Error - If the provided input is not a valid Firestore document. + * @returns This `WriteBatch` instance. Used for chaining method calls. + */ + set( + documentRef: DocumentReference, + data: PartialWithFieldValue, + options: SetOptions, + ): WriteBatch; + /** + * Updates fields in the document referred to by the provided {@link + * DocumentReference}. The update will fail if applied to a document that does + * not exist. + * + * @param documentRef - A reference to the document to be updated. + * @param data - An object containing the fields and values with which to + * update the document. Fields can contain dots to reference nested fields + * within the document. + * @throws Error - If the provided input is not valid Firestore data. + * @returns This `WriteBatch` instance. Used for chaining method calls. + */ + update( + documentRef: DocumentReference, + data: UpdateData, + ): WriteBatch; + /** + * Updates fields in the document referred to by this {@link + * DocumentReference}. The update will fail if applied to a document that does + * not exist. + * + * Nested fields can be update by providing dot-separated field path strings + * or by providing `FieldPath` objects. + * + * @param documentRef - A reference to the document to be updated. + * @param field - The first field to update. + * @param value - The first value. + * @param moreFieldsAndValues - Additional key value pairs. + * @throws Error - If the provided input is not valid Firestore data. + * @returns This `WriteBatch` instance. Used for chaining method calls. + */ + update( + documentRef: DocumentReference, + field: string | FieldPath, + value: unknown, + ...moreFieldsAndValues: unknown[] + ): WriteBatch; + update( + documentRef: DocumentReference, + fieldOrUpdateData: string | FieldPath | UpdateData, + value?: unknown, + ...moreFieldsAndValues: unknown[] + ): WriteBatch; + + /** + * Deletes the document referred to by the provided {@link DocumentReference}. + * + * @param documentRef - A reference to the document to be deleted. + * @returns This `WriteBatch` instance. Used for chaining method calls. + */ + delete( + documentRef: DocumentReference, + ): WriteBatch; + /** + * Commits all of the writes in this write batch as a single atomic unit. + * + * The result of these writes will only be reflected in document reads that + * occur after the returned promise resolves. If the client is offline, the + * write fails. If you would like to see local modifications or buffer writes + * until the client is online, use the full Firestore SDK. + * + * @returns A `Promise` resolved once all of the writes in the batch have been + * successfully written to the backend as an atomic unit (note that it won't + * resolve while you're offline). + */ + commit(): Promise; +} + +/** + * A reference to a transaction. + * + * The `Transaction` object passed to a transaction's `updateFunction` provides + * the methods to read and write data within the transaction context. See + * {@link runTransaction}. + */ +export class Transaction extends LiteTransaction { + /** + * Reads the document referenced by the provided {@link DocumentReference}. + * + * @param documentRef - A reference to the document to be read. + * @returns A `DocumentSnapshot` with the read data. + */ + get( + documentRef: DocumentReference, + ): Promise>; + + /** + * Writes to the document referred to by the provided {@link + * DocumentReference}. If the document does not exist yet, it will be created. + * + * @param documentRef - A reference to the document to be set. + * @param data - An object of the fields and values for the document. + * @throws Error - If the provided input is not a valid Firestore document. + * @returns This `Transaction` instance. Used for chaining method calls. + */ + set( + documentRef: DocumentReference, + data: WithFieldValue, + ): this; + /** + * Writes to the document referred to by the provided {@link + * DocumentReference}. If the document does not exist yet, it will be created. + * If you provide `merge` or `mergeFields`, the provided data can be merged + * into an existing document. + * + * @param documentRef - A reference to the document to be set. + * @param data - An object of the fields and values for the document. + * @param options - An object to configure the set behavior. + * @throws Error - If the provided input is not a valid Firestore document. + * @returns This `Transaction` instance. Used for chaining method calls. + */ + set( + documentRef: DocumentReference, + data: PartialWithFieldValue, + options: SetOptions, + ): this; + + /** + * Updates fields in the document referred to by the provided {@link + * DocumentReference}. The update will fail if applied to a document that does + * not exist. + * + * @param documentRef - A reference to the document to be updated. + * @param data - An object containing the fields and values with which to + * update the document. Fields can contain dots to reference nested fields + * within the document. + * @throws Error - If the provided input is not valid Firestore data. + * @returns This `Transaction` instance. Used for chaining method calls. + */ + update( + documentRef: DocumentReference, + data: UpdateData, + ): this; + /** + * Updates fields in the document referred to by the provided {@link + * DocumentReference}. The update will fail if applied to a document that does + * not exist. + * + * Nested fields can be updated by providing dot-separated field path + * strings or by providing `FieldPath` objects. + * + * @param documentRef - A reference to the document to be updated. + * @param field - The first field to update. + * @param value - The first value. + * @param moreFieldsAndValues - Additional key/value pairs. + * @throws Error - If the provided input is not valid Firestore data. + * @returns This `Transaction` instance. Used for chaining method calls. + */ + update( + documentRef: DocumentReference, + field: string | FieldPath, + value: unknown, + ...moreFieldsAndValues: unknown[] + ): this; + update( + documentRef: DocumentReference, + fieldOrUpdateData: string | FieldPath | UpdateData, + value?: unknown, + ...moreFieldsAndValues: unknown[] + ): this; + /** + * Deletes the document referred to by the provided {@link DocumentReference}. + * + * @param documentRef - A reference to the document to be deleted. + * @returns This `Transaction` instance. Used for chaining method calls. + */ + delete( + documentRef: DocumentReference, + ): this; +} + +/** + * Executes the given `updateFunction` and then attempts to commit the changes + * applied within the transaction. If any document read within the transaction + * has changed, Cloud Firestore retries the `updateFunction`. If it fails to + * commit after 5 attempts, the transaction fails. + * + * The maximum number of writes allowed in a single transaction is 500. + * + * @param firestore - A reference to the Firestore database to run this + * transaction against. + * @param updateFunction - The function to execute within the transaction + * context. + * @param options - An options object to configure maximum number of attempts to + * commit. + * @returns If the transaction completed successfully or was explicitly aborted + * (the `updateFunction` returned a failed promise), the promise returned by the + * `updateFunction `is returned here. Otherwise, if the transaction failed, a + * rejected promise with the corresponding failure error is returned. + */ +export function runTransaction( + firestore: Firestore, + updateFunction: (transaction: Transaction) => Promise, + options?: TransactionOptions, +): Promise; + /** * Gets a `DocumentReference` instance that refers to the document at the * specified absolute path. @@ -168,7 +786,7 @@ export function doc( firestore: Firestore, path: string, ...pathSegments: string[] -): DocumentReference; +): DocumentReference; /** * Gets a `DocumentReference` instance that refers to a document within @@ -209,12 +827,6 @@ export declare function doc( ...pathSegments: string[] ): DocumentReference; -export function doc( - parent: Firestore | CollectionReference | DocumentReference, - path?: string, - ...pathSegments: string[] -): DocumentReference; - /** * Gets a `CollectionReference` instance that refers to the collection at * the specified absolute path. @@ -287,12 +899,6 @@ export function collection( ...pathSegments: string[] ): CollectionReference; -export function collection( - parent: Firestore | DocumentReference | CollectionReference, - path: string, - ...pathSegments: string[] -): CollectionReference; - /** *Returns true if the provided references are equal. * @@ -334,7 +940,10 @@ export function collectionGroup( * @returns A `Promise` resolved once the data has been successfully written * to the backend (note that it won't resolve while you're offline). */ -export function setDoc(reference: DocumentReference, data: WithFieldValue): Promise; +export function setDoc( + reference: DocumentReference, + data: WithFieldValue, +): Promise; /** * Writes to the document referred to by the specified `DocumentReference`. If @@ -347,18 +956,11 @@ export function setDoc(reference: DocumentReference, data: WithFieldValue< * @returns A Promise resolved once the data has been successfully written * to the backend (note that it won't resolve while you're offline). */ -export function setDoc( - reference: DocumentReference, - data: PartialWithFieldValue, - options: FirebaseFirestoreTypes.SetOptions, -): Promise; - -export function setDoc( - reference: DocumentReference, - data: PartialWithFieldValue, - options?: FirebaseFirestoreTypes.SetOptions, +export function setDoc( + reference: DocumentReference, + data: PartialWithFieldValue, + options: SetOptions, ): Promise; - /** * Updates fields in the document referred to by the specified * `DocumentReference`. The update will fail if applied to a document that does @@ -371,7 +973,10 @@ export function setDoc( * @returns A `Promise` resolved once the data has been successfully written * to the backend (note that it won't resolve while you're offline). */ -export function updateDoc(reference: DocumentReference, data: UpdateData): Promise; +export function updateDoc( + reference: DocumentReference, + data: UpdateData, +): Promise; /** * Updates fields in the document referred to by the specified * `DocumentReference` The update will fail if applied to a document that does @@ -387,13 +992,12 @@ export function updateDoc(reference: DocumentReference, data: UpdateData, +export function updateDoc( + reference: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[] ): Promise; - /** * Add a new document to specified `CollectionReference` with the given data, * assigning it a document ID automatically. @@ -529,7 +1133,7 @@ export function setLogLevel(logLevel: LogLevel): void; */ export function runTransaction( firestore: Firestore, - updateFunction: (transaction: FirebaseFirestoreTypes.Transaction) => Promise, + updateFunction: (transaction: Transaction) => Promise, ): Promise; /** @@ -737,7 +1341,7 @@ export function namedQuery(firestore: Firestore, name: string): Promise} parent + * @param {Firestore | CollectionReference | DocumentReference} parent * @param {string?} path * @param {string?} pathSegments * @returns {DocumentReference} @@ -58,7 +59,7 @@ export function doc(parent, path, ...pathSegments) { } /** - * @param {Firestore | DocumentReference | CollectionReference} parent + * @param {Firestore | DocumentReference | CollectionReference} parent * @param {string} path * @param {string?} pathSegments * @returns {CollectionReference} diff --git a/packages/firestore/lib/modular/query.d.ts b/packages/firestore/lib/modular/query.d.ts index c4c4a31cbe..2d267dbb21 100644 --- a/packages/firestore/lib/modular/query.d.ts +++ b/packages/firestore/lib/modular/query.d.ts @@ -1,12 +1,9 @@ import { FirebaseFirestoreTypes } from '../..'; +import { DocumentReference, DocumentSnapshot, Query } from './index'; -import Query = FirebaseFirestoreTypes.Query; import QueryCompositeFilterConstraint = FirebaseFirestoreTypes.QueryCompositeFilterConstraint; import WhereFilterOp = FirebaseFirestoreTypes.WhereFilterOp; import FieldPath = FirebaseFirestoreTypes.FieldPath; -import QuerySnapshot = FirebaseFirestoreTypes.QuerySnapshot; -import DocumentReference = FirebaseFirestoreTypes.DocumentReference; -import DocumentSnapshot = FirebaseFirestoreTypes.DocumentSnapshot; import DocumentData = FirebaseFirestoreTypes.DocumentData; /** Describes the different query constraints available in this SDK. */ @@ -24,12 +21,14 @@ export type QueryConstraintType = * An `AppliableConstraint` is an abstraction of a constraint that can be applied * to a Firestore query. */ -export interface AppliableConstraint { +export abstract class AppliableConstraint { /** * Takes the provided {@link Query} and returns a copy of the {@link Query} with this * {@link AppliableConstraint} applied. */ - _apply(query: Query): Query; + _apply( + query: Query, + ): Query; } /** @@ -40,15 +39,17 @@ export interface AppliableConstraint { * can then be passed to {@link (query:1)} to create a new query instance that * also contains this `QueryConstraint`. */ -export interface QueryConstraint extends AppliableConstraint { +export abstract class QueryConstraint extends AppliableConstraint { /** The type of this query constraint */ - readonly type: QueryConstraintType; + abstract readonly type: QueryConstraintType; /** * Takes the provided {@link Query} and returns a copy of the {@link Query} with this * {@link AppliableConstraint} applied. */ - _apply(query: Query): Query; + _apply( + query: Query, + ): Query; } export class QueryOrderByConstraint extends QueryConstraint { @@ -112,7 +113,7 @@ export declare function query( * * @param query - The {@link Query} instance to use as a base for the new * constraints. - * @param queryConstraints - The list of {@link IQueryConstraint}s to apply. + * @param queryConstraints - The list of {@link QueryConstraint}s to apply. * @throws if any of the provided query constraints cannot be combined with the * existing or new constraints. */ @@ -121,12 +122,6 @@ export declare function query( ...queryConstraints: QueryConstraint[] ): Query; -export function query( - query: Query, - queryConstraint: QueryCompositeFilterConstraint | IQueryConstraint | undefined, - ...additionalQueryConstraints: Array -): Query; - /** * Creates a {@link QueryFieldFilterConstraint} that enforces that documents * must contain the specified field and that the value should satisfy the @@ -188,7 +183,7 @@ export function orderBy( * @param snapshot - The snapshot of the document to start at. * @returns A {@link QueryStartAtConstraint} to pass to `query()`. */ -export function startAt(snapshot: DocumentSnapshot): QueryStartAtConstraint; +export function startAt(snapshot: DocumentSnapshot): QueryStartAtConstraint; /** * * Creates a {@link QueryStartAtConstraint} that modifies the result set to @@ -201,9 +196,7 @@ export function startAt(snapshot: DocumentSnapshot): QueryStartAtConstr */ export function startAt(...fieldValues: unknown[]): QueryStartAtConstraint; -export function startAt( - ...docOrFields: Array> -): QueryStartAtConstraint; +export function startAt(...docOrFields: Array): QueryStartAtConstraint; /** * Creates a {@link QueryStartAtConstraint} that modifies the result set to @@ -254,7 +247,9 @@ export function limit(limit: number): QueryLimitConstraint; * @returns A Promise resolved with a `DocumentSnapshot` containing the * current document contents. */ -export declare function getDoc(reference: DocumentReference): Promise>; +export declare function getDoc( + reference: DocumentReference, +): Promise>; /** * Reads the document referred to by this `DocumentReference` from cache. @@ -263,9 +258,9 @@ export declare function getDoc(reference: DocumentReference): Promise( - reference: DocumentReference, -): Promise>; +export declare function getDocFromCache( + reference: DocumentReference, +): Promise>; /** * Reads the document referred to by this `DocumentReference` from the server. @@ -274,9 +269,9 @@ export declare function getDocFromCache( * @returns A `Promise` resolved with a `DocumentSnapshot` containing the * current document contents. */ -export declare function getDocFromServer( - reference: DocumentReference, -): Promise>; +export declare function getDocFromServer( + reference: DocumentReference, +): Promise>; /** * Executes the query and returns the results as a `QuerySnapshot`. diff --git a/packages/firestore/lib/modular/query.js b/packages/firestore/lib/modular/query.js index d38bf762ad..bb200dec2d 100644 --- a/packages/firestore/lib/modular/query.js +++ b/packages/firestore/lib/modular/query.js @@ -1,13 +1,13 @@ /** - * @typedef {import('..').FirebaseFirestoreTypes.DocumentReference} DocumentReference - * @typedef {import('..').FirebaseFirestoreTypes.DocumentSnapshot} DocumentSnapshot * @typedef {import('..').FirebaseFirestoreTypes.FieldPath} FieldPath * @typedef {import('..').FirebaseFirestoreTypes.QueryCompositeFilterConstraint} QueryCompositeFilterConstraint - * @typedef {import('..').FirebaseFirestoreTypes.QuerySnapshot} QuerySnapshot - * @typedef {import('..').FirebaseFirestoreTypes.Query} Query * @typedef {import('..').FirebaseFirestoreTypes.WhereFilterOp} WhereFilterOp * @typedef {import('../FirestoreFilter')._Filter} _Filter - * @typedef {import('./query').IQueryConstraint} IQueryConstraint + * @typedef {import('.').DocumentReference} DocumentReference + * @typedef {import('.').Query} Query + * @typedef {import('./snapshot').QuerySnapshot} QuerySnapshot + * @typedef {import('./snapshot').DocumentSnapshot} DocumentSnapshot + * @typedef {import('./query').QueryConstraint} IQueryConstraint * @typedef {import('./query').OrderByDirection} OrderByDirection * @typedef {import('./query').QueryFieldFilterConstraint} QueryFieldFilterConstraint * @typedef {import('./query').QueryLimitConstraint} QueryLimitConstraint @@ -168,7 +168,7 @@ export function limitToLast(limit) { } /** - * @param {DocumentReference} query + * @param {DocumentReference} reference * @returns {Promise} */ export function getDoc(reference) { @@ -176,7 +176,7 @@ export function getDoc(reference) { } /** - * @param {DocumentReference} query + * @param {DocumentReference} reference * @returns {Promise} */ export function getDocFromCache(reference) { @@ -184,7 +184,7 @@ export function getDocFromCache(reference) { } /** - * @param {DocumentReference} query + * @param {DocumentReference} reference * @returns {Promise} */ export function getDocFromServer(reference) { diff --git a/packages/firestore/lib/modular/snapshot.d.ts b/packages/firestore/lib/modular/snapshot.d.ts index ebf9279e75..60cbb05cdd 100644 --- a/packages/firestore/lib/modular/snapshot.d.ts +++ b/packages/firestore/lib/modular/snapshot.d.ts @@ -1,14 +1,198 @@ import { FirebaseFirestoreTypes } from '../index'; +import { DocumentReference, Query } from './index'; -import DocumentReference = FirebaseFirestoreTypes.DocumentReference; -import DocumentSnapshot = FirebaseFirestoreTypes.DocumentSnapshot; import SnapshotListenOptions = FirebaseFirestoreTypes.SnapshotListenOptions; -import QuerySnapshot = FirebaseFirestoreTypes.QuerySnapshot; -import Query = FirebaseFirestoreTypes.Query; export type Unsubscribe = () => void; export type FirestoreError = Error; +/** + * A DocumentSnapshot contains data read from a document in your Firestore database. The data can be extracted with + * .`data()` or `.get(:field)` to get a specific field. + * + * For a DocumentSnapshot that points to a non-existing document, any data access will return 'undefined'. + * You can use the `exists()` method to explicitly verify a document's existence. + */ +export interface DocumentSnapshot< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> { + /** + * Method of the `DocumentSnapshot` that signals whether or not the data exists. True if the document exists. + */ + exists(): this is QueryDocumentSnapshot; + + /** + * Property of the `DocumentSnapshot` that provides the document's ID. + */ + id: string; + + /** + * Metadata about the `DocumentSnapshot`, including information about its source and local modifications. + */ + metadata: SnapshotMetadata; + + /** + * The `DocumentReference` for the document included in the `DocumentSnapshot`. + */ + ref: DocumentReference; + + /** + * Retrieves all fields in the document as an Object. Returns 'undefined' if the document doesn't exist. + * + * #### Example + * + * ```js + * const user = await firebase.firestore().doc('users/alovelace').get(); + * + * console.log('User', user.data()); + * ``` + */ + data(): AppModelType | undefined; + + /** + * Retrieves the field specified by fieldPath. Returns undefined if the document or field doesn't exist. + * + * #### Example + * + * ```js + * const user = await firebase.firestore().doc('users/alovelace').get(); + * + * console.log('Address ZIP Code', user.get('address.zip')); + * ``` + * + * @param fieldPath The path (e.g. 'foo' or 'foo.bar') to a specific field. + */ + get( + fieldPath: keyof DbModelType | string | FieldPath, + ): fieldType; + + /** + * Returns true if this `DocumentSnapshot` is equal to the provided one. + * + * #### Example + * + * ```js + * const user1 = await firebase.firestore().doc('users/alovelace').get(); + * const user2 = await firebase.firestore().doc('users/dsmith').get(); + * + * // false + * user1.isEqual(user2); + * ``` + * + * @param other The `DocumentSnapshot` to compare against. + */ + isEqual(other: DocumentSnapshot): boolean; +} + +/** + * A QueryDocumentSnapshot contains data read from a document in your Firestore database as part of a query. + * The document is guaranteed to exist and its data can be extracted with .data() or .get(:field) to get a specific field. + * + * A QueryDocumentSnapshot offers the same API surface as a DocumentSnapshot. + * Since query results contain only existing documents, the exists() method will always be true and data() will never return 'undefined'. + */ +export class QueryDocumentSnapshot< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> extends DocumentSnapshot { + /** + * A QueryDocumentSnapshot is always guaranteed to exist. + */ + exists(): true; + + /** + * Retrieves all fields in the document as an Object. + * + * #### Example + * + * ```js + * const users = await firebase.firestore().collection('users').get(); + * + * for (const user of users.docs) { + * console.log('User', user.data()); + * } + * ``` + */ + data(): AppModelType; +} + +/** + * A `QuerySnapshot` contains zero or more `QueryDocumentSnapshot` objects representing the results of a query. The documents + * can be accessed as an array via the `docs` property or enumerated using the `forEach` method. The number of documents + * can be determined via the `empty` and `size` properties. + */ +export interface QuerySnapshot< + AppModelType = DocumentData, + DbModelType extends DocumentData = DocumentData, +> { + /** + * An array of all the documents in the `QuerySnapshot`. + */ + docs: QueryDocumentSnapshot[]; + + /** + * True if there are no documents in the `QuerySnapshot`. + */ + empty: boolean; + + /** + * Metadata about this snapshot, concerning its source and if it has local modifications. + */ + metadata: SnapshotMetadata; + + /** + * The query on which you called get or `onSnapshot` in order to `get` this `QuerySnapshot`. + */ + query: Query; + + /** + * The number of documents in the `QuerySnapshot`. + */ + size: number; + + /** + * Enumerates all of the documents in the `QuerySnapshot`. + * + * #### Example + * + * ```js + * const querySnapshot = await firebase.firestore().collection('users').get(); + * + * querySnapshot.forEach((queryDocumentSnapshot) => { + * console.log('User', queryDocumentSnapshot.data()); + * }) + * ``` + * + * @param callback A callback to be called with a `QueryDocumentSnapshot` for each document in the snapshot. + * @param thisArg The `this` binding for the callback. + */ + + forEach( + callback: (result: QueryDocumentSnapshot, index: number) => void, + thisArg?: any, + ): void; + + /** + * Returns true if this `QuerySnapshot` is equal to the provided one. + * + * #### Example + * + * ```js + * const querySnapshot1 = await firebase.firestore().collection('users').limit(5).get(); + * const querySnapshot2 = await firebase.firestore().collection('users').limit(10).get(); + * + * // false + * querySnapshot1.isEqual(querySnapshot2); + * ``` + * + * > This operation can be resource intensive when dealing with large datasets. + * + * @param other The `QuerySnapshot` to compare against. + */ + isEqual(other: QuerySnapshot): boolean; +} + /** * Attaches a listener for `DocumentSnapshot` events. You may either pass * individual `onNext` and `onError` callbacks or pass a single observer @@ -22,10 +206,10 @@ export type FirestoreError = Error; * @returns An unsubscribe function that can be called to cancel * the snapshot listener. */ -export function onSnapshot( - reference: DocumentReference, +export function onSnapshot( + reference: DocumentReference, observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: FirestoreError) => void; complete?: () => void; }, @@ -44,11 +228,11 @@ export function onSnapshot( * @returns An unsubscribe function that can be called to cancel * the snapshot listener. */ -export function onSnapshot( - reference: DocumentReference, +export function onSnapshot( + reference: DocumentReference, options: SnapshotListenOptions, observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: FirestoreError) => void; complete?: () => void; }, @@ -71,9 +255,9 @@ export function onSnapshot( * @returns An unsubscribe function that can be called to cancel * the snapshot listener. */ -export function onSnapshot( - reference: DocumentReference, - onNext: (snapshot: DocumentSnapshot) => void, +export function onSnapshot( + reference: DocumentReference, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void, ): Unsubscribe; @@ -96,10 +280,10 @@ export function onSnapshot( * @returns An unsubscribe function that can be called to cancel * the snapshot listener. */ -export function onSnapshot( - reference: DocumentReference, +export function onSnapshot( + reference: DocumentReference, options: SnapshotListenOptions, - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void, ): Unsubscribe; diff --git a/packages/firestore/lib/modular/snapshot.js b/packages/firestore/lib/modular/snapshot.js index 0247f4095d..bf64b45302 100644 --- a/packages/firestore/lib/modular/snapshot.js +++ b/packages/firestore/lib/modular/snapshot.js @@ -1,7 +1,6 @@ /** - * @typedef {import('../..').FirebaseFirestoreTypes.Query} Query - * @typedef {import('../..').FirebaseFirestoreTypes.DocumentReference} DocumentReference - * @typedef {import('snapshot').Unsubscribe} Unsubscribe + * @typedef {import('.').Query} Query + * @typedef {import('.').DocumentReference} DocumentReference */ import { MODULAR_DEPRECATION_ARG } from '../../../app/lib/common'; diff --git a/packages/firestore/lib/utils/index.js b/packages/firestore/lib/utils/index.js index 359a7dc3fa..323faa7aa8 100644 --- a/packages/firestore/lib/utils/index.js +++ b/packages/firestore/lib/utils/index.js @@ -130,6 +130,21 @@ export function parseSetOptions(options) { return out; } +/** + * Applies a FirestoreDataConverter to the data object, if converter is provided. + * + * @param data + * @param converter + * @param options + * @returns Converted data. + */ +export function applyFirestoreDataConverter(data, converter, options) { + if (converter && converter.toFirestore) { + return converter.toFirestore(data, options); + } + return data; +} + // function buildFieldPathData(segments, value) { // if (segments.length === 1) { // return { @@ -243,3 +258,22 @@ export function parseSnapshotArgs(args) { return { snapshotListenOptions, callback, onNext, onError }; } + +/** + * Validates a withConverter object contains both required functions + * + * @param converter + */ +export function validateWithConverter(converter) { + if (isUndefined(converter) || !isObject(converter)) { + throw new Error('expected an object value.'); + } + + if (!isFunction(converter.toFirestore)) { + throw new Error("'toFirestore' expected a function."); + } + + if (!isFunction(converter.fromFirestore)) { + throw new Error("'fromFirestore' expected a function."); + } +} diff --git a/packages/firestore/type-test.ts b/packages/firestore/type-test.ts index 667df67e01..dc55f89b8c 100644 --- a/packages/firestore/type-test.ts +++ b/packages/firestore/type-test.ts @@ -16,8 +16,26 @@ import firebase, { startAfter, updateDoc, where, + Timestamp, + FirestoreDataConverter, + arrayUnion, + WithFieldValue, + DocumentReference, + PartialWithFieldValue, + QueryDocumentSnapshot, + deleteField, + increment, + runTransaction, + serverTimestamp, + setDoc, + writeBatch, + getFirestore, + QuerySnapshot, + DocumentSnapshot, } from '.'; +type DocumentData = FirebaseFirestoreTypes.DocumentData; + console.log(firebase().collection); // checks module exists at root @@ -147,7 +165,7 @@ getDocsFromServer( ).then(); onSnapshot(doc(firebase.firestore(), 'foo', 'foo'), () => {}); onSnapshot(doc(firebase.firestore(), 'foo', 'foo'), { - next: (snapshot: FirebaseFirestoreTypes.DocumentSnapshot) => { + next: (snapshot: DocumentSnapshot) => { console.log(snapshot.get('foo')); }, error: (error: { message: any }) => { @@ -160,7 +178,7 @@ onSnapshot( includeMetadataChanges: true, }, { - next: (snapshot: FirebaseFirestoreTypes.DocumentSnapshot) => { + next: (snapshot: DocumentSnapshot) => { console.log(snapshot.get('foo')); }, error: (error: { message: any }) => { @@ -171,7 +189,7 @@ onSnapshot( ); onSnapshot( collection(firebase.firestore(), 'foo'), - (snapshot: FirebaseFirestoreTypes.QuerySnapshot) => { + (snapshot: QuerySnapshot) => { console.log(snapshot.docs); }, (error: { message: any }) => { @@ -185,7 +203,7 @@ onSnapshot( includeMetadataChanges: true, }, { - next: (snapshot: FirebaseFirestoreTypes.QuerySnapshot) => { + next: (snapshot: QuerySnapshot) => { console.log(snapshot.docs); }, error: (error: { message: any }) => { @@ -194,3 +212,730 @@ onSnapshot( complete() {}, }, ); + +function withTestDb( + fn: (db: FirebaseFirestoreTypes.Module) => void | Promise, +): Promise { + return Promise.resolve(fn(getFirestore())); +} + +function withTestDoc(fn: (doc: DocumentReference) => void | Promise): Promise { + return withTestDb(db => { + return fn(doc(collection(db, 'test-collection'))); + }); +} + +function withTestDocAndInitialData( + data: DocumentData, + fn: (doc: DocumentReference) => void | Promise, +): Promise { + return withTestDb(async db => { + const ref = doc(collection(db, 'test-collection')); + await setDoc(ref, data); + return fn(ref); + }); +} + +/*** withConverter tests ***/ +class TestObject { + constructor( + readonly outerString: string, + readonly outerArr: string[], + readonly nested: { + innerNested: { + innerNestedNum: number; + }; + innerArr: number[]; + timestamp: Timestamp; + }, + ) {} +} + +const testConverter: FirestoreDataConverter = { + toFirestore(testObj: WithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + }, +}; + +const initialData = { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, +}; + +// nested partial support +const testConverterMerge = { + toFirestore(testObj: PartialWithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + }, +}; + +// supports FieldValues +withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + + // Allow Field Values in nested partials. + await setDoc( + ref, + { + outerString: deleteField(), + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }, + { merge: true }, + ); + + // Allow setting FieldValue on entire object field. + await setDoc( + ref, + { + nested: deleteField(), + }, + { merge: true }, + ); +}); + +// validates types in outer and inner fields +withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + + // Check top-level fields. + await setDoc( + ref, + { + // @ts-expect-error + outerString: 3, + // @ts-expect-error + outerArr: null, + }, + { merge: true }, + ); + + // Check nested fields. + await setDoc( + ref, + { + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string', + }, + // @ts-expect-error + innerArr: null, + }, + }, + { merge: true }, + ); + await setDoc( + ref, + { + // @ts-expect-error + nested: 3, + }, + { merge: true }, + ); +}); + +// checks for nonexistent properties +withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + // Top-level property. + await setDoc( + ref, + { + // @ts-expect-error + nonexistent: 'foo', + }, + { merge: true }, + ); + + // Nested property + await setDoc( + ref, + { + nested: { + // @ts-expect-error + nonexistent: 'foo', + }, + }, + { merge: true }, + ); +}); + +// allows omitting fields +withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + + // Omit outer fields + await setDoc( + ref, + { + outerString: deleteField(), + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }, + { merge: true }, + ); + + // Omit inner fields + await setDoc( + ref, + { + outerString: deleteField(), + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + timestamp: serverTimestamp(), + }, + }, + { merge: true }, + ); +}); + +// WithFieldValue +withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + await setDoc(ref, { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); +}); + +// requires all outer fields to be present +withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + // @ts-expect-error + await setDoc(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); +}); + +// requires all nested fields to be present +withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + await setDoc(ref, { + outerString: 'foo', + outerArr: [], + // @ts-expect-error + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + timestamp: serverTimestamp(), + }, + }); +}); + +// validates inner and outer fields +withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + await setDoc(ref, { + outerString: 'foo', + // @ts-expect-error + outerArr: 2, + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string', + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); +}); + +// checks for nonexistent properties +withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Top-level nonexistent fields should error + await setDoc(ref, { + outerString: 'foo', + // @ts-expect-error + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); + + // Nested nonexistent fields should error + await setDoc(ref, { + outerString: 'foo', + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + // @ts-expect-error + nonexistent: 'string', + innerNestedNum: 2, + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); +}); + +// allows certain types but not others +withTestDoc(async () => { + const withTryCatch = async (fn: () => Promise): Promise => { + try { + await fn(); + } catch {} + }; + + // These tests exist to establish which object types are allowed to be + // passed in by default when `T = DocumentData`. Some objects extend + // the JavaScript `{}`, which is why they're allowed whereas others + // throw an error. + return withTestDoc(async doc => { + // @ts-expect-error + await withTryCatch(() => setDoc(doc, 1)); + // @ts-expect-error + await withTryCatch(() => setDoc(doc, 'foo')); + // @ts-expect-error + await withTryCatch(() => setDoc(doc, false)); + await withTryCatch(() => setDoc(doc, undefined)); + await withTryCatch(() => setDoc(doc, null)); + await withTryCatch(() => setDoc(doc, [0])); + await withTryCatch(() => setDoc(doc, new Set())); + await withTryCatch(() => setDoc(doc, new Map())); + }); +}); + +// used as a type +class ObjectWrapper { + withFieldValueT(value: WithFieldValue): WithFieldValue { + return value; + } + + withPartialFieldValueT(value: PartialWithFieldValue): PartialWithFieldValue { + return value; + } + + // Wrapper to avoid having Firebase types in non-Firebase code. + withT(value: T): void { + this.withFieldValueT(value); + } + + // Wrapper to avoid having Firebase types in non-Firebase code. + withPartialT(value: Partial): void { + this.withPartialFieldValueT(value); + } +} + +// supports passing in the object as `T` +interface Foo { + id: string; + foo: number; +} +const foo = new ObjectWrapper(); +foo.withFieldValueT({ id: '', foo: increment(1) }); +foo.withPartialFieldValueT({ foo: increment(1) }); +foo.withT({ id: '', foo: 1 }); +foo.withPartialT({ foo: 1 }); + +// does not allow primitive types to use FieldValue +type Bar = number; +const bar = new ObjectWrapper(); +// @ts-expect-error +bar.withFieldValueT(increment(1)); +// @ts-expect-error +bar.withPartialFieldValueT(increment(1)); + +// UpdateData +withTestDocAndInitialData(initialData, async docRef => { + await updateDoc(docRef.withConverter(testConverter), { + outerString: deleteField(), + nested: { + innerNested: { + innerNestedNum: increment(2), + }, + innerArr: arrayUnion(3), + }, + }); +}); + +// validates inner and outer fields +withTestDocAndInitialData(initialData, async docRef => { + await updateDoc(docRef.withConverter(testConverter), { + // @ts-expect-error + outerString: 3, + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string', + }, + // @ts-expect-error + innerArr: 2, + }, + }); +}); + +// supports string-separated fields +withTestDocAndInitialData(initialData, async docRef => { + const testDocRef = docRef.withConverter(testConverter); + await updateDoc(testDocRef, { + // @ts-expect-error + outerString: 3, + // @ts-expect-error + 'nested.innerNested.innerNestedNum': 'string', + // @ts-expect-error + 'nested.innerArr': 3, + 'nested.timestamp': serverTimestamp(), + }); + + // String comprehension works in nested fields. + await updateDoc(testDocRef, { + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string', + }, + // @ts-expect-error + innerArr: 3, + }, + }); +}); + +// supports optional fields +interface TestObjectOptional { + optionalStr?: string; + nested?: { + requiredStr: string; + }; +} + +const testConverterOptional = { + toFirestore(testObj: WithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectOptional { + const data = snapshot.data(); + return { + optionalStr: data.optionalStr, + nested: data.nested, + }; + }, +}; + +withTestDocAndInitialData(initialData, async docRef => { + const testDocRef: DocumentReference = + docRef.withConverter(testConverterOptional); + + await updateDoc(testDocRef, { + optionalStr: 'foo', + }); + await updateDoc(testDocRef, { + optionalStr: 'foo', + }); + + await updateDoc(testDocRef, { + nested: { + requiredStr: 'foo', + }, + }); + await updateDoc(testDocRef, { + 'nested.requiredStr': 'foo', + }); +}); + +// supports null fields +interface TestObjectOptional2 { + optionalStr?: string; + nested?: { + strOrNull: string | null; + }; +} + +const testConverterOptional2 = { + toFirestore(testObj: WithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectOptional2 { + const data = snapshot.data(); + return { + optionalStr: data.optionalStr, + nested: data.nested, + }; + }, +}; + +withTestDocAndInitialData(initialData, async docRef => { + const testDocRef: DocumentReference = + docRef.withConverter(testConverterOptional2); + + await updateDoc(testDocRef, { + nested: { + strOrNull: null, + }, + }); + await updateDoc(testDocRef, { + 'nested.strOrNull': null, + }); +}); + +// supports union fields +interface TestObjectUnion { + optionalStr?: string; + nested?: + | { + requiredStr: string; + } + | { requiredNumber: number }; +} + +const testConverterUnion: FirestoreDataConverter = { + toFirestore(testObj: WithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObjectUnion { + const data = snapshot.data(); + return { + optionalStr: data.optionalStr, + nested: data.nested, + }; + }, +}; + +withTestDocAndInitialData(initialData, async docRef => { + const testDocRef = docRef.withConverter(testConverterUnion); + + await updateDoc(testDocRef, { + nested: { + requiredStr: 'foo', + }, + }); + + await updateDoc(testDocRef, { + 'nested.requiredStr': 'foo', + }); + await updateDoc(testDocRef, { + // @ts-expect-error + 'nested.requiredStr': 1, + }); + + await updateDoc(testDocRef, { + 'nested.requiredNumber': 1, + }); + + await updateDoc(testDocRef, { + // @ts-expect-error + 'nested.requiredNumber': 'foo', + }); + await updateDoc(testDocRef, { + // @ts-expect-error + 'nested.requiredNumber': null, + }); +}); + +// checks for nonexistent fields +withTestDocAndInitialData(initialData, async docRef => { + const testDocRef = docRef.withConverter(testConverter); + + // Top-level fields. + await updateDoc(testDocRef, { + // @ts-expect-error + nonexistent: 'foo', + }); + + // Nested Fields. + await updateDoc(testDocRef, { + nested: { + // @ts-expect-error + nonexistent: 'foo', + }, + }); + + // String fields. + await updateDoc(testDocRef, { + // @ts-expect-error + nonexistent: 'foo', + }); + await updateDoc(testDocRef, { + // @ts-expect-error + 'nested.nonexistent': 'foo', + }); +}); + +// methods +// addDoc() +withTestDb(async db => { + const ref = collection(db, 'testobj').withConverter(testConverter); + + // Requires all fields to be present + // @ts-expect-error + await addDoc(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: [], + timestamp: serverTimestamp(), + }, + }); +}); + +// WriteBatch.set() +withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter(testConverter); + const batch = writeBatch(db); + + // Requires full object if {merge: true} is not set. + // @ts-expect-error + batch.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); + + batch.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }, + { merge: true }, + ); +}); + +// WriteBatch.update() +withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter(testConverter); + const batch = writeBatch(db); + + batch.update(ref, { + outerArr: [], + nested: { + 'innerNested.innerNestedNum': increment(1), + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); +}); + +// Transaction.set() +withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter(testConverter); + + return runTransaction(db, async tx => { + // Requires full object if {merge: true} is not set. + // @ts-expect-error + tx.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); + + tx.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }, + { merge: true }, + ); + }); +}); + +// Transaction.update() +withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter(testConverter); + await setDoc(ref, { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); + + return runTransaction(db, async tx => { + tx.update(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1), + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp(), + }, + }); + }); +});