diff --git a/dev/src/bulk-writer.ts b/dev/src/bulk-writer.ts index a2b7c0a92..6fb09a2d2 100644 --- a/dev/src/bulk-writer.ts +++ b/dev/src/bulk-writer.ts @@ -562,7 +562,7 @@ export class BulkWriter { */ create( documentRef: firestore.DocumentReference, - data: T + data: firestore.WithFieldValue ): Promise { this._verifyNotClosed(); return this._enqueue(documentRef, 'create', bulkCommitBatch => @@ -654,13 +654,20 @@ export class BulkWriter { */ set( documentRef: firestore.DocumentReference, - data: T | Partial, + data: firestore.PartialWithFieldValue, options?: firestore.SetOptions ): Promise { this._verifyNotClosed(); - return this._enqueue(documentRef, 'set', bulkCommitBatch => - bulkCommitBatch.set(documentRef, data, options) - ); + return this._enqueue(documentRef, 'set', bulkCommitBatch => { + if (options) { + return bulkCommitBatch.set(documentRef, data, options); + } else { + return bulkCommitBatch.set( + documentRef, + data as firestore.WithFieldValue + ); + } + }); } /** @@ -706,7 +713,7 @@ export class BulkWriter { */ update( documentRef: firestore.DocumentReference, - dataOrField: firestore.UpdateData | string | FieldPath, + dataOrField: firestore.UpdateData | string | FieldPath, ...preconditionOrValues: Array< {lastUpdateTime?: Timestamp} | unknown | string | FieldPath > diff --git a/dev/src/document-change.ts b/dev/src/document-change.ts index b4dcb2f0a..9e615bc61 100644 --- a/dev/src/document-change.ts +++ b/dev/src/document-change.ts @@ -27,7 +27,7 @@ export type DocumentChangeType = 'added' | 'removed' | 'modified'; * @class DocumentChange */ export class DocumentChange - implements firestore.DocumentChange + implements firestore.DocumentChange { private readonly _type: DocumentChangeType; private readonly _document: QueryDocumentSnapshot; diff --git a/dev/src/reference.ts b/dev/src/reference.ts index b720c3ab3..1f8ec4f90 100644 --- a/dev/src/reference.ts +++ b/dev/src/reference.ts @@ -132,6 +132,7 @@ export class DocumentReference * * @param _firestore The Firestore Database client. * @param _path The Path of this reference. + * @param _converter The converter to use when serializing data. */ constructor( private readonly _firestore: Firestore, @@ -356,7 +357,7 @@ export class DocumentReference * console.log(`Failed to create document: ${err}`); * }); */ - create(data: T): Promise { + create(data: firestore.WithFieldValue): Promise { const writeBatch = new WriteBatch(this._firestore); return writeBatch .create(this, data) @@ -395,8 +396,11 @@ export class DocumentReference .then(([writeResult]) => writeResult); } - set(data: Partial, options: firestore.SetOptions): Promise; - set(data: T): Promise; + set( + data: firestore.PartialWithFieldValue, + options: firestore.SetOptions + ): Promise; + set(data: firestore.WithFieldValue): Promise; /** * Writes to the document referred to by this DocumentReference. If the * document does not yet exist, it will be created. If you pass @@ -421,14 +425,16 @@ export class DocumentReference * }); */ set( - data: T | Partial, + data: firestore.PartialWithFieldValue, options?: firestore.SetOptions ): Promise { - const writeBatch = new WriteBatch(this._firestore); - return writeBatch - .set(this, data, options) - .commit() - .then(([writeResult]) => writeResult); + let writeBatch = new WriteBatch(this._firestore); + if (options) { + writeBatch = writeBatch.set(this, data, options); + } else { + writeBatch = writeBatch.set(this, data as firestore.WithFieldValue); + } + return writeBatch.commit().then(([writeResult]) => writeResult); } /** @@ -461,7 +467,7 @@ export class DocumentReference * }); */ update( - dataOrField: firestore.UpdateData | string | firestore.FieldPath, + dataOrField: firestore.UpdateData | string | firestore.FieldPath, ...preconditionOrValues: Array< unknown | string | firestore.FieldPath | firestore.Precondition > @@ -2609,7 +2615,7 @@ export class CollectionReference * console.log(`Added document with name: ${documentReference.id}`); * }); */ - add(data: T): Promise> { + add(data: firestore.WithFieldValue): Promise> { const firestoreData = this._queryOptions.converter.toFirestore(data); validateDocumentData( 'data', diff --git a/dev/src/transaction.ts b/dev/src/transaction.ts index 37f316f56..a78ca9db6 100644 --- a/dev/src/transaction.ts +++ b/dev/src/transaction.ts @@ -212,17 +212,23 @@ export class Transaction implements firestore.Transaction { * }); * }); */ - create(documentRef: firestore.DocumentReference, data: T): Transaction { + create( + documentRef: firestore.DocumentReference, + data: firestore.WithFieldValue + ): Transaction { this._writeBatch.create(documentRef, data); return this; } set( documentRef: firestore.DocumentReference, - data: Partial, + data: firestore.PartialWithFieldValue, options: firestore.SetOptions ): Transaction; - set(documentRef: firestore.DocumentReference, data: T): Transaction; + set( + documentRef: firestore.DocumentReference, + data: firestore.WithFieldValue + ): Transaction; /** * Writes to the document referred to by the provided * [DocumentReference]{@link DocumentReference}. If the document @@ -252,10 +258,14 @@ export class Transaction implements firestore.Transaction { */ set( documentRef: firestore.DocumentReference, - data: T | Partial, + data: firestore.PartialWithFieldValue, options?: firestore.SetOptions ): Transaction { - this._writeBatch.set(documentRef, data, options); + if (options) { + this._writeBatch.set(documentRef, data, options); + } else { + this._writeBatch.set(documentRef, data as firestore.WithFieldValue); + } return this; } @@ -299,7 +309,7 @@ export class Transaction implements firestore.Transaction { */ update( documentRef: firestore.DocumentReference, - dataOrField: firestore.UpdateData | string | firestore.FieldPath, + dataOrField: firestore.UpdateData | string | firestore.FieldPath, ...preconditionOrValues: Array< firestore.Precondition | unknown | string | firestore.FieldPath > diff --git a/dev/src/types.ts b/dev/src/types.ts index 64fe05b38..33bf17e9b 100644 --- a/dev/src/types.ts +++ b/dev/src/types.ts @@ -14,7 +14,11 @@ * limitations under the License. */ -import {FirestoreDataConverter, DocumentData} from '@google-cloud/firestore'; +import { + FirestoreDataConverter, + DocumentData, + WithFieldValue, +} from '@google-cloud/firestore'; import {CallOptions} from 'google-gax'; import {Duplex} from 'stream'; @@ -114,7 +118,7 @@ export type RBTree = any; * @internal */ const defaultConverterObj: FirestoreDataConverter = { - toFirestore(modelObject: DocumentData): DocumentData { + toFirestore(modelObject: WithFieldValue): DocumentData { return modelObject; }, fromFirestore(snapshot: QueryDocumentSnapshot): DocumentData { diff --git a/dev/src/write-batch.ts b/dev/src/write-batch.ts index ae22a5656..eca09d411 100644 --- a/dev/src/write-batch.ts +++ b/dev/src/write-batch.ts @@ -185,9 +185,14 @@ export class WriteBatch implements firestore.WriteBatch { * console.log('Successfully executed batch.'); * }); */ - create(documentRef: firestore.DocumentReference, data: T): WriteBatch { + create( + documentRef: firestore.DocumentReference, + data: firestore.WithFieldValue + ): WriteBatch { const ref = validateDocumentReference('documentRef', documentRef); - const firestoreData = ref._converter.toFirestore(data); + const firestoreData = ref._converter.toFirestore( + data as firestore.WithFieldValue + ); validateDocumentData( 'data', firestoreData, @@ -268,14 +273,12 @@ export class WriteBatch implements firestore.WriteBatch { set( documentRef: firestore.DocumentReference, - data: Partial, + data: firestore.PartialWithFieldValue, options: firestore.SetOptions ): WriteBatch; - set(documentRef: firestore.DocumentReference, data: T): WriteBatch; set( documentRef: firestore.DocumentReference, - data: T | Partial, - options?: firestore.SetOptions + data: firestore.WithFieldValue ): WriteBatch; /** * Write to the document referred to by the provided @@ -308,7 +311,7 @@ export class WriteBatch implements firestore.WriteBatch { */ set( documentRef: firestore.DocumentReference, - data: T | Partial, + data: firestore.PartialWithFieldValue, options?: firestore.SetOptions ): WriteBatch { validateSetOptions('options', options, {optional: true}); @@ -405,7 +408,7 @@ export class WriteBatch implements firestore.WriteBatch { */ update( documentRef: firestore.DocumentReference, - dataOrField: firestore.UpdateData | string | firestore.FieldPath, + dataOrField: firestore.UpdateData | string | firestore.FieldPath, ...preconditionOrValues: Array< | {lastUpdateTime?: firestore.Timestamp} | unknown @@ -470,15 +473,16 @@ export class WriteBatch implements firestore.WriteBatch { // eslint-disable-next-line prefer-rest-params validateMaxNumberOfArguments('update', arguments, 3); - const data = dataOrField as firestore.UpdateData; - Object.entries(data).forEach(([key, value]) => { - // Skip `undefined` values (can be hit if `ignoreUndefinedProperties` - // is set) - if (value !== undefined) { - validateFieldPath(key, key); - updateMap.set(FieldPath.fromArgument(key), value); + Object.entries(dataOrField as firestore.UpdateData).forEach( + ([key, value]) => { + // Skip `undefined` values (can be hit if `ignoreUndefinedProperties` + // is set) + if (value !== undefined) { + validateFieldPath(key, key); + updateMap.set(FieldPath.fromArgument(key), value); + } } - }); + ); if (preconditionOrValues.length > 0) { validateUpdatePrecondition( diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index c6a95e9c8..095c39fe0 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -12,7 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {QuerySnapshot, DocumentData} from '@google-cloud/firestore'; +import { + QuerySnapshot, + DocumentData, + WithFieldValue, + PartialWithFieldValue, + SetOptions, +} from '@google-cloud/firestore'; import {describe, it, before, beforeEach, afterEach} from 'mocha'; import {expect, use} from 'chai'; @@ -2908,7 +2914,11 @@ describe('Bundle building', () => { ref1.set({name: '1', sort: 1, value: 'string value'}), ref2.set({name: '2', sort: 2, value: 42}), ref3.set({name: '3', sort: 3, value: {nested: 'nested value'}}), - ref4.set({name: '4', sort: 4, value: FieldValue.serverTimestamp()}), + ref4.set({ + name: '4', + sort: 4, + value: FieldValue.serverTimestamp(), + }), ]); }); @@ -3045,3 +3055,559 @@ describe('Bundle building', () => { expect(bundledDoc).to.deep.equal(expected); }); }); + +describe('Types test', () => { + let firestore: Firestore; + let randomCol: CollectionReference; + let doc: DocumentReference; + + class TestObject { + constructor( + readonly outerString: string, + readonly outerArr: string[], + readonly nested: { + innerNested: { + innerNestedNum: number; + }; + innerArr: number[]; + timestamp: Timestamp; + } + ) {} + } + + const testConverter = { + 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: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }; + + beforeEach(async () => { + firestore = new Firestore({}); + randomCol = getTestRoot(firestore); + doc = randomCol.doc(); + + await doc.set(initialData); + }); + + afterEach(() => verifyInstance(firestore)); + + describe('NestedPartial', () => { + const testConverterMerge = { + toFirestore( + testObj: PartialWithFieldValue, + options?: SetOptions + ) { + if (options && (options.merge || options.mergeFields)) { + expect(testObj).to.not.be.an.instanceOf(TestObject); + } else { + expect(testObj).to.be.an.instanceOf(TestObject); + } + return {...testObj}; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + }, + }; + + it('supports FieldValues', async () => { + const ref = doc.withConverter(testConverterMerge); + + // Allow Field Values in nested partials. + await ref.set( + { + outerString: FieldValue.delete(), + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }, + {merge: true} + ); + + // Allow setting FieldValue on entire object field. + await ref.set( + { + nested: FieldValue.delete(), + }, + {merge: true} + ); + }); + + it('validates types in outer and inner fields', async () => { + const ref = doc.withConverter(testConverterMerge); + + // Check top-level fields. + await ref.set( + { + // @ts-expect-error Should fail to transpile. + outerString: 3, + // @ts-expect-error Should fail to transpile. + outerArr: null, + }, + {merge: true} + ); + + // Check nested fields. + await ref.set( + { + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + innerNestedNum: 'string', + }, + // @ts-expect-error Should fail to transpile. + innerArr: null, + }, + }, + {merge: true} + ); + await ref.set( + { + // @ts-expect-error Should fail to transpile. + nested: 3, + }, + {merge: true} + ); + }); + + it('checks for nonexistent properties', async () => { + const ref = doc.withConverter(testConverterMerge); + // Top-level property. + await ref.set( + { + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }, + {merge: true} + ); + + // Nested property + await ref.set( + { + nested: { + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }, + }, + {merge: true} + ); + }); + }); + + describe('NestedPartial', () => { + const testConverterMerge = { + toFirestore( + testObj: PartialWithFieldValue, + options?: SetOptions + ) { + if (options && (options.merge || options.mergeFields)) { + expect(testObj).to.not.be.an.instanceOf(TestObject); + } else { + expect(testObj).to.be.an.instanceOf(TestObject); + } + return {...testObj}; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + }, + }; + + it('supports FieldValues', async () => { + const ref = doc.withConverter(testConverterMerge); + + // Allow Field Values in nested partials. + await ref.set( + { + outerString: FieldValue.delete(), + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }, + {merge: true} + ); + + // Allow setting FieldValue on entire object field. + await ref.set( + { + nested: FieldValue.delete(), + }, + {merge: true} + ); + }); + + it('validates types in outer and inner fields', async () => { + const ref = doc.withConverter(testConverterMerge); + + // Check top-level fields. + await ref.set( + { + // @ts-expect-error Should fail to transpile. + outerString: 3, + // @ts-expect-error Should fail to transpile. + outerArr: null, + }, + {merge: true} + ); + + // Check nested fields. + await ref.set( + { + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + innerNestedNum: 'string', + }, + // @ts-expect-error Should fail to transpile. + innerArr: null, + }, + }, + {merge: true} + ); + await ref.set( + { + // @ts-expect-error Should fail to transpile. + nested: 3, + }, + {merge: true} + ); + }); + + it('checks for nonexistent properties', async () => { + const ref = doc.withConverter(testConverterMerge); + // Top-level property. + await ref.set( + { + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }, + {merge: true} + ); + + // Nested property + await ref.set( + { + nested: { + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }, + }, + {merge: true} + ); + }); + }); + + describe('WithFieldValue', () => { + it('supports FieldValues', async () => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + await ref.set({ + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + + it('requires all fields to be present', async () => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + // @ts-expect-error Should fail to transpile. + await ref.set({ + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + + it('validates inner and outer fields', async () => { + const ref = doc.withConverter(testConverter); + + await ref.set({ + outerString: 'foo', + // @ts-expect-error Should fail to transpile. + outerArr: 2, + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + innerNestedNum: 'string', + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + + it('checks for nonexistent properties', async () => { + const ref = doc.withConverter(testConverter); + + // Top-level nonexistent fields should error + await ref.set({ + outerString: 'foo', + // @ts-expect-error Should fail to transpile. + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + + // Nested nonexistent fields should error + await ref.set({ + outerString: 'foo', + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + nonexistent: 'string', + innerNestedNum: 2, + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + }); + + describe('UpdateData', () => { + it('supports FieldValues', async () => { + const ref = doc.withConverter(testConverter); + await ref.update({ + outerString: FieldValue.delete(), + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(2), + }, + innerArr: FieldValue.arrayUnion(3), + }, + }); + }); + + it('validates inner and outer fields', async () => { + const ref = doc.withConverter(testConverter); + await ref.update({ + // @ts-expect-error Should fail to transpile. + outerString: 3, + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + innerNestedNum: 'string', + }, + // @ts-expect-error Should fail to transpile. + innerArr: 2, + }, + }); + }); + + it('supports string-separated fields', async () => { + const ref = doc.withConverter(testConverter); + await ref.update({ + // @ts-expect-error Should fail to transpile. + outerString: 3, + // @ts-expect-error Should fail to transpile. + 'nested.innerNested.innerNestedNum': 'string', + // @ts-expect-error Should fail to transpile. + 'nested.innerArr': 3, + 'nested.timestamp': FieldValue.serverTimestamp(), + }); + + // String comprehension works in nested fields. + await ref.update({ + nested: { + innerNested: { + // @ts-expect-error Should fail to transpile. + innerNestedNum: 'string', + }, + // @ts-expect-error Should fail to transpile. + innerArr: 3, + }, + }); + }); + + it('checks for nonexistent fields', async () => { + const ref = doc.withConverter(testConverter); + + // Top-level fields. + await ref.update({ + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }); + + // Nested Fields. + await ref.update({ + nested: { + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }, + }); + + // String fields. + await ref.update({ + // @ts-expect-error Should fail to transpile. + nonexistent: 'foo', + }); + await ref.update({ + // @ts-expect-error Should fail to transpile. + 'nested.nonexistent': 'foo', + }); + }); + }); + + describe('methods', () => { + it('CollectionReference.add()', async () => { + const ref = randomCol.withConverter(testConverter); + + // Requires all fields to be present + // @ts-expect-error Should fail to transpile. + await ref.add({ + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2, + }, + innerArr: [], + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + + it('WriteBatch.set()', () => { + const ref = doc.withConverter(testConverter); + const batch = firestore.batch(); + + // Requires full object if {merge: true} is not set. + // @ts-expect-error Should fail to transpile. + batch.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + + batch.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }, + {merge: true} + ); + }); + + it('WriteBatch.update()', () => { + const ref = doc.withConverter(testConverter); + const batch = firestore.batch(); + + batch.update(ref, { + outerArr: [], + nested: { + 'innerNested.innerNestedNum': FieldValue.increment(1), + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + + it('Transaction.set()', async () => { + const ref = doc.withConverter(testConverter); + + return firestore.runTransaction(async tx => { + // Requires full object if {merge: true} is not set. + // @ts-expect-error Should fail to transpile. + tx.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + + tx.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }, + {merge: true} + ); + }); + }); + + it('Transaction.update()', async () => { + const ref = doc.withConverter(testConverter); + + return firestore.runTransaction(async tx => { + tx.update(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: FieldValue.increment(1), + }, + innerArr: FieldValue.arrayUnion(2), + timestamp: FieldValue.serverTimestamp(), + }, + }); + }); + }); + }); +}); diff --git a/dev/test/util/helpers.ts b/dev/test/util/helpers.ts index 04922661e..3ef14c321 100644 --- a/dev/test/util/helpers.ts +++ b/dev/test/util/helpers.ts @@ -12,7 +12,12 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {DocumentData, Settings, SetOptions} from '@google-cloud/firestore'; +import { + DocumentData, + Settings, + SetOptions, + PartialWithFieldValue, +} from '@google-cloud/firestore'; import {expect} from 'chai'; import * as extend from 'extend'; @@ -345,7 +350,10 @@ export const postConverter = { }; export const postConverterMerge = { - toFirestore(post: Partial, options?: SetOptions): DocumentData { + toFirestore( + post: PartialWithFieldValue, + options?: SetOptions + ): DocumentData { if (options && (options.merge || options.mergeFields)) { expect(post).to.not.be.an.instanceOf(Post); } else { diff --git a/types/firestore.d.ts b/types/firestore.d.ts index 558ae812a..5af51701c 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -27,10 +27,31 @@ declare namespace FirebaseFirestore { */ export type DocumentData = {[field: string]: any}; + /** + * Similar to Typescript's `Partial`, but allows nested fields to be + * omitted and FieldValues to be passed in as property values. + */ + export type PartialWithFieldValue = T extends Primitive + ? T + : T extends {} + ? {[K in keyof T]?: PartialWithFieldValue | FieldValue} + : Partial; + + /** + * Allows FieldValues to be passed in as a property value while maintaining + * type safety. + */ + export type WithFieldValue = T extends Primitive + ? T + : T extends {} + ? {[K in keyof T]: WithFieldValue | FieldValue} + : Partial; + /** * Update data (for use with [update]{@link DocumentReference#update}) * that contains paths mapped to values. Fields that contain dots reference - * nested fields within the document. + * nested fields within the document. FieldValues can be passed in + * as property values. * * You can update a top-level field in your document by using the field name * as a key (e.g. `foo`). The provided value completely replaces the contents @@ -40,7 +61,59 @@ declare namespace FirebaseFirestore { * key (e.g. `foo.bar`). This nested field update replaces the contents at * `bar` but does not modify other data under `foo`. */ - export type UpdateData = {[fieldPath: string]: any}; + export type UpdateData = T extends Primitive + ? T + : T extends {} + ? {[K in keyof T]?: UpdateData | FieldValue} & NestedUpdateFields + : Partial; + + /** Primitive types. */ + export type Primitive = string | number | boolean | undefined | null; + + /** + * For each field (e.g. 'bar'), find all nested keys (e.g. {'bar.baz': T1, + * 'bar.qux': T2}). Intersect them together to make a single map containing + * all possible keys that are all marked as optional + */ + export type NestedUpdateFields> = + UnionToIntersection< + { + // Check that T[K] extends Record to only allow nesting for map values. + [K in keyof T & string]: T[K] extends Record + ? // Recurse into the map and add the prefix in front of each key + // (e.g. Prefix 'bar.' to create: 'bar.baz' and 'bar.qux'. + AddPrefixToKeys> + : // TypedUpdateData is always a map of values. + never; + }[keyof T & string] // Also include the generated prefix-string keys. + >; + + /** + * Returns a new map where every key is prefixed with the outer key appended + * to a dot. + */ + export type AddPrefixToKeys< + Prefix extends string, + T extends Record + > = + // Remap K => Prefix.K. See https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as + {[K in keyof T & string as `${Prefix}.${K}`]+?: T[K]}; + + /** + * Given a union type `U = T1 | T2 | ...`, returns an intersected type + * `(T1 & T2 & ...)`. + * + * Uses distributive conditional types and inference from conditional types. + * This works because multiple candidates for the same type variable in + * contra-variant positions causes an intersection type to be inferred. + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-inference-in-conditional-types + * https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type + */ + export type UnionToIntersection = ( + U extends unknown ? (k: U) => void : never + ) extends (k: infer I) => void + ? I + : never; /** * Sets or disables the log function for all active Firestore instances. @@ -95,9 +168,27 @@ declare namespace FirebaseFirestore { * 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`. + * + * The `WithFieldValue` type extends `T` to also allow FieldValues such + * as `FieldValue.delete()` to be used as property values. + */ + toFirestore(modelObject: WithFieldValue): DocumentData; + + /** + * 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`. + * + * The `PartialWithFieldValue` type extends `Partial` to allow + * FieldValues such as `FieldValue.delete()` to be used as property values. + * It also supports nested `Partial` by allowing nested fields to be + * omitted. */ - toFirestore(modelObject: T): DocumentData; - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + toFirestore( + modelObject: PartialWithFieldValue, + options: SetOptions + ): DocumentData; /** * Called by the Firestore SDK to convert Firestore data into an object of @@ -492,7 +583,10 @@ declare namespace FirebaseFirestore { * @param data The object data to serialize as the document. * @return This `Transaction` instance. Used for chaining method calls. */ - create(documentRef: DocumentReference, data: T): Transaction; + create( + documentRef: DocumentReference, + data: WithFieldValue + ): Transaction; /** * Writes to the document referred to by the provided `DocumentReference`. @@ -506,10 +600,13 @@ declare namespace FirebaseFirestore { */ set( documentRef: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): Transaction; - set(documentRef: DocumentReference, data: T): Transaction; + set( + documentRef: DocumentReference, + data: WithFieldValue + ): Transaction; /** * Updates fields in the document referred to by the provided @@ -525,9 +622,9 @@ declare namespace FirebaseFirestore { * @param precondition A Precondition to enforce on this update. * @return This `Transaction` instance. Used for chaining method calls. */ - update( - documentRef: DocumentReference, - data: UpdateData, + update( + documentRef: DocumentReference, + data: UpdateData, precondition?: Precondition ): Transaction; @@ -590,7 +687,10 @@ declare namespace FirebaseFirestore { * write fails, the promise is rejected with a * [BulkWriterError]{@link BulkWriterError}. */ - create(documentRef: DocumentReference, data: T): Promise; + create( + documentRef: DocumentReference, + data: WithFieldValue + ): Promise; /** * Delete a document from the database. @@ -636,10 +736,13 @@ declare namespace FirebaseFirestore { */ set( documentRef: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): Promise; - set(documentRef: DocumentReference, data: T): Promise; + set( + documentRef: DocumentReference, + data: WithFieldValue + ): Promise; /** * Update fields of the document referred to by the provided @@ -664,9 +767,9 @@ declare namespace FirebaseFirestore { * write fails, the promise is rejected with a * [BulkWriterError]{@link BulkWriterError}. */ - update( - documentRef: DocumentReference, - data: UpdateData, + update( + documentRef: DocumentReference, + data: UpdateData, precondition?: Precondition ): Promise; @@ -835,7 +938,10 @@ declare namespace FirebaseFirestore { * @param data The object data to serialize as the document. * @return This `WriteBatch` instance. Used for chaining method calls. */ - create(documentRef: DocumentReference, data: T): WriteBatch; + create( + documentRef: DocumentReference, + data: WithFieldValue + ): WriteBatch; /** * Write to the document referred to by the provided `DocumentReference`. @@ -849,10 +955,13 @@ declare namespace FirebaseFirestore { */ set( documentRef: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): WriteBatch; - set(documentRef: DocumentReference, data: T): WriteBatch; + set( + documentRef: DocumentReference, + data: WithFieldValue + ): WriteBatch; /** * Update fields of the document referred to by the provided @@ -868,9 +977,9 @@ declare namespace FirebaseFirestore { * @param precondition A Precondition to enforce on this update. * @return This `WriteBatch` instance. Used for chaining method calls. */ - update( - documentRef: DocumentReference, - data: UpdateData, + update( + documentRef: DocumentReference, + data: UpdateData, precondition?: Precondition ): WriteBatch; @@ -1053,7 +1162,7 @@ declare namespace FirebaseFirestore { * @param data The object data to serialize as the document. * @return A Promise resolved with the write time of this create. */ - create(data: T): Promise; + create(data: WithFieldValue): Promise; /** * Writes to the document referred to by this `DocumentReference`. If the @@ -1064,8 +1173,11 @@ declare namespace FirebaseFirestore { * @param options An object to configure the set behavior. * @return A Promise resolved with the write time of this set. */ - set(data: Partial, options: SetOptions): Promise; - set(data: T): Promise; + set( + data: PartialWithFieldValue, + options: SetOptions + ): Promise; + set(data: WithFieldValue): Promise; /** * Updates fields in the document referred to by this `DocumentReference`. @@ -1079,7 +1191,10 @@ declare namespace FirebaseFirestore { * @param precondition A Precondition to enforce on this update. * @return A Promise resolved with the write time of this update. */ - update(data: UpdateData, precondition?: Precondition): Promise; + update( + data: UpdateData, + precondition?: Precondition + ): Promise; /** * Updates fields in the document referred to by this `DocumentReference`. @@ -1690,7 +1805,7 @@ declare namespace FirebaseFirestore { * @return A Promise resolved with a `DocumentReference` pointing to the * newly created document after it has been written to the backend. */ - add(data: T): Promise>; + add(data: WithFieldValue): Promise>; /** * Returns true if this `CollectionReference` is equal to the provided one.