From 94ddc897400cafe5a1ee16f3ad0d285411bdd0b2 Mon Sep 17 00:00:00 2001 From: Brian Chen Date: Tue, 14 Jan 2020 10:44:21 -0800 Subject: [PATCH] feat: add support for Typescript Custom Mapping (#828) --- dev/src/document-change.ts | 9 +- dev/src/document.ts | 77 ++++--- dev/src/index.ts | 41 ++-- dev/src/reference.ts | 381 +++++++++++++++++++++++++++-------- dev/src/transaction.ts | 52 +++-- dev/src/types.ts | 75 ++++++- dev/src/watch.ts | 104 ++++++---- dev/src/write-batch.ts | 37 ++-- dev/system-test/firestore.ts | 86 +++++++- dev/test/collection.ts | 52 +++++ dev/test/document.ts | 52 +++++ dev/test/query.ts | 41 +++- dev/test/typescript.ts | 13 ++ dev/test/util/helpers.ts | 20 +- dev/test/watch.ts | 3 +- types/firestore.d.ts | 223 ++++++++++++++------ 16 files changed, 967 insertions(+), 299 deletions(-) diff --git a/dev/src/document-change.ts b/dev/src/document-change.ts index b0e40eafd..0b8c16ecf 100644 --- a/dev/src/document-change.ts +++ b/dev/src/document-change.ts @@ -15,6 +15,7 @@ */ import {QueryDocumentSnapshot} from './document'; +import {DocumentData} from './types'; export type DocumentChangeType = 'added' | 'removed' | 'modified'; @@ -24,9 +25,9 @@ export type DocumentChangeType = 'added' | 'removed' | 'modified'; * * @class */ -export class DocumentChange { +export class DocumentChange { private readonly _type: DocumentChangeType; - private readonly _document: QueryDocumentSnapshot; + private readonly _document: QueryDocumentSnapshot; private readonly _oldIndex: number; private readonly _newIndex: number; @@ -42,7 +43,7 @@ export class DocumentChange { */ constructor( type: DocumentChangeType, - document: QueryDocumentSnapshot, + document: QueryDocumentSnapshot, oldIndex: number, newIndex: number ) { @@ -169,7 +170,7 @@ export class DocumentChange { * @param {*} other The value to compare against. * @return true if this `DocumentChange` is equal to the provided value. */ - isEqual(other: DocumentChange): boolean { + isEqual(other: DocumentChange): boolean { if (this === other) { return true; } diff --git a/dev/src/document.ts b/dev/src/document.ts index a13674f5f..e0bc273c5 100644 --- a/dev/src/document.ts +++ b/dev/src/document.ts @@ -36,10 +36,7 @@ import api = google.firestore.v1; * * @private */ -export class DocumentSnapshotBuilder { - /** The reference to the document. */ - ref?: DocumentReference; - +export class DocumentSnapshotBuilder { /** The fields of the Firestore `Document` Protobuf backing this document. */ fieldsProto?: ApiMapValue; @@ -52,6 +49,10 @@ export class DocumentSnapshotBuilder { /** The time when this document was last updated. */ updateTime?: Timestamp; + // We include the DocumentReference in the constructor in order to allow the + // DocumentSnapshotBuilder to be typed with when it is constructed. + constructor(readonly ref: DocumentReference) {} + /** * Builds the DocumentSnapshot. * @@ -59,7 +60,7 @@ export class DocumentSnapshotBuilder { * @returns Returns either a QueryDocumentSnapshot (if `fieldsProto` was * provided) or a DocumentSnapshot. */ - build(): QueryDocumentSnapshot | DocumentSnapshot { + build(): QueryDocumentSnapshot | DocumentSnapshot { assert( (this.fieldsProto !== undefined) === (this.createTime !== undefined), 'Create time should be set iff document exists.' @@ -94,9 +95,8 @@ export class DocumentSnapshotBuilder { * * @class */ -export class DocumentSnapshot { - private _ref: DocumentReference; - private _fieldsProto: ApiMapValue | undefined; +export class DocumentSnapshot { + private _ref: DocumentReference; private _serializer: Serializer; private _readTime: Timestamp | undefined; private _createTime: Timestamp | undefined; @@ -106,7 +106,7 @@ export class DocumentSnapshot { * @hideconstructor * * @param ref The reference to the document. - * @param fieldsProto The fields of the Firestore `Document` Protobuf backing + * @param _fieldsProto The fields of the Firestore `Document` Protobuf backing * this document (or undefined if the document does not exist). * @param readTime The time when this snapshot was read (or undefined if * the document exists only locally). @@ -116,14 +116,13 @@ export class DocumentSnapshot { * if the document does not exist). */ constructor( - ref: DocumentReference, - fieldsProto?: ApiMapValue, + ref: DocumentReference, + readonly _fieldsProto?: ApiMapValue, readTime?: Timestamp, createTime?: Timestamp, updateTime?: Timestamp ) { this._ref = ref; - this._fieldsProto = fieldsProto; this._serializer = ref.firestore._serializer!; this._readTime = readTime; this._createTime = createTime; @@ -138,10 +137,10 @@ export class DocumentSnapshot { * @param obj The object to store in the DocumentSnapshot. * @return The created DocumentSnapshot. */ - static fromObject( - ref: DocumentReference, + static fromObject( + ref: DocumentReference, obj: DocumentData - ): DocumentSnapshot { + ): DocumentSnapshot { const serializer = ref.firestore._serializer!; return new DocumentSnapshot(ref, serializer.encodeFields(obj)); } @@ -156,10 +155,10 @@ export class DocumentSnapshot { * @param data The field/value map to expand. * @return The created DocumentSnapshot. */ - static fromUpdateMap( - ref: DocumentReference, + static fromUpdateMap( + ref: DocumentReference, data: UpdateMap - ): DocumentSnapshot { + ): DocumentSnapshot { const serializer = ref.firestore._serializer!; /** @@ -270,7 +269,7 @@ export class DocumentSnapshot { * } * }); */ - get ref(): DocumentReference { + get ref(): DocumentReference { return this._ref; } @@ -364,8 +363,8 @@ export class DocumentSnapshot { * Retrieves all fields in the document as an object. Returns 'undefined' if * the document doesn't exist. * - * @returns {DocumentData|undefined} An object containing all fields in the - * document or 'undefined' if the document doesn't exist. + * @returns {T|undefined} An object containing all fields in the document or + * 'undefined' if the document doesn't exist. * * @example * let documentRef = firestore.doc('col/doc'); @@ -375,11 +374,7 @@ export class DocumentSnapshot { * console.log(`Retrieved data: ${JSON.stringify(data)}`); * }); */ - // We deliberately use `any` in the external API to not impose type-checking - // on end users. - // tslint:disable-next-line no-any - data(): {[field: string]: any} | undefined { - // tslint:disable-line no-any + data(): T | undefined { const fields = this._fieldsProto; if (fields === undefined) { @@ -390,7 +385,7 @@ export class DocumentSnapshot { for (const prop of Object.keys(fields)) { obj[prop] = this._serializer.decodeValue(fields[prop]); } - return obj; + return this.ref._converter.fromFirestore(obj); } /** @@ -490,7 +485,7 @@ export class DocumentSnapshot { * @return {boolean} true if this `DocumentSnapshot` is equal to the provided * value. */ - isEqual(other: DocumentSnapshot): boolean { + isEqual(other: DocumentSnapshot): boolean { // Since the read time is different on every document read, we explicitly // ignore all document metadata in this comparison. return ( @@ -517,7 +512,9 @@ export class DocumentSnapshot { * @class * @extends DocumentSnapshot */ -export class QueryDocumentSnapshot extends DocumentSnapshot { +export class QueryDocumentSnapshot extends DocumentSnapshot< + T +> { /** * @hideconstructor * @@ -529,7 +526,7 @@ export class QueryDocumentSnapshot extends DocumentSnapshot { * @param updateTime The time when the document was last updated. */ constructor( - ref: DocumentReference, + ref: DocumentReference, fieldsProto: ApiMapValue, readTime: Timestamp, createTime: Timestamp, @@ -582,7 +579,7 @@ export class QueryDocumentSnapshot extends DocumentSnapshot { * * @override * - * @returns {DocumentData} An object containing all fields in the document. + * @returns {T} An object containing all fields in the document. * * @example * let query = firestore.collection('col'); @@ -592,7 +589,7 @@ export class QueryDocumentSnapshot extends DocumentSnapshot { * console.log(`Retrieved data: ${JSON.stringify(data)}`); * }); */ - data(): DocumentData { + data(): T { const data = super.data(); if (!data) { throw new Error( @@ -871,7 +868,7 @@ export class DocumentMask { * @private * @class */ -export class DocumentTransform { +export class DocumentTransform { /** * @private * @hideconstructor @@ -880,7 +877,7 @@ export class DocumentTransform { * @param transforms A Map of FieldPaths to FieldTransforms. */ constructor( - private readonly ref: DocumentReference, + private readonly ref: DocumentReference, private readonly transforms: Map ) {} @@ -892,10 +889,10 @@ export class DocumentTransform { * @param obj The object to extract the transformations from. * @returns The Document Transform. */ - static fromObject( - ref: DocumentReference, + static fromObject( + ref: DocumentReference, obj: DocumentData - ): DocumentTransform { + ): DocumentTransform { const updateMap = new Map(); for (const prop of Object.keys(obj)) { @@ -913,10 +910,10 @@ export class DocumentTransform { * @param data The update data to extract the transformations from. * @returns The Document Transform. */ - static fromUpdateMap( - ref: DocumentReference, + static fromUpdateMap( + ref: DocumentReference, data: UpdateMap - ): DocumentTransform { + ): DocumentTransform { const transforms = new Map(); function encode_(val: unknown, path: FieldPath, allowTransforms: boolean) { diff --git a/dev/src/index.ts b/dev/src/index.ts index 131014cd9..8f83c12da 100644 --- a/dev/src/index.ts +++ b/dev/src/index.ts @@ -43,6 +43,7 @@ import {Timestamp} from './timestamp'; import {parseGetAllArguments, Transaction} from './transaction'; import { ApiMapValue, + DocumentData, FirestoreStreamingMethod, FirestoreUnaryMethod, GapicClient, @@ -83,6 +84,7 @@ export {FieldPath} from './path'; export {GeoPoint} from './geo-point'; export {setLogFunction} from './logger'; export { + FirestoreDataConverter, UpdateData, DocumentData, Settings, @@ -695,20 +697,22 @@ export class Firestore { ); } - const document = new DocumentSnapshotBuilder(); - + let ref: DocumentReference; + let document: DocumentSnapshotBuilder; if (typeof documentOrName === 'string') { - document.ref = new DocumentReference( + ref = new DocumentReference( this, QualifiedResourcePath.fromSlashSeparatedString(documentOrName) ); + document = new DocumentSnapshotBuilder(ref); } else { - document.ref = new DocumentReference( + ref = new DocumentReference( this, QualifiedResourcePath.fromSlashSeparatedString( documentOrName.name as string ) ); + document = new DocumentSnapshotBuilder(ref); document.fieldsProto = documentOrName.fields ? convertFields(documentOrName.fields as ApiMapValue) : {}; @@ -886,7 +890,7 @@ export class Firestore { * } * }); */ - listCollections() { + listCollections(): Promise { const rootDocument = new DocumentReference(this, ResourcePath.EMPTY); return rootDocument.listCollections(); } @@ -912,9 +916,9 @@ export class Firestore { * console.log(`Second document: ${JSON.stringify(docs[1])}`); * }); */ - getAll( - ...documentRefsOrReadOptions: Array - ): Promise { + getAll( + ...documentRefsOrReadOptions: Array | ReadOptions> + ): Promise>> { validateMinNumberOfArguments('Firestore.getAll', arguments, 1); const {documents, fieldMask} = parseGetAllArguments( @@ -937,12 +941,12 @@ export class Firestore { * @param transactionId The transaction ID to use for this read. * @returns A Promise that contains an array with the resulting documents. */ - getAll_( - docRefs: DocumentReference[], + getAll_( + docRefs: Array>, fieldMask: FieldPath[] | null, requestTag: string, transactionId?: Uint8Array - ): Promise { + ): Promise>> { const requestedDocuments = new Set(); const retrievedDocuments = new Map(); @@ -962,11 +966,10 @@ export class Firestore { } const self = this; - return self .requestStream('batchGetDocuments', request, requestTag) .then(stream => { - return new Promise((resolve, reject) => { + return new Promise>>((resolve, reject) => { stream .on('error', err => { logger( @@ -1024,11 +1027,19 @@ export class Firestore { // BatchGetDocuments doesn't preserve document order. We use // the request order to sort the resulting documents. - const orderedDocuments: DocumentSnapshot[] = []; + const orderedDocuments: Array> = []; + for (const docRef of docRefs) { const document = retrievedDocuments.get(docRef.path); if (document !== undefined) { - orderedDocuments.push(document); + // Recreate the DocumentSnapshot with the DocumentReference + // containing the original converter. + const finalDoc = new DocumentSnapshotBuilder(docRef); + finalDoc.fieldsProto = document._fieldsProto; + finalDoc.readTime = document.readTime; + finalDoc.createTime = document.createTime; + finalDoc.updateTime = document.updateTime; + orderedDocuments.push(finalDoc.build()); } else { reject( new Error(`Did not receive document for "${docRef.path}".`) diff --git a/dev/src/reference.ts b/dev/src/reference.ts index 442db77db..df580d81e 100644 --- a/dev/src/reference.ts +++ b/dev/src/reference.ts @@ -39,7 +39,9 @@ import { import {Serializable, Serializer, validateUserInput} from './serializer'; import {Timestamp} from './timestamp'; import { + defaultConverter, DocumentData, + FirestoreDataConverter, OrderByDirection, Precondition, SetOptions, @@ -121,7 +123,7 @@ const comparisonOperators: { * * @class */ -export class DocumentReference implements Serializable { +export class DocumentReference implements Serializable { /** * @hideconstructor * @@ -130,7 +132,8 @@ export class DocumentReference implements Serializable { */ constructor( private readonly _firestore: Firestore, - readonly _path: ResourcePath + readonly _path: ResourcePath, + readonly _converter = defaultConverter as FirestoreDataConverter ) {} /** @@ -216,8 +219,12 @@ export class DocumentReference implements Serializable { * console.log(`Found ${results.size} matches in parent collection`); * }): */ - get parent(): CollectionReference { - return new CollectionReference(this._firestore, this._path.parent()!); + get parent(): CollectionReference { + return new CollectionReference( + this._firestore, + this._path.parent()!, + this._converter + ); } /** @@ -237,7 +244,7 @@ export class DocumentReference implements Serializable { * } * }); */ - get(): Promise { + get(): Promise> { return this._firestore.getAll(this).then(([result]) => result); } @@ -282,7 +289,7 @@ export class DocumentReference implements Serializable { * } * }); */ - listCollections(): Promise { + listCollections(): Promise>> { const tag = requestTag(); return this.firestore.initializeIfNeeded(tag).then(() => { const request: api.IListCollectionIdsRequest = { @@ -299,7 +306,7 @@ export class DocumentReference implements Serializable { tag ) .then(collectionIds => { - const collections: CollectionReference[] = []; + const collections: Array> = []; // We can just sort this list using the default comparator since it // will only contain collection ids. @@ -332,7 +339,7 @@ export class DocumentReference implements Serializable { * console.log(`Failed to create document: ${err}`); * }); */ - create(data: DocumentData): Promise { + create(data: T): Promise { const writeBatch = new WriteBatch(this._firestore); return writeBatch .create(this, data) @@ -375,7 +382,7 @@ export class DocumentReference implements Serializable { * [SetOptions]{@link SetOptions}, the provided data can be merged into an * existing document. * - * @param {DocumentData} data A map of the fields and values for the document. + * @param {T} data A map of the fields and values for the document. * @param {SetOptions=} options An object to configure the set behavior. * @param {boolean=} options.merge If true, set() merges the values specified * in its data argument. Fields omitted from this set() call remain untouched. @@ -392,7 +399,7 @@ export class DocumentReference implements Serializable { * console.log(`Document written at ${res.updateTime}`); * }); */ - set(data: DocumentData, options?: SetOptions): Promise { + set(data: T, options?: SetOptions): Promise { const writeBatch = new WriteBatch(this._firestore); return writeBatch .set(this, data, options) @@ -436,8 +443,12 @@ export class DocumentReference implements Serializable { validateMinNumberOfArguments('DocumentReference.update', arguments, 1); const writeBatch = new WriteBatch(this._firestore); - return writeBatch.update - .apply(writeBatch, [this, dataOrField, ...preconditionOrValues]) + return writeBatch + .update( + this as DocumentReference, + dataOrField, + ...preconditionOrValues + ) .commit() .then(([writeResult]) => writeResult); } @@ -469,7 +480,7 @@ export class DocumentReference implements Serializable { * unsubscribe(); */ onSnapshot( - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void ): () => void { validateFunction('onNext', onNext); @@ -486,8 +497,12 @@ export class DocumentReference implements Serializable { } // The document is missing. - const document = new DocumentSnapshotBuilder(); - document.ref = new DocumentReference(this._firestore, this._path); + const ref = new DocumentReference( + this._firestore, + this._path, + this._converter + ); + const document = new DocumentSnapshotBuilder(ref); document.readTime = readTime; onNext(document.build()); }, onError || console.error); @@ -500,12 +515,13 @@ export class DocumentReference implements Serializable { * @return {boolean} true if this `DocumentReference` is equal to the provided * value. */ - isEqual(other: DocumentReference): boolean { + isEqual(other: DocumentReference): boolean { return ( this === other || (other instanceof DocumentReference && this._firestore === other._firestore && - this._path.isEqual(other._path)) + this._path.isEqual(other._path) && + this._converter === other._converter) ); } @@ -517,6 +533,54 @@ export class DocumentReference implements Serializable { toProto(): api.IValue { return {referenceValue: this.formattedName}; } + + /** + * 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. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * @example + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * data: FirebaseFirestore.DocumentData + * ): Post { + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await 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 + * } + * + * @param converter Converts objects to and from Firestore. + * @return A DocumentReference that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): DocumentReference { + return new DocumentReference(this.firestore, this._path, converter); + } } /** @@ -641,11 +705,11 @@ class FieldFilter { * * @class QuerySnapshot */ -export class QuerySnapshot { - private _materializedDocs: QueryDocumentSnapshot[] | null = null; - private _materializedChanges: DocumentChange[] | null = null; - private _docs: (() => QueryDocumentSnapshot[]) | null = null; - private _changes: (() => DocumentChange[]) | null = null; +export class QuerySnapshot { + private _materializedDocs: Array> | null = null; + private _materializedChanges: Array> | null = null; + private _docs: (() => Array>) | null = null; + private _changes: (() => Array>) | null = null; /** * @hideconstructor @@ -659,11 +723,11 @@ export class QuerySnapshot { * events for this snapshot. */ constructor( - private readonly _query: Query, + private readonly _query: Query, private readonly _readTime: Timestamp, private readonly _size: number, - docs: () => QueryDocumentSnapshot[], - changes: () => DocumentChange[] + docs: () => Array>, + changes: () => Array> ) { this._docs = docs; this._changes = changes; @@ -688,7 +752,7 @@ export class QuerySnapshot { * console.log(`Returned second batch of results`); * }); */ - get query(): Query { + get query(): Query { return this._query; } @@ -709,7 +773,7 @@ export class QuerySnapshot { * } * }); */ - get docs(): QueryDocumentSnapshot[] { + get docs(): Array> { if (this._materializedDocs) { return this._materializedDocs!; } @@ -791,7 +855,7 @@ export class QuerySnapshot { * } * }); */ - docChanges(): DocumentChange[] { + docChanges(): Array> { if (this._materializedChanges) { return this._materializedChanges!; } @@ -820,7 +884,7 @@ export class QuerySnapshot { * }); */ forEach( - callback: (result: QueryDocumentSnapshot) => void, + callback: (result: QueryDocumentSnapshot) => void, thisArg?: unknown ): void { validateFunction('callback', callback); @@ -838,7 +902,7 @@ export class QuerySnapshot { * @return {boolean} true if this `QuerySnapshot` is equal to the provided * value. */ - isEqual(other: QuerySnapshot): boolean { + isEqual(other: QuerySnapshot): boolean { // Since the read time is different on every query read, we explicitly // ignore all metadata in this comparison. @@ -911,10 +975,11 @@ interface QueryCursor { * These options are immutable. Modified options can be created using `with()`. * @private */ -export class QueryOptions { +export class QueryOptions { constructor( readonly parentPath: ResourcePath, readonly collectionId: string, + readonly converter: FirestoreDataConverter, readonly allDescendants: boolean, readonly fieldFilters: FieldFilter[], readonly fieldOrders: FieldOrder[], @@ -929,10 +994,14 @@ export class QueryOptions { * Returns query options for a collection group query. * @private */ - static forCollectionGroupQuery(collectionId: string): QueryOptions { - return new QueryOptions( + static forCollectionGroupQuery( + collectionId: string, + converter = defaultConverter as FirestoreDataConverter + ): QueryOptions { + return new QueryOptions( /*parentPath=*/ ResourcePath.EMPTY, collectionId, + converter, /*allDescendants=*/ true, /*fieldFilters=*/ [], /*fieldOrders=*/ [] @@ -943,10 +1012,14 @@ export class QueryOptions { * Returns query options for a single-collection query. * @private */ - static forCollectionQuery(collectionRef: ResourcePath): QueryOptions { - return new QueryOptions( + static forCollectionQuery( + collectionRef: ResourcePath, + converter = defaultConverter as FirestoreDataConverter + ): QueryOptions { + return new QueryOptions( collectionRef.parent()!, collectionRef.id!, + converter, /*allDescendants=*/ false, /*fieldFilters=*/ [], /*fieldOrders=*/ [] @@ -957,10 +1030,14 @@ export class QueryOptions { * Returns the union of the current and the provided options. * @private */ - with(settings: Partial): QueryOptions { + with( + settings: Partial, 'converter'>>, + converter = defaultConverter as FirestoreDataConverter + ): QueryOptions { return new QueryOptions( coalesce(settings.parentPath, this.parentPath)!, coalesce(settings.collectionId, this.collectionId)!, + converter, coalesce(settings.allDescendants, this.allDescendants)!, coalesce(settings.fieldFilters, this.fieldFilters)!, coalesce(settings.fieldOrders, this.fieldOrders)!, @@ -972,7 +1049,23 @@ export class QueryOptions { ); } - isEqual(other: QueryOptions) { + withConverter(converter: FirestoreDataConverter): QueryOptions { + return new QueryOptions( + this.parentPath, + this.collectionId, + converter, + this.allDescendants, + this.fieldFilters, + this.fieldOrders, + this.startAt, + this.endAt, + this.limit, + this.offset, + this.projection + ); + } + + isEqual(other: QueryOptions) { if (this === other) { return true; } @@ -981,6 +1074,7 @@ export class QueryOptions { other instanceof QueryOptions && this.parentPath.isEqual(other.parentPath) && this.collectionId === other.collectionId && + this.converter === other.converter && this.allDescendants === other.allDescendants && this.limit === other.limit && this.offset === other.offset && @@ -999,7 +1093,7 @@ export class QueryOptions { * * @class Query */ -export class Query { +export class Query { private readonly _serializer: Serializer; /** @@ -1010,7 +1104,7 @@ export class Query { */ constructor( private readonly _firestore: Firestore, - protected readonly _queryOptions: QueryOptions + protected readonly _queryOptions: QueryOptions ) { this._serializer = new Serializer(_firestore); } @@ -1024,7 +1118,7 @@ export class Query { * @returns 'true' if the input is a single DocumentSnapshot.. */ static _isDocumentSnapshot( - fieldValuesOrDocumentSnapshot: Array + fieldValuesOrDocumentSnapshot: Array | unknown> ): boolean { return ( fieldValuesOrDocumentSnapshot.length === 1 && @@ -1118,7 +1212,7 @@ export class Query { fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown - ): Query { + ): Query { validateFieldPath('fieldPath', fieldPath); opStr = validateQueryOperator('opStr', opStr, value); validateQueryValue('value', value); @@ -1171,7 +1265,7 @@ export class Query { * console.log(`y is ${res.docs[0].get('y')}.`); * }); */ - select(...fieldPaths: Array): Query { + select(...fieldPaths: Array): Query { const fields: api.StructuredQuery.IFieldReference[] = []; if (fieldPaths.length === 0) { @@ -1214,7 +1308,7 @@ export class Query { orderBy( fieldPath: string | FieldPath, directionStr?: OrderByDirection - ): Query { + ): Query { validateFieldPath('fieldPath', fieldPath); directionStr = validateQueryOrder('directionStr', directionStr); @@ -1255,7 +1349,7 @@ export class Query { * }); * }); */ - limit(limit: number): Query { + limit(limit: number): Query { validateInteger('limit', limit); const options = this._queryOptions.with({limit}); @@ -1281,7 +1375,7 @@ export class Query { * }); * }); */ - offset(offset: number): Query { + offset(offset: number): Query { validateInteger('offset', offset); const options = this._queryOptions.with({offset}); @@ -1294,7 +1388,7 @@ export class Query { * @param {*} other The value to compare against. * @return {boolean} true if this `Query` is equal to the provided value. */ - isEqual(other: Query): boolean { + isEqual(other: Query): boolean { if (this === other) { return true; } @@ -1313,7 +1407,7 @@ export class Query { * @returns The implicit ordering semantics. */ private createImplicitOrderBy( - cursorValuesOrDocumentSnapshot: Array + cursorValuesOrDocumentSnapshot: Array | unknown> ): FieldOrder[] { if (!Query._isDocumentSnapshot(cursorValuesOrDocumentSnapshot)) { return this._queryOptions.fieldOrders; @@ -1419,11 +1513,11 @@ export class Query { * query. * @private */ - private validateReference(val: unknown): DocumentReference { + private validateReference(val: unknown): DocumentReference { const basePath = this._queryOptions.allDescendants ? this._queryOptions.parentPath : this._queryOptions.parentPath.append(this._queryOptions.collectionId); - let reference: DocumentReference; + let reference: DocumentReference; if (typeof val === 'string') { const path = basePath.append(val); @@ -1445,7 +1539,11 @@ export class Query { ); } - reference = new DocumentReference(this._firestore, basePath.append(val)); + reference = new DocumentReference( + this._firestore, + basePath.append(val), + this._queryOptions.converter + ); } else if (val instanceof DocumentReference) { reference = val; if (!basePath.isPrefixOf(reference._path)) { @@ -1493,8 +1591,8 @@ export class Query { * }); */ startAt( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { + ...fieldValuesOrDocumentSnapshot: Array | unknown> + ): Query { validateMinNumberOfArguments('Query.startAt', arguments, 1); const fieldOrders = this.createImplicitOrderBy( @@ -1531,8 +1629,8 @@ export class Query { * }); */ startAfter( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { + ...fieldValuesOrDocumentSnapshot: Array | unknown> + ): Query { validateMinNumberOfArguments('Query.startAfter', arguments, 1); const fieldOrders = this.createImplicitOrderBy( @@ -1568,8 +1666,8 @@ export class Query { * }); */ endBefore( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { + ...fieldValuesOrDocumentSnapshot: Array | unknown> + ): Query { validateMinNumberOfArguments('Query.endBefore', arguments, 1); const fieldOrders = this.createImplicitOrderBy( @@ -1605,8 +1703,8 @@ export class Query { * }); */ endAt( - ...fieldValuesOrDocumentSnapshot: Array - ): Query { + ...fieldValuesOrDocumentSnapshot: Array | unknown> + ): Query { validateMinNumberOfArguments('Query.endAt', arguments, 1); const fieldOrders = this.createImplicitOrderBy( @@ -1638,7 +1736,7 @@ export class Query { * }); * }); */ - get(): Promise { + get(): Promise> { return this._get(); } @@ -1648,9 +1746,9 @@ export class Query { * @private * @param {bytes=} transactionId A transaction ID. */ - _get(transactionId?: Uint8Array): Promise { + _get(transactionId?: Uint8Array): Promise> { const self = this; - const docs: QueryDocumentSnapshot[] = []; + const docs: Array> = []; return new Promise((resolve, reject) => { let readTime: Timestamp; @@ -1663,8 +1761,7 @@ export class Query { .on('data', result => { readTime = result.readTime; if (result.document) { - const document = result.document; - docs.push(document); + docs.push(result.document); } }) .on('end', () => { @@ -1675,7 +1772,7 @@ export class Query { docs.length, () => docs, () => { - const changes: DocumentChange[] = []; + const changes: Array> = []; for (let i = 0; i < docs.length; ++i) { changes.push(new DocumentChange('added', docs[i], -1, i)); } @@ -1823,7 +1920,16 @@ export class Query { proto.document, proto.readTime ); - this.push({document, readTime}); + const finalDoc = new DocumentSnapshotBuilder( + document.ref.withConverter(self._queryOptions.converter) + ); + // Recreate the QueryDocumentSnapshot with the DocumentReference + // containing the original converter. + finalDoc.fieldsProto = document._fieldsProto; + finalDoc.readTime = document.readTime; + finalDoc.createTime = document.createTime; + finalDoc.updateTime = document.updateTime; + this.push({document: finalDoc.build(), readTime}); } else { this.push({readTime}); } @@ -1879,13 +1985,17 @@ export class Query { * unsubscribe(); */ onSnapshot( - onNext: (snapshot: QuerySnapshot) => void, + onNext: (snapshot: QuerySnapshot) => void, onError?: (error: Error) => void ): () => void { validateFunction('onNext', onNext); validateFunction('onError', onError, {optional: true}); - const watch = new QueryWatch(this.firestore, this); + const watch = new QueryWatch( + this.firestore, + this, + this._queryOptions.converter + ); return watch.onSnapshot((readTime, size, docs, changes) => { onNext(new QuerySnapshot(this, readTime, size, docs, changes)); @@ -1899,8 +2009,8 @@ export class Query { * @private */ comparator(): ( - s1: QueryDocumentSnapshot, - s2: QueryDocumentSnapshot + s1: QueryDocumentSnapshot, + s2: QueryDocumentSnapshot ) => number { return (doc1, doc2) => { // Add implicit sorting by name, using the last specified direction. @@ -1940,6 +2050,56 @@ export class Query { return 0; }; } + + /** + * 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. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * @example + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * data: FirebaseFirestore.DocumentData + * ): Post { + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await 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 + * } + * + * @param converter Converts objects to and from Firestore. + * @return A Query that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): Query { + return new Query( + this.firestore, + this._queryOptions.withConverter(converter) + ); + } } /** @@ -1950,15 +2110,19 @@ export class Query { * @class * @extends Query */ -export class CollectionReference extends Query { +export class CollectionReference extends Query { /** * @hideconstructor * * @param firestore The Firestore Database client. * @param path The Path of this collection. */ - constructor(firestore: Firestore, path: ResourcePath) { - super(firestore, QueryOptions.forCollectionQuery(path)); + constructor( + firestore: Firestore, + path: ResourcePath, + converter?: FirestoreDataConverter + ) { + super(firestore, QueryOptions.forCollectionQuery(path, converter)); } /** @@ -1999,7 +2163,7 @@ export class CollectionReference extends Query { * let documentRef = collectionRef.parent; * console.log(`Parent name: ${documentRef.path}`); */ - get parent(): DocumentReference { + get parent(): DocumentReference { return new DocumentReference(this.firestore, this._queryOptions.parentPath); } @@ -2046,7 +2210,7 @@ export class CollectionReference extends Query { * } * }); */ - listDocuments(): Promise { + listDocuments(): Promise>> { const tag = requestTag(); return this.firestore.initializeIfNeeded(tag).then(() => { const parentPath = this._queryOptions.parentPath.toQualifiedResourcePath( @@ -2082,8 +2246,8 @@ export class CollectionReference extends Query { }); } - doc(): DocumentReference; - doc(documentPath: string): DocumentReference; + doc(): DocumentReference; + doc(documentPath: string): DocumentReference; /** * Gets a [DocumentReference]{@link DocumentReference} instance that * refers to the document at the specified path. If no path is specified, an @@ -2101,7 +2265,7 @@ export class CollectionReference extends Query { * console.log(`Reference with name: ${documentRefWithName.path}`); * console.log(`Reference with auto-id: ${documentRefWithAutoId.path}`); */ - doc(documentPath?: string): DocumentReference { + doc(documentPath?: string): DocumentReference { if (arguments.length === 0) { documentPath = autoId(); } else { @@ -2115,7 +2279,11 @@ export class CollectionReference extends Query { ); } - return new DocumentReference(this.firestore, path); + return new DocumentReference( + this.firestore, + path, + this._queryOptions.converter + ); } /** @@ -2134,7 +2302,7 @@ export class CollectionReference extends Query { * console.log(`Added document with name: ${documentReference.id}`); * }); */ - add(data: DocumentData): Promise { + add(data: T): Promise> { validateDocumentData('data', data, /*allowDeletes=*/ false); const documentRef = this.doc(); @@ -2148,12 +2316,65 @@ export class CollectionReference extends Query { * @return {boolean} true if this `CollectionReference` is equal to the * provided value. */ - isEqual(other: CollectionReference): boolean { + isEqual(other: CollectionReference): boolean { return ( this === other || (other instanceof CollectionReference && super.isEqual(other)) ); } + + /** + * 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. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * @example + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * data: FirebaseFirestore.DocumentData + * ): Post { + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await 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 + * } + * + * @param converter Converts objects to and from Firestore. + * @return A CollectionReference that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter + ): CollectionReference { + return new CollectionReference( + this.firestore, + this.resourcePath, + converter + ); + } } /** diff --git a/dev/src/transaction.ts b/dev/src/transaction.ts index ebefaeb86..2030ef123 100644 --- a/dev/src/transaction.ts +++ b/dev/src/transaction.ts @@ -92,7 +92,7 @@ export class Transaction { * @param {Query} query A query to execute. * @return {Promise} A QuerySnapshot for the retrieved data. */ - get(query: Query): Promise; + get(query: Query): Promise>; /** * Reads the document referenced by the provided `DocumentReference.` @@ -101,7 +101,7 @@ export class Transaction { * @param {DocumentReference} documentRef A reference to the document to be read. * @return {Promise} A DocumentSnapshot for the read data. */ - get(documentRef: DocumentReference): Promise; + get(documentRef: DocumentReference): Promise>; /** * Retrieve a document or a query result from the database. Holds a @@ -124,9 +124,9 @@ export class Transaction { * }); * }); */ - get( - refOrQuery: DocumentReference | Query - ): Promise { + get( + refOrQuery: DocumentReference | Query + ): Promise | QuerySnapshot> { if (!this._writeBatch.isEmpty) { throw new Error(READ_AFTER_WRITE_ERROR_MSG); } @@ -179,9 +179,9 @@ export class Transaction { * }); * }); */ - getAll( - ...documentRefsOrReadOptions: Array - ): Promise { + getAll( + ...documentRefsOrReadOptions: Array | ReadOptions> + ): Promise>> { if (!this._writeBatch.isEmpty) { throw new Error(READ_AFTER_WRITE_ERROR_MSG); } @@ -221,7 +221,7 @@ export class Transaction { * }); * }); */ - create(documentRef: DocumentReference, data: DocumentData): Transaction { + create(documentRef: DocumentReference, data: T): Transaction { this._writeBatch.create(documentRef, data); return this; } @@ -235,7 +235,7 @@ export class Transaction { * * @param {DocumentReference} documentRef A reference to the document to be * set. - * @param {DocumentData} data The object to serialize as the document. + * @param {T} data The object to serialize as the document. * @param {SetOptions=} options An object to configure the set behavior. * @param {boolean=} options.merge - If true, set() merges the values * specified in its data argument. Fields omitted from this set() call @@ -253,9 +253,9 @@ export class Transaction { * return Promise.resolve(); * }); */ - set( - documentRef: DocumentReference, - data: DocumentData, + set( + documentRef: DocumentReference, + data: T, options?: SetOptions ): Transaction { this._writeBatch.set(documentRef, data, options); @@ -300,18 +300,14 @@ export class Transaction { * }); * }); */ - update( - documentRef: DocumentReference, + update( + documentRef: DocumentReference, dataOrField: UpdateData | string | FieldPath, ...preconditionOrValues: Array ): Transaction { validateMinNumberOfArguments('Transaction.update', arguments, 2); - this._writeBatch.update.apply(this._writeBatch, [ - documentRef, - dataOrField, - ...preconditionOrValues, - ]); + this._writeBatch.update(documentRef, dataOrField, ...preconditionOrValues); return this; } @@ -336,8 +332,8 @@ export class Transaction { * return Promise.resolve(); * }); */ - delete( - documentRef: DocumentReference, + delete( + documentRef: DocumentReference, precondition?: PublicPrecondition ): this { this._writeBatch.delete(documentRef, precondition); @@ -419,10 +415,10 @@ export class Transaction { * @param documentRefsOrReadOptions An array of document references followed by * an optional ReadOptions object. */ -export function parseGetAllArguments( - documentRefsOrReadOptions: Array -): {documents: DocumentReference[]; fieldMask: FieldPath[] | null} { - let documents: DocumentReference[]; +export function parseGetAllArguments( + documentRefsOrReadOptions: Array | ReadOptions> +): {documents: Array>; fieldMask: FieldPath[] | null} { + let documents: Array>; let readOptions: ReadOptions | undefined = undefined; if (Array.isArray(documentRefsOrReadOptions[0])) { @@ -439,9 +435,9 @@ export function parseGetAllArguments( ) ) { readOptions = documentRefsOrReadOptions.pop() as ReadOptions; - documents = documentRefsOrReadOptions as DocumentReference[]; + documents = documentRefsOrReadOptions as Array>; } else { - documents = documentRefsOrReadOptions as DocumentReference[]; + documents = documentRefsOrReadOptions as Array>; } for (let i = 0; i < documents.length; ++i) { diff --git a/dev/src/types.ts b/dev/src/types.ts index a82b68349..851b11f54 100644 --- a/dev/src/types.ts +++ b/dev/src/types.ts @@ -92,6 +92,78 @@ export type UnaryMethod = ( // tslint:disable-next-line:no-any export type RBTree = any; +/** + * 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): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * data: FirebaseFirestore.DocumentData + * ): Post { + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await 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). + */ + toFirestore(modelObject: T): DocumentData; + + /** + * Called by the Firestore SDK to convert Firestore data into an object of + * type T. + */ + fromFirestore(data: DocumentData): T; +} + +/** + * A default converter to use when none is provided. + * @private + */ +export const defaultConverter: FirestoreDataConverter = { + toFirestore( + modelObject: FirebaseFirestore.DocumentData + ): FirebaseFirestore.DocumentData { + return modelObject; + }, + fromFirestore( + data: FirebaseFirestore.DocumentData + ): FirebaseFirestore.DocumentData { + return data; + }, +}; + /** * Settings used to directly configure a `Firestore` instance. */ @@ -141,7 +213,8 @@ export interface Settings { * mapped to values. */ export interface DocumentData { - [field: string]: unknown; + // tslint:disable-next-line:no-any + [field: string]: any; } /** diff --git a/dev/src/watch.ts b/dev/src/watch.ts index 4a6ef2a16..21a97d0eb 100644 --- a/dev/src/watch.ts +++ b/dev/src/watch.ts @@ -28,7 +28,12 @@ import {DocumentReference, Firestore, Query} from './index'; import {logger} from './logger'; import {QualifiedResourcePath} from './path'; import {Timestamp} from './timestamp'; -import {RBTree} from './types'; +import { + defaultConverter, + DocumentData, + FirestoreDataConverter, + RBTree, +} from './types'; import {requestTag} from './util'; import api = google.firestore.v1; @@ -43,7 +48,8 @@ const WATCH_TARGET_ID = 0x1; /*! * Sentinel value for a document remove. */ -const REMOVED = {} as DocumentSnapshotBuilder; +// tslint:disable-next-line:no-any +const REMOVED = {} as DocumentSnapshotBuilder; /*! * The change type for document change events. @@ -59,9 +65,9 @@ const ChangeType: {[k: string]: DocumentChangeType} = { * The comparator used for document watches (which should always get called with * the same document). */ -const DOCUMENT_WATCH_COMPARATOR = ( - doc1: QueryDocumentSnapshot, - doc2: QueryDocumentSnapshot +const DOCUMENT_WATCH_COMPARATOR = ( + doc1: QueryDocumentSnapshot, + doc2: QueryDocumentSnapshot ) => { assert(doc1 === doc2, 'Document watches only support one document.'); return 0; @@ -96,15 +102,15 @@ const EMPTY_FUNCTION = () => {}; * changed documents since the last snapshot delivered for this watch. */ -type DocumentComparator = ( - l: QueryDocumentSnapshot, - r: QueryDocumentSnapshot +type DocumentComparator = ( + l: QueryDocumentSnapshot, + r: QueryDocumentSnapshot ) => number; -interface DocumentChangeSet { +interface DocumentChangeSet { deletes: string[]; - adds: QueryDocumentSnapshot[]; - updates: QueryDocumentSnapshot[]; + adds: Array>; + updates: Array>; } /** @@ -114,7 +120,7 @@ interface DocumentChangeSet { * @class * @private */ -abstract class Watch { +abstract class Watch { protected readonly firestore: Firestore; private readonly backoff: ExponentialBackoff; private readonly requestTag: string; @@ -142,14 +148,14 @@ abstract class Watch { * A map of document names to QueryDocumentSnapshots for the last sent snapshot. * @private */ - private docMap = new Map(); + private docMap = new Map>(); /** * The accumulated map of document changes (keyed by document name) for the * current snapshot. * @private */ - private changeMap = new Map(); + private changeMap = new Map>(); /** * The current state of the query results. * @@ -175,8 +181,8 @@ abstract class Watch { private onNext: ( readTime: Timestamp, size: number, - docs: () => QueryDocumentSnapshot[], - changes: () => DocumentChange[] + docs: () => Array>, + changes: () => Array> ) => void; private onError: (error: Error) => void; @@ -187,7 +193,10 @@ abstract class Watch { * * @param firestore The Firestore Database client. */ - constructor(firestore: Firestore) { + constructor( + firestore: Firestore, + readonly _converter = defaultConverter as FirestoreDataConverter + ) { this.firestore = firestore; this.backoff = new ExponentialBackoff(); this.requestTag = requestTag(); @@ -202,7 +211,7 @@ abstract class Watch { * Returns a comparator for QueryDocumentSnapshots that is used to order the * document snapshots returned by this watch. */ - protected abstract getComparator(): DocumentComparator; + protected abstract getComparator(): DocumentComparator; /** * Starts a watch and attaches a listener for document change events. @@ -220,8 +229,8 @@ abstract class Watch { onNext: ( readTime: Timestamp, size: number, - docs: () => QueryDocumentSnapshot[], - changes: () => DocumentChange[] + docs: () => Array>, + changes: () => Array> ) => void, onError: (error: Error) => void ): () => void { @@ -269,10 +278,10 @@ abstract class Watch { * Splits up document changes into removals, additions, and updates. * @private */ - private extractCurrentChanges(readTime: Timestamp): DocumentChangeSet { + private extractCurrentChanges(readTime: Timestamp): DocumentChangeSet { const deletes: string[] = []; - const adds: QueryDocumentSnapshot[] = []; - const updates: QueryDocumentSnapshot[] = []; + const adds: Array> = []; + const updates: Array> = []; this.changeMap.forEach((value, name) => { if (value === REMOVED) { @@ -281,10 +290,10 @@ abstract class Watch { } } else if (this.docMap.has(name)) { value.readTime = readTime; - updates.push(value.build() as QueryDocumentSnapshot); + updates.push(value.build() as QueryDocumentSnapshot); } else { value.readTime = readTime; - adds.push(value.build() as QueryDocumentSnapshot); + adds.push(value.build() as QueryDocumentSnapshot); } }); @@ -505,8 +514,10 @@ abstract class Watch { if (changed) { logger('Watch.onData', this.requestTag, 'Received document change'); - const snapshot = new DocumentSnapshotBuilder(); - snapshot.ref = this.firestore.doc(relativeName); + const ref = this.firestore.doc(relativeName); + const snapshot = new DocumentSnapshotBuilder( + ref.withConverter(this._converter) + ); snapshot.fieldsProto = document.fields || {}; snapshot.createTime = Timestamp.fromProto(document.createTime!); snapshot.updateTime = Timestamp.fromProto(document.updateTime!); @@ -597,7 +608,7 @@ abstract class Watch { * Returns the corresponding DocumentChange event. * @private */ - private deleteDoc(name: string): DocumentChange { + private deleteDoc(name: string): DocumentChange { assert(this.docMap.has(name), 'Document to delete does not exist'); const oldDocument = this.docMap.get(name)!; const existing = this.docTree.find(oldDocument); @@ -612,7 +623,7 @@ abstract class Watch { * the corresponding DocumentChange event. * @private */ - private addDoc(newDocument: QueryDocumentSnapshot): DocumentChange { + private addDoc(newDocument: QueryDocumentSnapshot): DocumentChange { const name = newDocument.ref.path; assert(!this.docMap.has(name), 'Document to add already exists'); this.docTree = this.docTree.insert(newDocument, null); @@ -626,7 +637,9 @@ abstract class Watch { * Returns the DocumentChange event for successful modifications. * @private */ - private modifyDoc(newDocument: QueryDocumentSnapshot): DocumentChange | null { + private modifyDoc( + newDocument: QueryDocumentSnapshot + ): DocumentChange | null { const name = newDocument.ref.path; assert(this.docMap.has(name), 'Document to modify does not exist'); const oldDocument = this.docMap.get(name)!; @@ -649,9 +662,9 @@ abstract class Watch { * state. * @private */ - private computeSnapshot(readTime: Timestamp): DocumentChange[] { + private computeSnapshot(readTime: Timestamp): Array> { const changeSet = this.extractCurrentChanges(readTime); - const appliedChanges: DocumentChange[] = []; + const appliedChanges: Array> = []; // Process the sorted changes in the order that is expected by our clients // (removals, additions, and then modifications). We also need to sort the @@ -744,12 +757,15 @@ abstract class Watch { * * @private */ -export class DocumentWatch extends Watch { - constructor(firestore: Firestore, private readonly ref: DocumentReference) { - super(firestore); +export class DocumentWatch extends Watch { + constructor( + firestore: Firestore, + private readonly ref: DocumentReference + ) { + super(firestore, ref._converter); } - getComparator(): DocumentComparator { + getComparator(): DocumentComparator { return DOCUMENT_WATCH_COMPARATOR; } @@ -770,15 +786,19 @@ export class DocumentWatch extends Watch { * * @private */ -export class QueryWatch extends Watch { - private comparator: DocumentComparator; - - constructor(firestore: Firestore, private readonly query: Query) { - super(firestore); +export class QueryWatch extends Watch { + private comparator: DocumentComparator; + + constructor( + firestore: Firestore, + private readonly query: Query, + converter?: FirestoreDataConverter + ) { + super(firestore, converter); this.comparator = query.comparator(); } - getComparator(): DocumentComparator { + getComparator(): DocumentComparator { return this.query.comparator(); } diff --git a/dev/src/write-batch.ts b/dev/src/write-batch.ts index 1f3bf5103..6b2e161ee 100644 --- a/dev/src/write-batch.ts +++ b/dev/src/write-batch.ts @@ -166,7 +166,7 @@ export class WriteBatch { * * @param {DocumentReference} documentRef A reference to the document to be * created. - * @param {DocumentData} data The object to serialize as the document. + * @param {T} data The object to serialize as the document. * @returns {WriteBatch} This WriteBatch instance. Used for chaining * method calls. * @@ -180,19 +180,20 @@ export class WriteBatch { * console.log('Successfully executed batch.'); * }); */ - create(documentRef: DocumentReference, data: DocumentData): WriteBatch { + create(documentRef: DocumentReference, data: T): WriteBatch { validateDocumentReference('documentRef', documentRef); validateDocumentData('data', data, /* allowDeletes= */ false); this.verifyNotCommitted(); - const transform = DocumentTransform.fromObject(documentRef, data); + const firestoreData = documentRef._converter.toFirestore(data); + const transform = DocumentTransform.fromObject(documentRef, firestoreData); transform.validate(); const precondition = new Precondition({exists: false}); const op = () => { - const document = DocumentSnapshot.fromObject(documentRef, data); + const document = DocumentSnapshot.fromObject(documentRef, firestoreData); const write = !document.isEmpty || transform.isEmpty ? document.toProto() : null; @@ -231,8 +232,8 @@ export class WriteBatch { * console.log('Successfully executed batch.'); * }); */ - delete( - documentRef: DocumentReference, + delete( + documentRef: DocumentReference, precondition?: PublicPrecondition ): WriteBatch { validateDocumentReference('documentRef', documentRef); @@ -265,7 +266,7 @@ export class WriteBatch { * * @param {DocumentReference} documentRef A reference to the document to be * set. - * @param {DocumentData} data The object to serialize as the document. + * @param {T} data The object to serialize as the document. * @param {SetOptions=} options An object to configure the set behavior. * @param {boolean=} options.merge - If true, set() merges the values * specified in its data argument. Fields omitted from this set() call @@ -286,19 +287,19 @@ export class WriteBatch { * console.log('Successfully executed batch.'); * }); */ - set( - documentRef: DocumentReference, - data: DocumentData, + set( + documentRef: DocumentReference, + data: T, options?: SetOptions ): WriteBatch { validateSetOptions('options', options, {optional: true}); const mergeLeaves = options && options.merge === true; const mergePaths = options && options.mergeFields; - validateDocumentReference('documentRef', documentRef); + let firestoreData = documentRef._converter.toFirestore(data); validateDocumentData( 'data', - data, + firestoreData, /* allowDeletes= */ !!(mergePaths || mergeLeaves) ); @@ -308,19 +309,19 @@ export class WriteBatch { if (mergePaths) { documentMask = DocumentMask.fromFieldMask(options!.mergeFields!); - data = documentMask.applyTo(data); + firestoreData = documentMask.applyTo(firestoreData); } - const transform = DocumentTransform.fromObject(documentRef, data); + const transform = DocumentTransform.fromObject(documentRef, firestoreData); transform.validate(); const op = () => { - const document = DocumentSnapshot.fromObject(documentRef, data); + const document = DocumentSnapshot.fromObject(documentRef, firestoreData); if (mergePaths) { documentMask!.removeFields(transform.fields); } else { - documentMask = DocumentMask.fromObject(data); + documentMask = DocumentMask.fromObject(firestoreData); } const hasDocumentData = !document.isEmpty || !documentMask!.isEmpty; @@ -381,8 +382,8 @@ export class WriteBatch { * console.log('Successfully executed batch.'); * }); */ - update( - documentRef: DocumentReference, + update( + documentRef: DocumentReference, dataOrField: UpdateData | string | FieldPath, ...preconditionOrValues: Array< {lastUpdateTime?: Timestamp} | unknown | string | FieldPath diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index 7491e4891..81ecd158a 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -17,6 +17,7 @@ import {expect} from 'chai'; import { CollectionReference, DocumentData, + DocumentReference, DocumentSnapshot, FieldPath, FieldValue, @@ -30,7 +31,7 @@ import { WriteResult, } from '../src'; import {autoId, Deferred} from '../src/util'; -import {verifyInstance} from '../test/util/helpers'; +import {Post, postConverter, verifyInstance} from '../test/util/helpers'; const version = require('../../package.json').version; @@ -128,6 +129,17 @@ describe('Firestore class', () => { }); }); + it('getAll() supports generics', async () => { + const ref1 = randomCol.doc('doc1').withConverter(postConverter); + const ref2 = randomCol.doc('doc2').withConverter(postConverter); + await ref1.set(new Post('post1', 'author1')); + await ref2.set(new Post('post2', 'author2')); + + const docs = await firestore.getAll(ref1, ref2); + expect(docs[0].data()!.toString()).to.deep.equal('post1, by author1'); + expect(docs[1].data()!.toString()).to.deep.equal('post2, by author2'); + }); + it('cannot make calls after the client has been terminated', () => { const ref1 = randomCol.doc('doc1'); return firestore @@ -208,6 +220,18 @@ describe('CollectionReference class', () => { expect(existingDocs.map(doc => doc.id)).to.have.members(['a', 'c']); expect(missingDocs.map(doc => doc.id)).to.have.members(['b']); }); + + it('supports withConverter()', async () => { + const ref = firestore + .collection('col') + .withConverter(postConverter) + .doc('doc'); + await ref.set(new Post('post', 'author')); + const postData = await ref.get(); + const post = postData.data(); + expect(post).to.not.be.undefined; + expect(post!.toString()).to.equal('post, by author'); + }); }); describe('DocumentReference class', () => { @@ -967,6 +991,41 @@ describe('DocumentReference class', () => { await Promise.all(documentResults.map(d => d.promise)); unsubscribeCallbacks.forEach(c => c()); }); + + it('handles query snapshots with converters', async () => { + const setupDeferred = new Deferred(); + const resultsDeferred = new Deferred>(); + const ref = randomCol.doc('doc').withConverter(postConverter); + const unsubscribe = randomCol + .where('title', '==', 'post') + .withConverter(postConverter) + .onSnapshot(snapshot => { + if (snapshot.size === 0) { + setupDeferred.resolve(); + } + if (snapshot.size === 1) { + resultsDeferred.resolve(snapshot); + } + }); + + await setupDeferred.promise; + await ref.set(new Post('post', 'author')); + const snapshot = await resultsDeferred.promise; + expect(snapshot.docs[0].data().toString()).to.equal('post, by author'); + unsubscribe(); + }); + }); + + it('supports withConverter()', async () => { + const ref = firestore + .collection('col') + .doc('doc') + .withConverter(postConverter); + await ref.set(new Post('post', 'author')); + const postData = await ref.get(); + const post = postData.data(); + expect(post).to.not.be.undefined; + expect(post!.toString()).to.equal('post, by author'); }); }); @@ -1840,6 +1899,31 @@ describe('Transaction class', () => { }); }); + it('getAll() supports withConverter()', async () => { + const ref1 = randomCol.doc('doc1').withConverter(postConverter); + const ref2 = randomCol.doc('doc2').withConverter(postConverter); + await ref1.set(new Post('post1', 'author1')); + await ref2.set(new Post('post2', 'author2')); + + const docs = await firestore.runTransaction(updateFunction => { + return updateFunction.getAll(ref1, ref2); + }); + + expect(docs[0].data()!.toString()).to.equal('post1, by author1'); + expect(docs[1].data()!.toString()).to.equal('post2, by author2'); + }); + + it('set() and get() support withConverter()', async () => { + const ref = randomCol.doc('doc1').withConverter(postConverter); + await ref.set(new Post('post', 'author')); + await firestore.runTransaction(async txn => { + await txn.get(ref); + await txn.set(ref, new Post('new post', 'author')); + }); + const doc = await ref.get(); + expect(doc.data()!.toString()).to.equal('new post, by author'); + }); + it('has get() with query', () => { const ref = randomCol.doc('doc'); const query = randomCol.where('foo', '==', 'bar'); diff --git a/dev/test/collection.ts b/dev/test/collection.ts index 6bf8b00b2..7f6a65d44 100644 --- a/dev/test/collection.ts +++ b/dev/test/collection.ts @@ -13,6 +13,7 @@ // limitations under the License. import {expect} from 'chai'; +import * as through2 from 'through2'; import {DocumentReference, Firestore, setLogFunction} from '../src'; import { @@ -21,8 +22,13 @@ import { DATABASE_ROOT, document, InvalidApiUsage, + Post, + postConverter, + requestEquals, response, + set, verifyInstance, + writeResult, } from './util/helpers'; // Change the argument to 'console.log' to enable debug output. @@ -169,4 +175,50 @@ describe('Collection interface', () => { expect(coll1.isEqual(coll1Equals)).to.be.ok; expect(coll1.isEqual(coll2)).to.not.be.ok; }); + + it('for CollectionReference.withConverter().doc()', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: doc, + }); + requestEquals(request, expectedRequest); + + return response(writeResult(1)); + }, + batchGetDocuments: () => { + const stream = through2.obj(); + setImmediate(() => { + stream.push({found: doc, readTime: {seconds: 5, nanos: 6}}); + stream.push(null); + }); + + return stream; + }, + }; + + return createInstance(overrides).then(async firestore => { + const docRef = firestore + .collection('collectionId') + .withConverter(postConverter) + .doc('documentId'); + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + expect(post).to.not.be.undefined; + expect(post!.toString()).to.equal('post, by author'); + }); + }); + + it('drops the converter when calling CollectionReference.parent()', () => { + return createInstance().then(async firestore => { + const postsCollection = firestore + .collection('users/user1/posts') + .withConverter(postConverter); + + const usersCollection = postsCollection.parent; + expect(usersCollection!.isEqual(firestore.doc('users/user1'))).to.be.true; + }); + }); }); diff --git a/dev/test/document.ts b/dev/test/document.ts index 8d9a8594b..19a655815 100644 --- a/dev/test/document.ts +++ b/dev/test/document.ts @@ -14,9 +14,11 @@ import {expect} from 'chai'; import {GoogleError, Status} from 'google-gax'; +import * as through2 from 'through2'; import { DocumentReference, + DocumentSnapshot, FieldPath, FieldValue, Firestore, @@ -32,6 +34,8 @@ import { found, InvalidApiUsage, missing, + Post, + postConverter, remove, requestEquals, response, @@ -2035,3 +2039,51 @@ describe('listCollections() method', () => { }); }); }); + +describe('withConverter() support', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('for DocumentReference.get()', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: doc, + }); + requestEquals(request, expectedRequest); + + return response(writeResult(1)); + }, + batchGetDocuments: () => { + const stream = through2.obj(); + setImmediate(() => { + stream.push({found: doc, readTime: {seconds: 5, nanos: 6}}); + stream.push(null); + }); + + return stream; + }, + }; + + return createInstance(overrides).then(async firestore => { + const docRef = firestore + .collection('collectionId') + .doc('documentId') + .withConverter(postConverter); + + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + expect(post).to.not.be.undefined; + expect(post!.toString()).to.equal('post, by author'); + }); + }); +}); diff --git a/dev/test/query.ts b/dev/test/query.ts index f2c546c78..38f4b552b 100644 --- a/dev/test/query.ts +++ b/dev/test/query.ts @@ -26,8 +26,14 @@ import { createInstance, document, InvalidApiUsage, + Post, + postConverter, + requestEquals, + response, + set, stream, verifyInstance, + writeResult, } from './util/helpers'; import api = google.firestore.v1; @@ -43,11 +49,11 @@ function snapshot( data: DocumentData ): Promise { return createInstance().then(firestore => { - const snapshot = new DocumentSnapshotBuilder(); const path = QualifiedResourcePath.fromSlashSeparatedString( `${DATABASE_ROOT}/documents/${relativePath}` ); - snapshot.ref = new DocumentReference(firestore, path); + const ref = new DocumentReference(firestore, path); + const snapshot = new DocumentSnapshotBuilder(ref); snapshot.fieldsProto = firestore['_serializer']!.encodeFields(data); snapshot.readTime = Timestamp.fromMillis(0); snapshot.createTime = Timestamp.fromMillis(0); @@ -621,6 +627,37 @@ describe('query interface', () => { }); }); }); + + it('for Query.withConverter()', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: doc, + }); + requestEquals(request, expectedRequest); + return response(writeResult(1)); + }, + runQuery: request => { + queryEquals(request, fieldFilters('title', 'EQUAL', 'post')); + return stream({document: doc, readTime: {seconds: 5, nanos: 6}}); + }, + }; + + return createInstance(overrides).then(async firestore => { + await firestore + .collection('collectionId') + .doc('documentId') + .set({title: 'post', author: 'author'}); + const posts = await firestore + .collection('collectionId') + .where('title', '==', 'post') + .withConverter(postConverter) + .get(); + expect(posts.size).to.equal(1); + expect(posts.docs[0].data().toString()).to.equal('post, by author'); + }); + }); }); describe('where() interface', () => { diff --git a/dev/test/typescript.ts b/dev/test/typescript.ts index 80993ddf2..33c59db98 100644 --- a/dev/test/typescript.ts +++ b/dev/test/typescript.ts @@ -31,6 +31,7 @@ import DocumentData = FirebaseFirestore.DocumentData; import GeoPoint = FirebaseFirestore.GeoPoint; import Precondition = FirebaseFirestore.Precondition; import SetOptions = FirebaseFirestore.SetOptions; +import FirestoreDataConverter = FirebaseFirestore.FirestoreDataConverter; import Timestamp = FirebaseFirestore.Timestamp; import Settings = FirebaseFirestore.Settings; @@ -52,6 +53,15 @@ xdescribe('firestore.d.ts', () => { const updateData: UpdateData = {}; const documentData: DocumentData = {}; + const defaultConverter: FirestoreDataConverter = { + toFirestore(modelObject: DocumentData): DocumentData { + return modelObject; + }, + fromFirestore(data: DocumentData): DocumentData { + return data; + }, + }; + FirebaseFirestore.setLogFunction(console.log); it('has typings for Firestore', () => { @@ -153,6 +163,7 @@ xdescribe('firestore.d.ts', () => { const subcollection: CollectionReference = docRef.collection('coll'); docRef.listCollections().then((collections: CollectionReference[]) => {}); docRef.get().then((snapshot: DocumentSnapshot) => {}); + docRef.withConverter(defaultConverter); docRef .create(documentData) .then((writeResult: FirebaseFirestore.WriteResult) => {}); @@ -260,6 +271,7 @@ xdescribe('firestore.d.ts', () => { query = query.endBefore(snapshot); query = query.endBefore('foo'); query = query.endBefore('foo', 'bar'); + query = query.withConverter(defaultConverter); query.get().then((results: QuerySnapshot) => {}); query.stream().on('data', () => {}); let unsubscribe: () => void = query.onSnapshot( @@ -305,6 +317,7 @@ xdescribe('firestore.d.ts', () => { const docRef1: DocumentReference = collRef.doc(); const docRef2: DocumentReference = collRef.doc('doc'); collRef.add(documentData).then((docRef: DocumentReference) => {}); + collRef.withConverter(defaultConverter); const list: Promise = collRef.listDocuments(); const equals: boolean = collRef.isEqual(collRef); }); diff --git a/dev/test/util/helpers.ts b/dev/test/util/helpers.ts index 614e30db2..655b73d87 100644 --- a/dev/test/util/helpers.ts +++ b/dev/test/util/helpers.ts @@ -21,7 +21,7 @@ import * as through2 from 'through2'; import * as proto from '../../protos/firestore_v1_proto_api'; import {Firestore} from '../../src'; import {ClientPool} from '../../src/pool'; -import {GapicClient} from '../../src/types'; +import {DocumentData, GapicClient} from '../../src/types'; import api = proto.google.firestore.v1; @@ -334,3 +334,21 @@ export function stream(...elements: Array): Duplex { export function response(result: T): Promise<[T, unknown, unknown]> { return Promise.resolve([result, undefined, undefined]); } + +/** Sample user object class used in tests. */ +export class Post { + constructor(readonly title: string, readonly author: string) {} + toString(): string { + return this.title + ', by ' + this.author; + } +} + +/** Converts Post objects to and from Firestore in tests. */ +export const postConverter = { + toFirestore(post: Post): DocumentData { + return {title: post.title, author: post.author}; + }, + fromFirestore(data: DocumentData): Post { + return new Post(data.title, data.author); + }, +}; diff --git a/dev/test/watch.ts b/dev/test/watch.ts index efdd3f6c9..bf82d2713 100644 --- a/dev/test/watch.ts +++ b/dev/test/watch.ts @@ -141,8 +141,7 @@ function snapshot( ref: DocumentReference, data: DocumentData ): QueryDocumentSnapshot { - const snapshot = new DocumentSnapshotBuilder(); - snapshot.ref = ref; + const snapshot = new DocumentSnapshotBuilder(ref); snapshot.fieldsProto = ref.firestore._serializer!.encodeFields(data); snapshot.readTime = new Timestamp(0, 0); snapshot.createTime = new Timestamp(0, 0); diff --git a/types/firestore.d.ts b/types/firestore.d.ts index 038c90018..74eadbea9 100644 --- a/types/firestore.d.ts +++ b/types/firestore.d.ts @@ -40,7 +40,62 @@ declare namespace FirebaseFirestore { * @param logger A log function that takes a message (such as `console.log`) or * `null` to turn off logging. */ - function setLogFunction(logger: ((msg:string) => void) | null) : void; + function setLogFunction(logger: ((msg:string) => void) | null) : 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): FirebaseFirestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * data: FirebaseFirestore.DocumentData + * ): Post { + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await 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). + */ + toFirestore(modelObject: T): DocumentData; + + /** + * Called by the Firestore SDK to convert Firestore data into an object of + * type T. + */ + fromFirestore(data: DocumentData): T; + } /** * Settings used to directly configure a `Firestore` instance. @@ -126,7 +181,7 @@ declare namespace FirebaseFirestore { * @param collectionPath A slash-separated path to a collection. * @return The `CollectionReference` instance. */ - collection(collectionPath: string): CollectionReference; + collection(collectionPath: string): CollectionReference; /** * Gets a `DocumentReference` instance that refers to the document at the @@ -135,7 +190,7 @@ declare namespace FirebaseFirestore { * @param documentPath A slash-separated path to a document. * @return The `DocumentReference` instance. */ - doc(documentPath: string): DocumentReference; + doc(documentPath: string): DocumentReference; /** * Creates and returns a new Query that includes all documents in the @@ -147,7 +202,7 @@ declare namespace FirebaseFirestore { * will be included. Cannot contain a slash. * @return The created Query. */ - collectionGroup(collectionId: string): Query; + collectionGroup(collectionId: string): Query; /** * Retrieves multiple documents from Firestore. @@ -162,8 +217,8 @@ declare namespace FirebaseFirestore { * @return A Promise that resolves with an array of resulting document * snapshots. */ - getAll(...documentRefsOrReadOptions: Array): - Promise; + getAll(...documentRefsOrReadOptions: Array | ReadOptions>): + Promise>>; /** * Terminates the Firestore client and closes all open streams. @@ -178,7 +233,7 @@ declare namespace FirebaseFirestore { * * @returns A Promise that resolves with an array of CollectionReferences. */ - listCollections() : Promise; + listCollections() : Promise>>; /** * Executes the given updateFunction and commits the changes applied within @@ -256,7 +311,7 @@ declare namespace FirebaseFirestore { * @param query A query to execute. * @return A QuerySnapshot for the retrieved data. */ - get(query: Query): Promise; + get(query: Query): Promise>; /** * Reads the document referenced by the provided `DocumentReference.` @@ -265,7 +320,7 @@ declare namespace FirebaseFirestore { * @param documentRef A reference to the document to be read. * @return A DocumentSnapshot for the read data. */ - get(documentRef: DocumentReference): Promise; + get(documentRef: DocumentReference): Promise>; /** * Retrieves multiple documents from Firestore. Holds a pessimistic lock on @@ -281,8 +336,8 @@ declare namespace FirebaseFirestore { * @return A Promise that resolves with an array of resulting document * snapshots. */ - getAll(...documentRefsOrReadOptions: Array): - Promise; + getAll(...documentRefsOrReadOptions: Array): + Promise>>; /** * Create the document referred to by the provided `DocumentReference`. @@ -293,7 +348,7 @@ 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: DocumentData): Transaction; + create(documentRef: DocumentReference, data: T): Transaction; /** * Writes to the document referred to by the provided `DocumentReference`. @@ -305,7 +360,7 @@ declare namespace FirebaseFirestore { * @param options An object to configure the set behavior. * @return This `Transaction` instance. Used for chaining method calls. */ - set(documentRef: DocumentReference, data: DocumentData, + set(documentRef: DocumentReference, data: T, options?: SetOptions): Transaction; /** @@ -322,7 +377,7 @@ 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; /** @@ -344,7 +399,7 @@ declare namespace FirebaseFirestore { * update. * @return This `Transaction` instance. Used for chaining method calls. */ - update(documentRef: DocumentReference, field: string|FieldPath, value:any, + update(documentRef: DocumentReference, field: string|FieldPath, value:any, ...fieldsOrPrecondition: any[]): Transaction; /** @@ -354,7 +409,7 @@ declare namespace FirebaseFirestore { * @param precondition A Precondition to enforce for this delete. * @return This `Transaction` instance. Used for chaining method calls. */ - delete(documentRef: DocumentReference, + delete(documentRef: DocumentReference, precondition?: Precondition): Transaction; } @@ -381,7 +436,7 @@ 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: DocumentData): WriteBatch; + create(documentRef: DocumentReference, data: T): WriteBatch; /** * Write to the document referred to by the provided `DocumentReference`. @@ -393,7 +448,7 @@ declare namespace FirebaseFirestore { * @param options An object to configure the set behavior. * @return This `WriteBatch` instance. Used for chaining method calls. */ - set(documentRef: DocumentReference, data: DocumentData, + set(documentRef: DocumentReference, data: T, options?: SetOptions): WriteBatch; /** @@ -410,7 +465,7 @@ 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; /** @@ -431,7 +486,7 @@ declare namespace FirebaseFirestore { * to update, optionally followed a `Precondition` to enforce on this update. * @return This `WriteBatch` instance. Used for chaining method calls. */ - update(documentRef: DocumentReference, field: string|FieldPath, value:any, + update(documentRef: DocumentReference, field: string|FieldPath, value:any, ...fieldsOrPrecondition: any[]): WriteBatch; /** @@ -441,7 +496,7 @@ declare namespace FirebaseFirestore { * @param precondition A Precondition to enforce for this delete. * @return This `WriteBatch` instance. Used for chaining method calls. */ - delete(documentRef: DocumentReference, + delete(documentRef: DocumentReference, precondition?: Precondition): WriteBatch; /** @@ -535,7 +590,7 @@ declare namespace FirebaseFirestore { * the referenced location may or may not exist. A `DocumentReference` can * also be used to create a `CollectionReference` to a subcollection. */ - export class DocumentReference { + export class DocumentReference { private constructor(); /** The identifier of the document within its collection. */ @@ -550,7 +605,7 @@ declare namespace FirebaseFirestore { /** * A reference to the Collection to which this DocumentReference belongs. */ - readonly parent: CollectionReference; + readonly parent: CollectionReference; /** * A string representing the path of the referenced document (relative @@ -565,14 +620,14 @@ declare namespace FirebaseFirestore { * @param collectionPath A slash-separated path to a collection. * @return The `CollectionReference` instance. */ - collection(collectionPath: string): CollectionReference; + collection(collectionPath: string): CollectionReference; /** * Fetches the subcollections that are direct children of this document. * * @returns A Promise that resolves with an array of CollectionReferences. */ - listCollections() : Promise; + listCollections() : Promise>>; /** * Creates a document referred to by this `DocumentReference` with the @@ -581,7 +636,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: DocumentData): Promise; + create(data: T): Promise; /** * Writes to the document referred to by this `DocumentReference`. If the @@ -592,7 +647,7 @@ 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: DocumentData, options?: SetOptions): Promise; + set(data: T, options?: SetOptions): Promise; /** * Updates fields in the document referred to by this `DocumentReference`. @@ -642,7 +697,7 @@ declare namespace FirebaseFirestore { * @return A Promise resolved with a DocumentSnapshot containing the * current document contents. */ - get(): Promise; + get(): Promise>; /** * Attaches a listener for DocumentSnapshot events. @@ -654,7 +709,7 @@ declare namespace FirebaseFirestore { * @return An unsubscribe function that can be called to cancel * the snapshot listener. */ - onSnapshot(onNext: (snapshot: DocumentSnapshot) => void, + onSnapshot(onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void): () => void; /** @@ -663,7 +718,21 @@ declare namespace FirebaseFirestore { * @param other The `DocumentReference` to compare against. * @return true if this `DocumentReference` is equal to the provided one. */ - isEqual(other: DocumentReference): boolean; + isEqual(other: DocumentReference): boolean; + + /** + * 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. + * + * @param converter Converts objects to and from Firestore. + * @return A DocumentReference that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter + ): DocumentReference; } /** @@ -675,14 +744,14 @@ declare namespace FirebaseFirestore { * access will return 'undefined'. You can use the `exists` property to * explicitly verify a document's existence. */ - export class DocumentSnapshot { + export class DocumentSnapshot { protected constructor(); /** True if the document exists. */ readonly exists: boolean; /** A `DocumentReference` to the document location. */ - readonly ref: DocumentReference; + readonly ref: DocumentReference; /** * The ID of the document for which this `DocumentSnapshot` contains data. @@ -712,7 +781,7 @@ declare namespace FirebaseFirestore { * * @return An Object containing all fields in the document. */ - data(): DocumentData | undefined; + data(): T | undefined; /** * Retrieves the field specified by `fieldPath`. @@ -730,7 +799,7 @@ declare namespace FirebaseFirestore { * @param other The `DocumentSnapshot` to compare against. * @return true if this `DocumentSnapshot` is equal to the provided one. */ - isEqual(other: DocumentSnapshot): boolean; + isEqual(other: DocumentSnapshot): boolean; } /** @@ -744,7 +813,7 @@ declare namespace FirebaseFirestore { * `exists` property will always be true and `data()` will never return * 'undefined'. */ - export class QueryDocumentSnapshot extends DocumentSnapshot { + export class QueryDocumentSnapshot extends DocumentSnapshot { private constructor(); /** @@ -764,7 +833,7 @@ declare namespace FirebaseFirestore { * @override * @return An Object containing all fields in the document. */ - data(): DocumentData; + data(): T; } /** @@ -785,7 +854,7 @@ declare namespace FirebaseFirestore { * 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 class Query { + export class Query { protected constructor(); /** @@ -807,7 +876,7 @@ declare namespace FirebaseFirestore { * @param value The value for comparison * @return The created Query. */ - where(fieldPath: string|FieldPath, opStr: WhereFilterOp, value: any): Query; + where(fieldPath: string|FieldPath, opStr: WhereFilterOp, value: any): Query; /** * Creates and returns a new Query that's additionally sorted by the @@ -823,7 +892,7 @@ declare namespace FirebaseFirestore { */ orderBy( fieldPath: string|FieldPath, directionStr?: OrderByDirection - ): Query; + ): Query; /** * Creates and returns a new Query that's additionally limited to only @@ -835,7 +904,7 @@ declare namespace FirebaseFirestore { * @param limit The maximum number of items to return. * @return The created Query. */ - limit(limit: number): Query; + limit(limit: number): Query; /** * Specifies the offset of the returned results. @@ -846,7 +915,7 @@ declare namespace FirebaseFirestore { * @param offset The offset to apply to the Query results. * @return The created Query. */ - offset(offset: number): Query; + offset(offset: number): Query; /** * Creates and returns a new Query instance that applies a field mask to @@ -860,7 +929,7 @@ declare namespace FirebaseFirestore { * @param field The field paths to return. * @return The created Query. */ - select(...field: (string | FieldPath)[]): Query; + select(...field: (string | FieldPath)[]): Query; /** * Creates and returns a new Query that starts at the provided document @@ -871,7 +940,7 @@ declare namespace FirebaseFirestore { * @param snapshot The snapshot of the document to start after. * @return The created Query. */ - startAt(snapshot: DocumentSnapshot): Query; + startAt(snapshot: DocumentSnapshot): Query; /** * Creates and returns a new Query that starts at the provided fields @@ -882,7 +951,7 @@ declare namespace FirebaseFirestore { * of the query's order by. * @return The created Query. */ - startAt(...fieldValues: any[]): Query; + startAt(...fieldValues: any[]): Query; /** * Creates and returns a new Query that starts after the provided document @@ -893,7 +962,7 @@ declare namespace FirebaseFirestore { * @param snapshot The snapshot of the document to start after. * @return The created Query. */ - startAfter(snapshot: DocumentSnapshot): Query; + startAfter(snapshot: DocumentSnapshot): Query; /** * Creates and returns a new Query that starts after the provided fields @@ -904,7 +973,7 @@ declare namespace FirebaseFirestore { * of the query's order by. * @return The created Query. */ - startAfter(...fieldValues: any[]): Query; + startAfter(...fieldValues: any[]): Query; /** * Creates and returns a new Query that ends before the provided document @@ -915,7 +984,7 @@ declare namespace FirebaseFirestore { * @param snapshot The snapshot of the document to end before. * @return The created Query. */ - endBefore(snapshot: DocumentSnapshot): Query; + endBefore(snapshot: DocumentSnapshot): Query; /** * Creates and returns a new Query that ends before the provided fields @@ -926,7 +995,7 @@ declare namespace FirebaseFirestore { * of the query's order by. * @return The created Query. */ - endBefore(...fieldValues: any[]): Query; + endBefore(...fieldValues: any[]): Query; /** * Creates and returns a new Query that ends at the provided document @@ -937,7 +1006,7 @@ declare namespace FirebaseFirestore { * @param snapshot The snapshot of the document to end at. * @return The created Query. */ - endAt(snapshot: DocumentSnapshot): Query; + endAt(snapshot: DocumentSnapshot): Query; /** * Creates and returns a new Query that ends at the provided fields @@ -948,7 +1017,7 @@ declare namespace FirebaseFirestore { * of the query's order by. * @return The created Query. */ - endAt(...fieldValues: any[]): Query; + endAt(...fieldValues: any[]): Query; /** * Executes the query and returns the results as a `QuerySnapshot`. @@ -984,6 +1053,17 @@ declare namespace FirebaseFirestore { * @return true if this `Query` is equal to the provided one. */ isEqual(other: Query): boolean; + + /** + * 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. + * + * @param converter Converts objects to and from Firestore. + * @return A Query that uses the provided converter. + */ + withConverter(converter: FirestoreDataConverter): Query; } /** @@ -993,17 +1073,17 @@ declare namespace FirebaseFirestore { * number of documents can be determined via the `empty` and `size` * properties. */ - export class QuerySnapshot { + export class QuerySnapshot { private constructor(); /** * The query on which you called `get` or `onSnapshot` in order to get this * `QuerySnapshot`. */ - readonly query: Query; + readonly query: Query; /** An array of all the documents in the QuerySnapshot. */ - readonly docs: QueryDocumentSnapshot[]; + readonly docs: Array>; /** The number of documents in the QuerySnapshot. */ readonly size: number; @@ -1029,7 +1109,7 @@ declare namespace FirebaseFirestore { * @param thisArg The `this` binding for the callback. */ forEach( - callback: (result: QueryDocumentSnapshot) => void, thisArg?: any + callback: (result: QueryDocumentSnapshot) => void, thisArg?: any ): void; /** @@ -1039,7 +1119,7 @@ declare namespace FirebaseFirestore { * @param other The `QuerySnapshot` to compare against. * @return true if this `QuerySnapshot` is equal to the provided one. */ - isEqual(other: QuerySnapshot): boolean; + isEqual(other: QuerySnapshot): boolean; } /** @@ -1051,12 +1131,12 @@ declare namespace FirebaseFirestore { * A `DocumentChange` represents a change to the documents matching a query. * It contains the document affected and the type of change that occurred. */ - export interface DocumentChange { + export interface DocumentChange { /** The type of change ('added', 'modified', or 'removed'). */ readonly type: DocumentChangeType; /** The document affected by this change. */ - readonly doc: QueryDocumentSnapshot; + readonly doc: QueryDocumentSnapshot; /** * The index of the changed document in the result set immediately prior to @@ -1080,7 +1160,7 @@ declare namespace FirebaseFirestore { * @param other The `DocumentChange` to compare against. * @return true if this `DocumentChange` is equal to the provided one. */ - isEqual(other: DocumentChange): boolean; + isEqual(other: DocumentChange): boolean; } /** @@ -1088,7 +1168,7 @@ declare namespace FirebaseFirestore { * document references, and querying for documents (using the methods * inherited from `Query`). */ - export class CollectionReference extends Query { + export class CollectionReference extends Query { private constructor(); /** The identifier of the collection. */ @@ -1098,7 +1178,7 @@ declare namespace FirebaseFirestore { * A reference to the containing Document if this is a subcollection, else * null. */ - readonly parent: DocumentReference|null; + readonly parent: DocumentReference | null; /** * A string representing the path of the referenced collection (relative @@ -1118,7 +1198,7 @@ declare namespace FirebaseFirestore { * @return {Promise} The list of documents in this * collection. */ - listDocuments(): Promise; + listDocuments(): Promise>>; /** * Get a `DocumentReference` for a randomly-named document within this @@ -1127,7 +1207,7 @@ declare namespace FirebaseFirestore { * * @return The `DocumentReference` instance. */ - doc(): DocumentReference; + doc(): DocumentReference; /** * Get a `DocumentReference` for the document within the collection at the @@ -1136,7 +1216,7 @@ declare namespace FirebaseFirestore { * @param documentPath A slash-separated path to a document. * @return The `DocumentReference` instance. */ - doc(documentPath: string): DocumentReference; + doc(documentPath: string): DocumentReference; /** * Add a new document to this collection with the specified data, assigning @@ -1146,7 +1226,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: DocumentData): Promise; + add(data: T): Promise>; /** * Returns true if this `CollectionReference` is equal to the provided one. @@ -1154,7 +1234,20 @@ declare namespace FirebaseFirestore { * @param other The `CollectionReference` to compare against. * @return true if this `CollectionReference` is equal to the provided one. */ - isEqual(other: CollectionReference): boolean; + isEqual(other: CollectionReference): boolean; + + /** + * 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. + * + * @param converter Converts objects to and from Firestore. + * @return A CollectionReference that uses the provided converter. + */ + withConverter( + converter: FirestoreDataConverter + ): CollectionReference; } /**