diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 3b0b5aa8538..d3658115bb6 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -7080,13 +7080,73 @@ declare namespace firebase.firestore { * * */ export function setLogLevel(logLevel: LogLevel): void; + /** + * Converter used by `withConverter()` to transform user objects of type T + * into Firestore data. + * + * Using the converter allows you to specify generic type arguments when + * storing and retrieving objects from Firestore. + * + * @example + * ```typescript + * class Post { + * constructor(readonly title: string, readonly author: string) {} + * + * toString(): string { + * return this.title + ', by ' + this.author; + * } + * } + * + * const postConverter = { + * toFirestore(post: Post): firebase.firestore.DocumentData { + * return {title: post.title, author: post.author}; + * }, + * fromFirestore( + * snapshot: firebase.firestore.QueryDocumentSnapshot, + * options: firebase.firestore.SnapshotOptions + * ): Post { + * const data = snapshot.data(options)!; + * return new Post(data.title, data.author); + * } + * }; + * + * const postSnap = await firebase.firestore() + * .collection('posts') + * .withConverter(postConverter) + * .doc().get(); + * const post = postSnap.data(); + * if (post !== undefined) { + * post.title; // string + * post.toString(); // Should be defined + * post.someNonExistentProperty; // TS error + * } + * ``` + */ + export interface FirestoreDataConverter { + /** + * Called by the Firestore SDK to convert a custom model object of type T + * into a plain Javascript object (suitable for writing directly to the + * Firestore database). + */ + toFirestore(modelObject: T): DocumentData; + + /** + * Called by the Firestore SDK to convert Firestore data into an object of + * type T. You can access your data by calling: `snapshot.data(options)`. + * + * @param snapshot A QueryDocumentSnapshot containing your data and metadata. + * @param options The SnapshotOptions from the initial call to `data()`. + */ + fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): T; + } + /** * The Cloud Firestore service interface. * @@ -7133,7 +7193,7 @@ declare namespace firebase.firestore { * @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 @@ -7142,7 +7202,7 @@ declare namespace firebase.firestore { * @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 @@ -7154,7 +7214,7 @@ declare namespace firebase.firestore { * will be included. Cannot contain a slash. * @return The created Query. */ - collectionGroup(collectionId: string): Query; + collectionGroup(collectionId: string): Query; /** * Executes the given `updateFunction` and then attempts to commit the changes @@ -7384,7 +7444,7 @@ declare namespace firebase.firestore { * from 0 to 999,999,999 inclusive. */ constructor(seconds: number, nanoseconds: number); - + /** * Creates a new timestamp with the current date, with millisecond precision. * @@ -7504,7 +7564,7 @@ declare namespace firebase.firestore { * @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>; /** * Writes to the document referred to by the provided `DocumentReference`. @@ -7516,9 +7576,9 @@ declare namespace firebase.firestore { * @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; @@ -7533,7 +7593,7 @@ declare namespace firebase.firestore { * within the document. * @return This `Transaction` instance. Used for chaining method calls. */ - update(documentRef: DocumentReference, data: UpdateData): Transaction; + update(documentRef: DocumentReference, data: UpdateData): Transaction; /** * Updates fields in the document referred to by the provided @@ -7551,7 +7611,7 @@ declare namespace firebase.firestore { * to the backend (Note that it won't resolve while you're offline). */ update( - documentRef: DocumentReference, + documentRef: DocumentReference, field: string | FieldPath, value: any, ...moreFieldsAndValues: any[] @@ -7563,7 +7623,7 @@ declare namespace firebase.firestore { * @param documentRef A reference to the document to be deleted. * @return This `Transaction` instance. Used for chaining method calls. */ - delete(documentRef: DocumentReference): Transaction; + delete(documentRef: DocumentReference): Transaction; } /** @@ -7590,9 +7650,9 @@ declare namespace firebase.firestore { * @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; @@ -7607,7 +7667,7 @@ declare namespace firebase.firestore { * within the document. * @return This `WriteBatch` instance. Used for chaining method calls. */ - update(documentRef: DocumentReference, data: UpdateData): WriteBatch; + update(documentRef: DocumentReference, data: UpdateData): WriteBatch; /** * Updates fields in the document referred to by this `DocumentReference`. @@ -7624,7 +7684,7 @@ declare namespace firebase.firestore { * to the backend (Note that it won't resolve while you're offline). */ update( - documentRef: DocumentReference, + documentRef: DocumentReference, field: string | FieldPath, value: any, ...moreFieldsAndValues: any[] @@ -7636,7 +7696,7 @@ declare namespace firebase.firestore { * @param documentRef A reference to the document to be deleted. * @return This `WriteBatch` instance. Used for chaining method calls. */ - delete(documentRef: DocumentReference): WriteBatch; + delete(documentRef: DocumentReference): WriteBatch; /** * Commits all of the writes in this write batch as a single atomic unit. @@ -7722,7 +7782,7 @@ declare namespace firebase.firestore { * 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(); /** @@ -7739,7 +7799,7 @@ declare namespace firebase.firestore { /** * The Collection this `DocumentReference` belongs to. */ - readonly parent: CollectionReference; + readonly parent: CollectionReference; /** * A string representing the path of the referenced document (relative @@ -7754,7 +7814,7 @@ declare namespace firebase.firestore { * @param collectionPath A slash-separated path to a collection. * @return The `CollectionReference` instance. */ - collection(collectionPath: string): CollectionReference; + collection(collectionPath: string): CollectionReference; /** * Returns true if this `DocumentReference` is equal to the provided one. @@ -7762,7 +7822,7 @@ declare namespace firebase.firestore { * @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; /** * Writes to the document referred to by this `DocumentReference`. If the @@ -7774,7 +7834,7 @@ declare namespace firebase.firestore { * @return A Promise resolved once the data has been successfully written * to the backend (Note that it won't resolve while you're offline). */ - set(data: DocumentData, options?: SetOptions): Promise; + set(data: T, options?: SetOptions): Promise; /** * Updates fields in the document referred to by this `DocumentReference`. @@ -7828,7 +7888,7 @@ declare namespace firebase.firestore { * @return A Promise resolved with a DocumentSnapshot containing the * current document contents. */ - get(options?: GetOptions): Promise; + get(options?: GetOptions): Promise>; /** * Attaches a listener for DocumentSnapshot events. You may either pass @@ -7843,7 +7903,7 @@ declare namespace firebase.firestore { * the snapshot listener. */ onSnapshot(observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: FirestoreError) => void; complete?: () => void; }): () => void; @@ -7863,7 +7923,7 @@ declare namespace firebase.firestore { onSnapshot( options: SnapshotListenOptions, observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: Error) => void; complete?: () => void; } @@ -7884,7 +7944,7 @@ declare namespace firebase.firestore { * the snapshot listener. */ onSnapshot( - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; @@ -7906,10 +7966,24 @@ declare namespace firebase.firestore { */ onSnapshot( options: SnapshotListenOptions, - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; + + /** + * 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; } /** @@ -7977,7 +8051,7 @@ declare namespace firebase.firestore { * 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(); /** @@ -7988,7 +8062,7 @@ declare namespace firebase.firestore { /** * The `DocumentReference` for the document included in the `DocumentSnapshot`. */ - readonly ref: DocumentReference; + readonly ref: DocumentReference; /** * Property of the `DocumentSnapshot` that provides the document's ID. */ @@ -8013,7 +8087,7 @@ declare namespace firebase.firestore { * @return An Object containing all fields in the document or 'undefined' if * the document doesn't exist. */ - data(options?: SnapshotOptions): DocumentData | undefined; + data(options?: SnapshotOptions): T | undefined; /** * Retrieves the field specified by `fieldPath`. Returns `undefined` if the @@ -8038,7 +8112,7 @@ declare namespace firebase.firestore { * @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; } /** @@ -8052,7 +8126,9 @@ declare namespace firebase.firestore { * `exists` property will always be true and `data()` will never return * 'undefined'. */ - export class QueryDocumentSnapshot extends DocumentSnapshot { + export class QueryDocumentSnapshot extends DocumentSnapshot< + T + > { private constructor(); /** @@ -8068,7 +8144,7 @@ declare namespace firebase.firestore { * not yet been set to their final value). * @return An Object containing all fields in the document. */ - data(options?: SnapshotOptions): DocumentData; + data(options?: SnapshotOptions): T; } /** @@ -8095,7 +8171,7 @@ declare namespace firebase.firestore { * 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(); /** @@ -8118,7 +8194,7 @@ declare namespace firebase.firestore { fieldPath: string | FieldPath, opStr: WhereFilterOp, value: any - ): Query; + ): Query; /** * Creates and returns a new Query that's additionally sorted by the @@ -8132,7 +8208,7 @@ declare namespace firebase.firestore { orderBy( fieldPath: string | FieldPath, directionStr?: OrderByDirection - ): Query; + ): Query; /** * Creates and returns a new Query that only returns the first matching @@ -8141,7 +8217,7 @@ declare namespace firebase.firestore { * @param limit The maximum number of items to return. * @return The created Query. */ - limit(limit: number): Query; + limit(limit: number): Query; /** * Creates and returns a new Query that only returns the last matching @@ -8153,7 +8229,7 @@ declare namespace firebase.firestore { * @param limit The maximum number of items to return. * @return The created Query. */ - limitToLast(limit: number): Query; + limitToLast(limit: number): Query; /** * Creates and returns a new Query that starts at the provided document @@ -8164,7 +8240,7 @@ declare namespace firebase.firestore { * @param snapshot The snapshot of the document to start at. * @return The created Query. */ - startAt(snapshot: DocumentSnapshot): Query; + startAt(snapshot: DocumentSnapshot): Query; /** * Creates and returns a new Query that starts at the provided fields @@ -8175,7 +8251,7 @@ declare namespace firebase.firestore { * 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 @@ -8186,7 +8262,7 @@ declare namespace firebase.firestore { * @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 @@ -8197,7 +8273,7 @@ declare namespace firebase.firestore { * 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 @@ -8208,7 +8284,7 @@ declare namespace firebase.firestore { * @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 @@ -8219,7 +8295,7 @@ declare namespace firebase.firestore { * 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 @@ -8230,7 +8306,7 @@ declare namespace firebase.firestore { * @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 @@ -8241,7 +8317,7 @@ declare namespace firebase.firestore { * of the query's order by. * @return The created Query. */ - endAt(...fieldValues: any[]): Query; + endAt(...fieldValues: any[]): Query; /** * Returns true if this `Query` is equal to the provided one. @@ -8249,7 +8325,7 @@ declare namespace firebase.firestore { * @param other The `Query` to compare against. * @return true if this `Query` is equal to the provided one. */ - isEqual(other: Query): boolean; + isEqual(other: Query): boolean; /** * Executes the query and returns the results as a `QuerySnapshot`. @@ -8262,7 +8338,7 @@ declare namespace firebase.firestore { * @param options An object to configure the get behavior. * @return A Promise that will be resolved with the results of the Query. */ - get(options?: GetOptions): Promise; + get(options?: GetOptions): Promise>; /** * Attaches a listener for QuerySnapshot events. You may either pass @@ -8278,7 +8354,7 @@ declare namespace firebase.firestore { * the snapshot listener. */ onSnapshot(observer: { - next?: (snapshot: QuerySnapshot) => void; + next?: (snapshot: QuerySnapshot) => void; error?: (error: Error) => void; complete?: () => void; }): () => void; @@ -8299,7 +8375,7 @@ declare namespace firebase.firestore { onSnapshot( options: SnapshotListenOptions, observer: { - next?: (snapshot: QuerySnapshot) => void; + next?: (snapshot: QuerySnapshot) => void; error?: (error: Error) => void; complete?: () => void; } @@ -8321,7 +8397,7 @@ declare namespace firebase.firestore { * the snapshot listener. */ onSnapshot( - onNext: (snapshot: QuerySnapshot) => void, + onNext: (snapshot: QuerySnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; @@ -8344,10 +8420,21 @@ declare namespace firebase.firestore { */ onSnapshot( options: SnapshotListenOptions, - onNext: (snapshot: QuerySnapshot) => void, + onNext: (snapshot: QuerySnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; + + /** + * 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; } /** @@ -8357,14 +8444,14 @@ declare namespace firebase.firestore { * 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; /** * Metadata about this snapshot, concerning its source and if it has local * modifications. @@ -8372,7 +8459,7 @@ declare namespace firebase.firestore { readonly metadata: SnapshotMetadata; /** 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; @@ -8388,7 +8475,7 @@ declare namespace firebase.firestore { * changes (i.e. only `DocumentSnapshot.metadata` changed) should trigger * snapshot events. */ - docChanges(options?: SnapshotListenOptions): DocumentChange[]; + docChanges(options?: SnapshotListenOptions): Array>; /** * Enumerates all of the documents in the `QuerySnapshot`. @@ -8398,7 +8485,7 @@ declare namespace firebase.firestore { * @param thisArg The `this` binding for the callback. */ forEach( - callback: (result: QueryDocumentSnapshot) => void, + callback: (result: QueryDocumentSnapshot) => void, thisArg?: any ): void; @@ -8408,7 +8495,7 @@ declare namespace firebase.firestore { * @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; } /** @@ -8420,12 +8507,12 @@ declare namespace firebase.firestore { * 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 @@ -8448,7 +8535,7 @@ declare namespace firebase.firestore { * 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 collection's identifier. */ @@ -8458,7 +8545,7 @@ declare namespace firebase.firestore { * A reference to the containing `DocumentReference` if this is a subcollection. * If this isn't a subcollection, the reference is null. */ - readonly parent: DocumentReference | null; + readonly parent: DocumentReference | null; /** * A string representing the path of the referenced collection (relative @@ -8474,7 +8561,7 @@ declare namespace firebase.firestore { * @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 @@ -8484,7 +8571,7 @@ declare namespace firebase.firestore { * @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. @@ -8492,7 +8579,20 @@ declare namespace firebase.firestore { * @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; } /** diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index f446fd7af5e..c9c494236ce 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -40,6 +40,12 @@ export type LogLevel = 'debug' | 'error' | 'silent'; export function setLogLevel(logLevel: LogLevel): void; +export interface FirestoreDataConverter { + toFirestore(modelObject: T): DocumentData; + + fromFirestore(snapshot: QueryDocumentSnapshot, options: SnapshotOptions): T; +} + export class FirebaseFirestore { private constructor(); @@ -47,11 +53,11 @@ export class FirebaseFirestore { enablePersistence(settings?: PersistenceSettings): Promise; - collection(collectionPath: string): CollectionReference; + collection(collectionPath: string): CollectionReference; - doc(documentPath: string): DocumentReference; + doc(documentPath: string): DocumentReference; - collectionGroup(collectionId: string): Query; + collectionGroup(collectionId: string): Query; runTransaction( updateFunction: (transaction: Transaction) => Promise @@ -126,43 +132,43 @@ export class Blob { export class Transaction { private constructor(); - get(documentRef: DocumentReference): Promise; + get(documentRef: DocumentReference): Promise>; - set( - documentRef: DocumentReference, - data: DocumentData, + set( + documentRef: DocumentReference, + data: T, options?: SetOptions ): Transaction; - update(documentRef: DocumentReference, data: UpdateData): Transaction; + update(documentRef: DocumentReference, data: UpdateData): Transaction; update( - documentRef: DocumentReference, + documentRef: DocumentReference, field: string | FieldPath, value: any, ...moreFieldsAndValues: any[] ): Transaction; - delete(documentRef: DocumentReference): Transaction; + delete(documentRef: DocumentReference): Transaction; } export class WriteBatch { private constructor(); - set( - documentRef: DocumentReference, - data: DocumentData, + set( + documentRef: DocumentReference, + data: T, options?: SetOptions ): WriteBatch; - update(documentRef: DocumentReference, data: UpdateData): WriteBatch; + update(documentRef: DocumentReference, data: UpdateData): WriteBatch; update( - documentRef: DocumentReference, + documentRef: DocumentReference, field: string | FieldPath, value: any, ...moreFieldsAndValues: any[] ): WriteBatch; - delete(documentRef: DocumentReference): WriteBatch; + delete(documentRef: DocumentReference): WriteBatch; commit(): Promise; } @@ -180,19 +186,19 @@ export interface GetOptions { readonly source?: 'default' | 'server' | 'cache'; } -export class DocumentReference { +export class DocumentReference { private constructor(); readonly id: string; readonly firestore: FirebaseFirestore; - readonly parent: CollectionReference; + readonly parent: CollectionReference; readonly path: string; - collection(collectionPath: string): CollectionReference; + collection(collectionPath: string): CollectionReference; - isEqual(other: DocumentReference): boolean; + isEqual(other: DocumentReference): boolean; - set(data: DocumentData, options?: SetOptions): Promise; + set(data: T, options?: SetOptions): Promise; update(data: UpdateData): Promise; update( @@ -203,39 +209,40 @@ export class DocumentReference { delete(): Promise; - get(options?: GetOptions): Promise; + get(options?: GetOptions): Promise>; onSnapshot(observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: FirestoreError) => void; complete?: () => void; }): () => void; onSnapshot( options: SnapshotListenOptions, observer: { - next?: (snapshot: DocumentSnapshot) => void; + next?: (snapshot: DocumentSnapshot) => void; error?: (error: Error) => void; complete?: () => void; } ): () => void; onSnapshot( - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; onSnapshot( options: SnapshotListenOptions, - onNext: (snapshot: DocumentSnapshot) => void, + onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; + + withConverter(converter: FirestoreDataConverter): DocumentReference; } export interface SnapshotOptions { readonly serverTimestamps?: 'estimate' | 'previous' | 'none'; } -/** Metadata about a snapshot, describing the state of the snapshot. */ export interface SnapshotMetadata { readonly hasPendingWrites: boolean; readonly fromCache: boolean; @@ -243,24 +250,27 @@ export interface SnapshotMetadata { isEqual(other: SnapshotMetadata): boolean; } -export class DocumentSnapshot { +export class DocumentSnapshot { protected constructor(); readonly exists: boolean; - readonly ref: DocumentReference; + readonly ref: DocumentReference; readonly id: string; readonly metadata: SnapshotMetadata; - data(options?: SnapshotOptions): DocumentData | undefined; + data(options?: SnapshotOptions): T | undefined; get(fieldPath: string | FieldPath, options?: SnapshotOptions): any; - isEqual(other: DocumentSnapshot): boolean; + isEqual(other: DocumentSnapshot): boolean; } -export class QueryDocumentSnapshot extends DocumentSnapshot { +export class QueryDocumentSnapshot extends DocumentSnapshot< + T +> { private constructor(); - data(options?: SnapshotOptions): DocumentData; + + data(options?: SnapshotOptions): T; } export type OrderByDirection = 'desc' | 'asc'; @@ -275,104 +285,114 @@ export type WhereFilterOp = | 'in' | 'array-contains-any'; -export class Query { +export class Query { protected constructor(); readonly firestore: FirebaseFirestore; - where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: any): Query; + where( + fieldPath: string | FieldPath, + opStr: WhereFilterOp, + value: any + ): Query; orderBy( fieldPath: string | FieldPath, directionStr?: OrderByDirection - ): Query; + ): Query; - limit(limit: number): Query; + limit(limit: number): Query; - limitToLast(limit: number): Query; + limitToLast(limit: number): Query; - startAt(snapshot: DocumentSnapshot): Query; - startAt(...fieldValues: any[]): Query; + startAt(snapshot: DocumentSnapshot): Query; + startAt(...fieldValues: any[]): Query; - startAfter(snapshot: DocumentSnapshot): Query; - startAfter(...fieldValues: any[]): Query; + startAfter(snapshot: DocumentSnapshot): Query; + startAfter(...fieldValues: any[]): Query; - endBefore(snapshot: DocumentSnapshot): Query; - endBefore(...fieldValues: any[]): Query; + endBefore(snapshot: DocumentSnapshot): Query; + endBefore(...fieldValues: any[]): Query; - endAt(snapshot: DocumentSnapshot): Query; - endAt(...fieldValues: any[]): Query; + endAt(snapshot: DocumentSnapshot): Query; + endAt(...fieldValues: any[]): Query; - isEqual(other: Query): boolean; + isEqual(other: Query): boolean; - get(options?: GetOptions): Promise; + get(options?: GetOptions): Promise>; onSnapshot(observer: { - next?: (snapshot: QuerySnapshot) => void; + next?: (snapshot: QuerySnapshot) => void; error?: (error: Error) => void; complete?: () => void; }): () => void; onSnapshot( options: SnapshotListenOptions, observer: { - next?: (snapshot: QuerySnapshot) => void; + next?: (snapshot: QuerySnapshot) => void; error?: (error: Error) => void; complete?: () => void; } ): () => void; onSnapshot( - onNext: (snapshot: QuerySnapshot) => void, + onNext: (snapshot: QuerySnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; onSnapshot( options: SnapshotListenOptions, - onNext: (snapshot: QuerySnapshot) => void, + onNext: (snapshot: QuerySnapshot) => void, onError?: (error: Error) => void, onCompletion?: () => void ): () => void; + + withConverter(converter: FirestoreDataConverter): Query; } -export class QuerySnapshot { +export class QuerySnapshot { private constructor(); - readonly query: Query; + readonly query: Query; readonly metadata: SnapshotMetadata; - readonly docs: QueryDocumentSnapshot[]; + readonly docs: Array>; readonly size: number; readonly empty: boolean; - docChanges(options?: SnapshotListenOptions): DocumentChange[]; + docChanges(options?: SnapshotListenOptions): Array>; forEach( - callback: (result: QueryDocumentSnapshot) => void, + callback: (result: QueryDocumentSnapshot) => void, thisArg?: any ): void; - isEqual(other: QuerySnapshot): boolean; + isEqual(other: QuerySnapshot): boolean; } export type DocumentChangeType = 'added' | 'removed' | 'modified'; -export interface DocumentChange { +export interface DocumentChange { readonly type: DocumentChangeType; - readonly doc: QueryDocumentSnapshot; + readonly doc: QueryDocumentSnapshot; readonly oldIndex: number; readonly newIndex: number; } -export class CollectionReference extends Query { +export class CollectionReference extends Query { private constructor(); readonly id: string; - readonly parent: DocumentReference | null; + readonly parent: DocumentReference | null; readonly path: string; - doc(documentPath?: string): DocumentReference; + doc(documentPath?: string): DocumentReference; + + add(data: T): Promise>; - add(data: DocumentData): Promise; + isEqual(other: CollectionReference): boolean; - isEqual(other: CollectionReference): boolean; + withConverter( + converter: FirestoreDataConverter + ): CollectionReference; } export class FieldValue { diff --git a/packages/firestore/CHANGELOG.md b/packages/firestore/CHANGELOG.md index 954c36d293a..dd419f3929d 100644 --- a/packages/firestore/CHANGELOG.md +++ b/packages/firestore/CHANGELOG.md @@ -1,4 +1,10 @@ -# Unreleased (1.8.0) +# Unreleased +- [feature] Added support for storing and retrieving custom types in Firestore. + Added support for strongly typed collections, documents, and + queries. You can now use `withConverter()` to supply a custom data + converter that will convert between Firestore data and your custom type. + +# 1.8.0 - [changed] Improved the performance of repeatedly executed queries when persistence is enabled. Recently executed queries should see dramatic improvements. This benefit is reduced if changes accumulate while the query diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 95d36cd3b68..99575a675ad 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -697,9 +697,9 @@ export class Transaction implements firestore.Transaction { private _transaction: InternalTransaction ) {} - get( - documentRef: firestore.DocumentReference - ): Promise { + get( + documentRef: firestore.DocumentReference + ): Promise> { validateExactNumberOfArgs('Transaction.get', arguments, 1); const ref = validateReference( 'Transaction.get', @@ -714,20 +714,22 @@ export class Transaction implements firestore.Transaction { } const doc = docs[0]; if (doc instanceof NoDocument) { - return new DocumentSnapshot( + return new DocumentSnapshot( this._firestore, ref._key, null, /* fromCache= */ false, - /* hasPendingWrites= */ false + /* hasPendingWrites= */ false, + ref._converter ); } else if (doc instanceof Document) { - return new DocumentSnapshot( + return new DocumentSnapshot( this._firestore, ref._key, doc, /* fromCache= */ false, - /* hasPendingWrites= */ false + /* hasPendingWrites= */ false, + ref._converter ); } else { throw fail( @@ -737,9 +739,9 @@ export class Transaction implements firestore.Transaction { }); } - set( - documentRef: firestore.DocumentReference, - value: firestore.DocumentData, + set( + documentRef: firestore.DocumentReference, + value: T, options?: firestore.SetOptions ): Transaction { validateBetweenNumberOfArgs('Transaction.set', arguments, 2, 3); @@ -749,30 +751,38 @@ export class Transaction implements firestore.Transaction { this._firestore ); options = validateSetOptions('Transaction.set', options); + const [convertedValue, functionName] = applyFirestoreDataConverter( + ref._converter, + value, + 'Transaction.set' + ); const parsed = options.merge || options.mergeFields ? this._firestore._dataConverter.parseMergeData( - 'Transaction.set', - value, + functionName, + convertedValue, options.mergeFields ) - : this._firestore._dataConverter.parseSetData('Transaction.set', value); + : this._firestore._dataConverter.parseSetData( + functionName, + convertedValue + ); this._transaction.set(ref._key, parsed); return this; } update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, value: firestore.UpdateData ): Transaction; update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, field: string | ExternalFieldPath, value: unknown, ...moreFieldsAndValues: unknown[] ): Transaction; update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, fieldOrUpdateData: string | ExternalFieldPath | firestore.UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] @@ -813,7 +823,7 @@ export class Transaction implements firestore.Transaction { return this; } - delete(documentRef: firestore.DocumentReference): Transaction { + delete(documentRef: firestore.DocumentReference): Transaction { validateExactNumberOfArgs('Transaction.delete', arguments, 1); const ref = validateReference( 'Transaction.delete', @@ -831,9 +841,9 @@ export class WriteBatch implements firestore.WriteBatch { constructor(private _firestore: Firestore) {} - set( - documentRef: firestore.DocumentReference, - value: firestore.DocumentData, + set( + documentRef: firestore.DocumentReference, + value: T, options?: firestore.SetOptions ): WriteBatch { validateBetweenNumberOfArgs('WriteBatch.set', arguments, 2, 3); @@ -844,14 +854,22 @@ export class WriteBatch implements firestore.WriteBatch { this._firestore ); options = validateSetOptions('WriteBatch.set', options); + const [convertedValue, functionName] = applyFirestoreDataConverter( + ref._converter, + value, + 'WriteBatch.set' + ); const parsed = options.merge || options.mergeFields ? this._firestore._dataConverter.parseMergeData( - 'WriteBatch.set', - value, + functionName, + convertedValue, options.mergeFields ) - : this._firestore._dataConverter.parseSetData('WriteBatch.set', value); + : this._firestore._dataConverter.parseSetData( + functionName, + convertedValue + ); this._mutations = this._mutations.concat( parsed.toMutations(ref._key, Precondition.NONE) ); @@ -859,17 +877,17 @@ export class WriteBatch implements firestore.WriteBatch { } update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, value: firestore.UpdateData ): WriteBatch; update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, field: string | ExternalFieldPath, value: unknown, ...moreFieldsAndValues: unknown[] ): WriteBatch; update( - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, fieldOrUpdateData: string | ExternalFieldPath | firestore.UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] @@ -914,7 +932,7 @@ export class WriteBatch implements firestore.WriteBatch { return this; } - delete(documentRef: firestore.DocumentReference): WriteBatch { + delete(documentRef: firestore.DocumentReference): WriteBatch { validateExactNumberOfArgs('WriteBatch.delete', arguments, 1); this.verifyNotCommitted(); const ref = validateReference( @@ -950,14 +968,23 @@ export class WriteBatch implements firestore.WriteBatch { /** * A reference to a particular document in a collection in the database. */ -export class DocumentReference implements firestore.DocumentReference { +export class DocumentReference + implements firestore.DocumentReference { private _firestoreClient: FirestoreClient; - constructor(public _key: DocumentKey, readonly firestore: Firestore) { + constructor( + public _key: DocumentKey, + readonly firestore: Firestore, + readonly _converter?: firestore.FirestoreDataConverter + ) { this._firestoreClient = this.firestore.ensureClientConfigured(); } - static forPath(path: ResourcePath, firestore: Firestore): DocumentReference { + static forPath( + path: ResourcePath, + firestore: Firestore, + converter?: firestore.FirestoreDataConverter + ): DocumentReference { if (path.length % 2 !== 0) { throw new FirestoreError( Code.INVALID_ARGUMENT, @@ -966,22 +993,28 @@ export class DocumentReference implements firestore.DocumentReference { `${path.canonicalString()} has ${path.length}` ); } - return new DocumentReference(new DocumentKey(path), firestore); + return new DocumentReference(new DocumentKey(path), firestore, converter); } get id(): string { return this._key.path.lastSegment(); } - get parent(): firestore.CollectionReference { - return new CollectionReference(this._key.path.popLast(), this.firestore); + get parent(): firestore.CollectionReference { + return new CollectionReference( + this._key.path.popLast(), + this.firestore, + this._converter + ); } get path(): string { return this._key.path.canonicalString(); } - collection(pathString: string): firestore.CollectionReference { + collection( + pathString: string + ): firestore.CollectionReference { validateExactNumberOfArgs('DocumentReference.collection', arguments, 1); validateArgType( 'DocumentReference.collection', @@ -999,30 +1032,39 @@ export class DocumentReference implements firestore.DocumentReference { return new CollectionReference(this._key.path.child(path), this.firestore); } - isEqual(other: firestore.DocumentReference): boolean { + isEqual(other: firestore.DocumentReference): boolean { if (!(other instanceof DocumentReference)) { throw invalidClassError('isEqual', 'DocumentReference', 1, other); } - return this.firestore === other.firestore && this._key.isEqual(other._key); + return ( + this.firestore === other.firestore && + this._key.isEqual(other._key) && + this._converter === other._converter + ); } set( value: firestore.DocumentData, options?: firestore.SetOptions - ): Promise { + ): Promise; + set(value: T, options?: firestore.SetOptions): Promise { validateBetweenNumberOfArgs('DocumentReference.set', arguments, 1, 2); options = validateSetOptions('DocumentReference.set', options); - + const [convertedValue, functionName] = applyFirestoreDataConverter( + this._converter, + value, + 'DocumentReference.set' + ); const parsed = options.merge || options.mergeFields ? this.firestore._dataConverter.parseMergeData( - 'DocumentReference.set', - value, + functionName, + convertedValue, options.mergeFields ) : this.firestore._dataConverter.parseSetData( - 'DocumentReference.set', - value + functionName, + convertedValue ); return this._firestoreClient.write( parsed.toMutations(this._key, Precondition.NONE) @@ -1074,20 +1116,20 @@ export class DocumentReference implements firestore.DocumentReference { } onSnapshot( - observer: PartialObserver + observer: PartialObserver> ): Unsubscribe; onSnapshot( options: firestore.SnapshotListenOptions, - observer: PartialObserver + observer: PartialObserver> ): Unsubscribe; onSnapshot( - onNext: NextFn, + onNext: NextFn>, onError?: ErrorFn, onCompletion?: CompleteFn ): Unsubscribe; onSnapshot( options: firestore.SnapshotListenOptions, - onNext: NextFn, + onNext: NextFn>, onError?: ErrorFn, onCompletion?: CompleteFn ): Unsubscribe; @@ -1102,7 +1144,7 @@ export class DocumentReference implements firestore.DocumentReference { let options: firestore.SnapshotListenOptions = { includeMetadataChanges: false }; - let observer: PartialObserver; + let observer: PartialObserver>; let currArg = 0; if ( typeof args[currArg] === 'object' && @@ -1126,7 +1168,9 @@ export class DocumentReference implements firestore.DocumentReference { }; if (isPartialObserver(args[currArg])) { - observer = args[currArg] as PartialObserver; + observer = args[currArg] as PartialObserver< + firestore.DocumentSnapshot + >; } else { validateArgType( 'DocumentReference.onSnapshot', @@ -1147,7 +1191,7 @@ export class DocumentReference implements firestore.DocumentReference { args[currArg + 2] ); observer = { - next: args[currArg] as NextFn, + next: args[currArg] as NextFn>, error: args[currArg + 1] as ErrorFn, complete: args[currArg + 2] as CompleteFn }; @@ -1157,7 +1201,7 @@ export class DocumentReference implements firestore.DocumentReference { private onSnapshotInternal( options: ListenOptions, - observer: PartialObserver + observer: PartialObserver> ): Unsubscribe { let errHandler = (err: Error): void => { console.error('Uncaught Error in onSnapshot:', err); @@ -1181,7 +1225,8 @@ export class DocumentReference implements firestore.DocumentReference { this._key, doc, snapshot.fromCache, - snapshot.hasPendingWrites + snapshot.hasPendingWrites, + this._converter ) ); } @@ -1200,11 +1245,11 @@ export class DocumentReference implements firestore.DocumentReference { }; } - get(options?: firestore.GetOptions): Promise { + get(options?: firestore.GetOptions): Promise> { validateBetweenNumberOfArgs('DocumentReference.get', arguments, 0, 1); validateGetOptions('DocumentReference.get', options); return new Promise( - (resolve: Resolver, reject: Rejecter) => { + (resolve: Resolver>, reject: Rejecter) => { if (options && options.source === 'cache') { this.firestore .ensureClientConfigured() @@ -1216,7 +1261,8 @@ export class DocumentReference implements firestore.DocumentReference { this._key, doc, /*fromCache=*/ true, - doc instanceof Document ? doc.hasLocalMutations : false + doc instanceof Document ? doc.hasLocalMutations : false, + this._converter ) ); }, reject); @@ -1228,7 +1274,7 @@ export class DocumentReference implements firestore.DocumentReference { } private getViaSnapshotListener( - resolve: Resolver, + resolve: Resolver>, reject: Rejecter, options?: firestore.GetOptions ): void { @@ -1238,7 +1284,7 @@ export class DocumentReference implements firestore.DocumentReference { waitForSyncWhenOnline: true }, { - next: (snap: firestore.DocumentSnapshot) => { + next: (snap: firestore.DocumentSnapshot) => { // Remove query first before passing event to user to avoid // user actions affecting the now stale query. unlisten(); @@ -1280,6 +1326,12 @@ export class DocumentReference implements firestore.DocumentReference { } ); } + + withConverter( + converter: firestore.FirestoreDataConverter + ): firestore.DocumentReference { + return new DocumentReference(this._key, this.firestore, converter); + } } class SnapshotMetadata implements firestore.SnapshotMetadata { @@ -1302,29 +1354,44 @@ class SnapshotMetadata implements firestore.SnapshotMetadata { */ export interface SnapshotOptions extends firestore.SnapshotOptions {} -export class DocumentSnapshot implements firestore.DocumentSnapshot { +export class DocumentSnapshot + implements firestore.DocumentSnapshot { constructor( private _firestore: Firestore, private _key: DocumentKey, public _document: Document | null, private _fromCache: boolean, - private _hasPendingWrites: boolean + private _hasPendingWrites: boolean, + private readonly _converter?: firestore.FirestoreDataConverter ) {} - data( - options?: firestore.SnapshotOptions - ): firestore.DocumentData | undefined { + data(options?: firestore.SnapshotOptions): T | undefined { validateBetweenNumberOfArgs('DocumentSnapshot.data', arguments, 0, 1); options = validateSnapshotOptions('DocumentSnapshot.data', options); - return !this._document - ? undefined - : this.convertObject( + if (!this._document) { + return undefined; + } else { + // We only want to use the converter and create a new DocumentSnapshot + // if a converter has been provided. + if (this._converter) { + const snapshot = new QueryDocumentSnapshot( + this._firestore, + this._key, + this._document, + this._fromCache, + this._hasPendingWrites + ); + return this._converter.fromFirestore(snapshot, options); + } else { + return this.toJSObject( this._document.data(), FieldValueOptions.fromSnapshotOptions( options, this._firestore._areTimestampsInSnapshotsEnabled() ) - ); + ) as T; + } + } } get( @@ -1338,7 +1405,7 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot { .data() .field(fieldPathFromArgument('DocumentSnapshot.get', fieldPath)); if (value !== null) { - return this.convertValue( + return this.toJSValue( value, FieldValueOptions.fromSnapshotOptions( options, @@ -1354,8 +1421,12 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot { return this._key.path.lastSegment(); } - get ref(): firestore.DocumentReference { - return new DocumentReference(this._key, this._firestore); + get ref(): firestore.DocumentReference { + return new DocumentReference( + this._key, + this._firestore, + this._converter + ); } get exists(): boolean { @@ -1366,7 +1437,7 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot { return new SnapshotMetadata(this._hasPendingWrites, this._fromCache); } - isEqual(other: firestore.DocumentSnapshot): boolean { + isEqual(other: firestore.DocumentSnapshot): boolean { if (!(other instanceof DocumentSnapshot)) { throw invalidClassError('isEqual', 'DocumentSnapshot', 1, other); } @@ -1376,26 +1447,27 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot { this._key.isEqual(other._key) && (this._document === null ? other._document === null - : this._document.isEqual(other._document)) + : this._document.isEqual(other._document)) && + this._converter === other._converter ); } - private convertObject( + private toJSObject( data: ObjectValue, options: FieldValueOptions ): firestore.DocumentData { const result: firestore.DocumentData = {}; data.forEach((key, value) => { - result[key] = this.convertValue(value, options); + result[key] = this.toJSValue(value, options); }); return result; } - private convertValue(value: FieldValue, options: FieldValueOptions): unknown { + private toJSValue(value: FieldValue, options: FieldValueOptions): unknown { if (value instanceof ObjectValue) { - return this.convertObject(value, options); + return this.toJSObject(value, options); } else if (value instanceof ArrayValue) { - return this.convertArray(value, options); + return this.toJSArray(value, options); } else if (value instanceof RefValue) { const key = value.value(options); const database = this._firestore.ensureClientConfigured().databaseId(); @@ -1410,42 +1482,44 @@ export class DocumentSnapshot implements firestore.DocumentSnapshot { `instead.` ); } - return new DocumentReference(key, this._firestore); + return new DocumentReference(key, this._firestore, this._converter); } else { return value.value(options); } } - private convertArray( - data: ArrayValue, - options: FieldValueOptions - ): unknown[] { + private toJSArray(data: ArrayValue, options: FieldValueOptions): unknown[] { return data.internalValue.map(value => { - return this.convertValue(value, options); + return this.toJSValue(value, options); }); } } -export class QueryDocumentSnapshot extends DocumentSnapshot - implements firestore.QueryDocumentSnapshot { - data(options?: SnapshotOptions): firestore.DocumentData { +export class QueryDocumentSnapshot + extends DocumentSnapshot + implements firestore.QueryDocumentSnapshot { + data(options?: SnapshotOptions): T { const data = super.data(options); assert( - typeof data === 'object', + data !== undefined, 'Document in a QueryDocumentSnapshot should exist' ); return data; } } -export class Query implements firestore.Query { - constructor(public _query: InternalQuery, readonly firestore: Firestore) {} +export class Query implements firestore.Query { + constructor( + public _query: InternalQuery, + readonly firestore: Firestore, + protected readonly _converter?: firestore.FirestoreDataConverter + ) {} where( field: string | ExternalFieldPath, opStr: firestore.WhereFilterOp, value: unknown - ): firestore.Query { + ): firestore.Query { validateExactNumberOfArgs('Query.where', arguments, 3); validateDefined('Query.where', 3, value); @@ -1501,13 +1575,17 @@ export class Query implements firestore.Query { } const filter = FieldFilter.create(fieldPath, operator, fieldValue); this.validateNewFilter(filter); - return new Query(this._query.addFilter(filter), this.firestore); + return new Query( + this._query.addFilter(filter), + this.firestore, + this._converter + ); } orderBy( field: string | ExternalFieldPath, directionStr?: firestore.OrderByDirection - ): firestore.Query { + ): firestore.Query { validateBetweenNumberOfArgs('Query.orderBy', arguments, 1, 2); validateOptionalArgType( 'Query.orderBy', @@ -1544,27 +1622,39 @@ export class Query implements firestore.Query { const fieldPath = fieldPathFromArgument('Query.orderBy', field); const orderBy = new OrderBy(fieldPath, direction); this.validateNewOrderBy(orderBy); - return new Query(this._query.addOrderBy(orderBy), this.firestore); + return new Query( + this._query.addOrderBy(orderBy), + this.firestore, + this._converter + ); } - limit(n: number): firestore.Query { + limit(n: number): firestore.Query { validateExactNumberOfArgs('Query.limit', arguments, 1); validateArgType('Query.limit', 'number', 1, n); validatePositiveNumber('Query.limit', 1, n); - return new Query(this._query.withLimitToFirst(n), this.firestore); + return new Query( + this._query.withLimitToFirst(n), + this.firestore, + this._converter + ); } - limitToLast(n: number): firestore.Query { + limitToLast(n: number): firestore.Query { validateExactNumberOfArgs('Query.limitToLast', arguments, 1); validateArgType('Query.limitToLast', 'number', 1, n); validatePositiveNumber('Query.limitToLast', 1, n); - return new Query(this._query.withLimitToLast(n), this.firestore); + return new Query( + this._query.withLimitToLast(n), + this.firestore, + this._converter + ); } startAt( - docOrField: unknown | firestore.DocumentSnapshot, + docOrField: unknown | firestore.DocumentSnapshot, ...fields: unknown[] - ): firestore.Query { + ): firestore.Query { validateAtLeastNumberOfArgs('Query.startAt', arguments, 1); const bound = this.boundFromDocOrFields( 'Query.startAt', @@ -1572,13 +1662,17 @@ export class Query implements firestore.Query { fields, /*before=*/ true ); - return new Query(this._query.withStartAt(bound), this.firestore); + return new Query( + this._query.withStartAt(bound), + this.firestore, + this._converter + ); } startAfter( - docOrField: unknown | firestore.DocumentSnapshot, + docOrField: unknown | firestore.DocumentSnapshot, ...fields: unknown[] - ): firestore.Query { + ): firestore.Query { validateAtLeastNumberOfArgs('Query.startAfter', arguments, 1); const bound = this.boundFromDocOrFields( 'Query.startAfter', @@ -1586,13 +1680,17 @@ export class Query implements firestore.Query { fields, /*before=*/ false ); - return new Query(this._query.withStartAt(bound), this.firestore); + return new Query( + this._query.withStartAt(bound), + this.firestore, + this._converter + ); } endBefore( - docOrField: unknown | firestore.DocumentSnapshot, + docOrField: unknown | firestore.DocumentSnapshot, ...fields: unknown[] - ): firestore.Query { + ): firestore.Query { validateAtLeastNumberOfArgs('Query.endBefore', arguments, 1); const bound = this.boundFromDocOrFields( 'Query.endBefore', @@ -1600,13 +1698,17 @@ export class Query implements firestore.Query { fields, /*before=*/ true ); - return new Query(this._query.withEndAt(bound), this.firestore); + return new Query( + this._query.withEndAt(bound), + this.firestore, + this._converter + ); } endAt( - docOrField: unknown | firestore.DocumentSnapshot, + docOrField: unknown | firestore.DocumentSnapshot, ...fields: unknown[] - ): firestore.Query { + ): firestore.Query { validateAtLeastNumberOfArgs('Query.endAt', arguments, 1); const bound = this.boundFromDocOrFields( 'Query.endAt', @@ -1614,10 +1716,14 @@ export class Query implements firestore.Query { fields, /*before=*/ false ); - return new Query(this._query.withEndAt(bound), this.firestore); + return new Query( + this._query.withEndAt(bound), + this.firestore, + this._converter + ); } - isEqual(other: firestore.Query): boolean { + isEqual(other: firestore.Query): boolean { if (!(other instanceof Query)) { throw invalidClassError('isEqual', 'Query', 1, other); } @@ -1626,10 +1732,16 @@ export class Query implements firestore.Query { ); } + withConverter( + converter: firestore.FirestoreDataConverter + ): firestore.Query { + return new Query(this._query, this.firestore, converter); + } + /** Helper function to create a bound from a document or fields */ private boundFromDocOrFields( methodName: string, - docOrField: unknown | firestore.DocumentSnapshot, + docOrField: unknown | firestore.DocumentSnapshot, fields: unknown[], before: boolean ): Bound { @@ -1777,19 +1889,21 @@ export class Query implements firestore.Query { return new Bound(components, before); } - onSnapshot(observer: PartialObserver): Unsubscribe; + onSnapshot( + observer: PartialObserver> + ): Unsubscribe; onSnapshot( options: firestore.SnapshotListenOptions, - observer: PartialObserver + observer: PartialObserver> ): Unsubscribe; onSnapshot( - onNext: NextFn, + onNext: NextFn>, onError?: ErrorFn, onCompletion?: CompleteFn ): Unsubscribe; onSnapshot( options: firestore.SnapshotListenOptions, - onNext: NextFn, + onNext: NextFn>, onError?: ErrorFn, onCompletion?: CompleteFn ): Unsubscribe; @@ -1797,7 +1911,7 @@ export class Query implements firestore.Query { onSnapshot(...args: unknown[]): Unsubscribe { validateBetweenNumberOfArgs('Query.onSnapshot', arguments, 1, 4); let options: firestore.SnapshotListenOptions = {}; - let observer: PartialObserver; + let observer: PartialObserver>; let currArg = 0; if ( typeof args[currArg] === 'object' && @@ -1817,7 +1931,7 @@ export class Query implements firestore.Query { } if (isPartialObserver(args[currArg])) { - observer = args[currArg] as PartialObserver; + observer = args[currArg] as PartialObserver>; } else { validateArgType('Query.onSnapshot', 'function', currArg, args[currArg]); validateOptionalArgType( @@ -1833,7 +1947,7 @@ export class Query implements firestore.Query { args[currArg + 2] ); observer = { - next: args[currArg] as NextFn, + next: args[currArg] as NextFn>, error: args[currArg + 1] as ErrorFn, complete: args[currArg + 2] as CompleteFn }; @@ -1844,7 +1958,7 @@ export class Query implements firestore.Query { private onSnapshotInternal( options: ListenOptions, - observer: PartialObserver + observer: PartialObserver> ): Unsubscribe { let errHandler = (err: Error): void => { console.error('Uncaught Error in onSnapshot:', err); @@ -1856,7 +1970,14 @@ export class Query implements firestore.Query { const asyncObserver = new AsyncObserver({ next: (result: ViewSnapshot): void => { if (observer.next) { - observer.next(new QuerySnapshot(this.firestore, this._query, result)); + observer.next( + new QuerySnapshot( + this.firestore, + this._query, + result, + this._converter + ) + ); } }, error: errHandler @@ -1883,18 +2004,25 @@ export class Query implements firestore.Query { } } - get(options?: firestore.GetOptions): Promise { + get(options?: firestore.GetOptions): Promise> { validateBetweenNumberOfArgs('Query.get', arguments, 0, 1); validateGetOptions('Query.get', options); this.validateHasExplicitOrderByForLimitToLast(this._query); return new Promise( - (resolve: Resolver, reject: Rejecter) => { + (resolve: Resolver>, reject: Rejecter) => { if (options && options.source === 'cache') { this.firestore .ensureClientConfigured() .getDocumentsFromLocalCache(this._query) .then((viewSnap: ViewSnapshot) => { - resolve(new QuerySnapshot(this.firestore, this._query, viewSnap)); + resolve( + new QuerySnapshot( + this.firestore, + this._query, + viewSnap, + this._converter + ) + ); }, reject); } else { this.getViaSnapshotListener(resolve, reject, options); @@ -1904,7 +2032,7 @@ export class Query implements firestore.Query { } private getViaSnapshotListener( - resolve: Resolver, + resolve: Resolver>, reject: Rejecter, options?: firestore.GetOptions ): void { @@ -1914,7 +2042,7 @@ export class Query implements firestore.Query { waitForSyncWhenOnline: true }, { - next: (result: firestore.QuerySnapshot) => { + next: (result: firestore.QuerySnapshot) => { // Remove query first before passing event to user to avoid // user actions affecting the now stale query. unlisten(); @@ -1980,7 +2108,7 @@ export class Query implements firestore.Query { } return new RefValue(this.firestore._databaseId, new DocumentKey(path)); } else if (documentIdValue instanceof DocumentReference) { - const ref = documentIdValue as DocumentReference; + const ref = documentIdValue as DocumentReference; return new RefValue(this.firestore._databaseId, ref._key); } else { throw new FirestoreError( @@ -2113,16 +2241,18 @@ export class Query implements firestore.Query { } } -export class QuerySnapshot implements firestore.QuerySnapshot { - private _cachedChanges: firestore.DocumentChange[] | null = null; +export class QuerySnapshot + implements firestore.QuerySnapshot { + private _cachedChanges: Array> | null = null; private _cachedChangesIncludeMetadataChanges: boolean | null = null; readonly metadata: firestore.SnapshotMetadata; constructor( - private _firestore: Firestore, - private _originalQuery: InternalQuery, - private _snapshot: ViewSnapshot + private readonly _firestore: Firestore, + private readonly _originalQuery: InternalQuery, + private readonly _snapshot: ViewSnapshot, + private readonly _converter?: firestore.FirestoreDataConverter ) { this.metadata = new SnapshotMetadata( _snapshot.hasPendingWrites, @@ -2130,8 +2260,8 @@ export class QuerySnapshot implements firestore.QuerySnapshot { ); } - get docs(): firestore.QueryDocumentSnapshot[] { - const result: firestore.QueryDocumentSnapshot[] = []; + get docs(): Array> { + const result: Array> = []; this.forEach(doc => result.push(doc)); return result; } @@ -2145,7 +2275,7 @@ export class QuerySnapshot implements firestore.QuerySnapshot { } forEach( - callback: (result: firestore.QueryDocumentSnapshot) => void, + callback: (result: firestore.QueryDocumentSnapshot) => void, thisArg?: unknown ): void { validateBetweenNumberOfArgs('QuerySnapshot.forEach', arguments, 1, 2); @@ -2155,13 +2285,13 @@ export class QuerySnapshot implements firestore.QuerySnapshot { }); } - get query(): firestore.Query { - return new Query(this._originalQuery, this._firestore); + get query(): firestore.Query { + return new Query(this._originalQuery, this._firestore, this._converter); } docChanges( options?: firestore.SnapshotListenOptions - ): firestore.DocumentChange[] { + ): Array> { if (options) { validateOptionNames('QuerySnapshot.docChanges', options, [ 'includeMetadataChanges' @@ -2190,10 +2320,11 @@ export class QuerySnapshot implements firestore.QuerySnapshot { !this._cachedChanges || this._cachedChangesIncludeMetadataChanges !== includeMetadataChanges ) { - this._cachedChanges = changesFromSnapshot( + this._cachedChanges = changesFromSnapshot( this._firestore, includeMetadataChanges, - this._snapshot + this._snapshot, + this._converter ); this._cachedChangesIncludeMetadataChanges = includeMetadataChanges; } @@ -2202,7 +2333,7 @@ export class QuerySnapshot implements firestore.QuerySnapshot { } /** Check the equality. The call can be very expensive. */ - isEqual(other: firestore.QuerySnapshot): boolean { + isEqual(other: firestore.QuerySnapshot): boolean { if (!(other instanceof QuerySnapshot)) { throw invalidClassError('isEqual', 'QuerySnapshot', 1, other); } @@ -2210,17 +2341,19 @@ export class QuerySnapshot implements firestore.QuerySnapshot { return ( this._firestore === other._firestore && this._originalQuery.isEqual(other._originalQuery) && - this._snapshot.isEqual(other._snapshot) + this._snapshot.isEqual(other._snapshot) && + this._converter === other._converter ); } - private convertToDocumentImpl(doc: Document): QueryDocumentSnapshot { + private convertToDocumentImpl(doc: Document): QueryDocumentSnapshot { return new QueryDocumentSnapshot( this._firestore, doc.key, doc, this.metadata.fromCache, - this._snapshot.mutatedKeys.has(doc.key) + this._snapshot.mutatedKeys.has(doc.key), + this._converter ); } } @@ -2261,16 +2394,20 @@ docChangesPropertiesToOverride.forEach(property => { } catch (err) {} // Ignore this failure intentionally }); -export class CollectionReference extends Query - implements firestore.CollectionReference { - constructor(path: ResourcePath, firestore: Firestore) { - super(InternalQuery.atPath(path), firestore); - if (path.length % 2 !== 1) { +export class CollectionReference extends Query + implements firestore.CollectionReference { + constructor( + readonly _path: ResourcePath, + firestore: Firestore, + _converter?: firestore.FirestoreDataConverter + ) { + super(InternalQuery.atPath(_path), firestore, _converter); + if (_path.length % 2 !== 1) { throw new FirestoreError( Code.INVALID_ARGUMENT, 'Invalid collection reference. Collection ' + 'references must have an odd number of segments, but ' + - `${path.canonicalString()} has ${path.length}` + `${_path.canonicalString()} has ${_path.length}` ); } } @@ -2279,12 +2416,15 @@ export class CollectionReference extends Query return this._query.path.lastSegment(); } - get parent(): firestore.DocumentReference | null { + get parent(): firestore.DocumentReference | null { const parentPath = this._query.path.popLast(); if (parentPath.isEmpty()) { return null; } else { - return new DocumentReference(new DocumentKey(parentPath), this.firestore); + return new DocumentReference( + new DocumentKey(parentPath), + this.firestore + ); } } @@ -2292,7 +2432,7 @@ export class CollectionReference extends Query return this._query.path.canonicalString(); } - doc(pathString?: string): firestore.DocumentReference { + doc(pathString?: string): firestore.DocumentReference { validateBetweenNumberOfArgs('CollectionReference.doc', arguments, 0, 1); // We allow omission of 'pathString' but explicitly prohibit passing in both // 'undefined' and 'null'. @@ -2312,18 +2452,25 @@ export class CollectionReference extends Query ); } const path = ResourcePath.fromString(pathString!); - return DocumentReference.forPath( + return DocumentReference.forPath( this._query.path.child(path), - this.firestore + this.firestore, + this._converter ); } - add(value: firestore.DocumentData): Promise { + add(value: T): Promise> { validateExactNumberOfArgs('CollectionReference.add', arguments, 1); validateArgType('CollectionReference.add', 'object', 1, value); const docRef = this.doc(); return docRef.set(value).then(() => docRef); } + + withConverter( + converter: firestore.FirestoreDataConverter + ): firestore.CollectionReference { + return new CollectionReference(this._path, this.firestore, converter); + } } function validateSetOptions( @@ -2394,11 +2541,11 @@ function validateGetOptions( } } -function validateReference( +function validateReference( methodName: string, - documentRef: firestore.DocumentReference, + documentRef: firestore.DocumentReference, firestore: Firestore -): DocumentReference { +): DocumentReference { if (!(documentRef instanceof DocumentReference)) { throw invalidClassError(methodName, 'DocumentReference', 1, documentRef); } else if (documentRef.firestore !== firestore) { @@ -2416,23 +2563,25 @@ function validateReference( * * Exported for testing. */ -export function changesFromSnapshot( +export function changesFromSnapshot( firestore: Firestore, includeMetadataChanges: boolean, - snapshot: ViewSnapshot -): firestore.DocumentChange[] { + snapshot: ViewSnapshot, + converter?: firestore.FirestoreDataConverter +): Array> { if (snapshot.oldDocs.isEmpty()) { // Special case the first snapshot because index calculation is easy and // fast let lastDoc: Document; let index = 0; return snapshot.docChanges.map(change => { - const doc = new QueryDocumentSnapshot( + const doc = new QueryDocumentSnapshot( firestore, change.doc.key, change.doc, snapshot.fromCache, - snapshot.mutatedKeys.has(change.doc.key) + snapshot.mutatedKeys.has(change.doc.key), + converter ); assert( change.type === ChangeType.Added, @@ -2459,12 +2608,13 @@ export function changesFromSnapshot( change => includeMetadataChanges || change.type !== ChangeType.Metadata ) .map(change => { - const doc = new QueryDocumentSnapshot( + const doc = new QueryDocumentSnapshot( firestore, change.doc.key, change.doc, snapshot.fromCache, - snapshot.mutatedKeys.has(change.doc.key) + snapshot.mutatedKeys.has(change.doc.key), + converter ); let oldIndex = -1; let newIndex = -1; @@ -2496,6 +2646,30 @@ function resultChangeType(type: ChangeType): firestore.DocumentChangeType { } } +/** + * Converts custom model object of type T into DocumentData by applying the + * converter if it exists. + * + * This function is used when converting user objects to DocumentData + * because we want to provide the user with a more specific error message if + * their set() or fails due to invalid data originating from a toFirestore() + * call. + */ +function applyFirestoreDataConverter( + converter: firestore.FirestoreDataConverter | undefined, + value: T, + functionName: string +): [firestore.DocumentData, string] { + let convertedValue; + if (converter) { + convertedValue = converter.toFirestore(value); + functionName = 'toFirestore() in ' + functionName; + } else { + convertedValue = value as firestore.DocumentData; + } + return [convertedValue, functionName]; +} + // Export the classes with a private constructor (it will fail if invoked // at runtime). Note that this still allows instanceof checks. diff --git a/packages/firestore/test/integration/api/batch_writes.test.ts b/packages/firestore/test/integration/api/batch_writes.test.ts index c2becabe04a..93700d5dd00 100644 --- a/packages/firestore/test/integration/api/batch_writes.test.ts +++ b/packages/firestore/test/integration/api/batch_writes.test.ts @@ -355,4 +355,44 @@ apiDescribe('Database batch writes', (persistence: boolean) => { }); }); }); + + // PORTING NOTE: These tests are for FirestoreDataConverter support and apply + // only to web. + apiDescribe('withConverter() support', (persistence: boolean) => { + class Post { + constructor(readonly title: string, readonly author: string) {} + byline(): string { + return this.title + ', by ' + this.author; + } + } + + it('for Writebatch.set()', () => { + return integrationHelpers.withTestDb(persistence, db => { + const docRef = db + .collection('posts') + .doc() + .withConverter({ + toFirestore(post: Post): firestore.DocumentData { + return { title: post.title, author: post.author }; + }, + fromFirestore( + snapshot: firestore.QueryDocumentSnapshot, + options: firestore.SnapshotOptions + ): Post { + const data = snapshot.data(options); + return new Post(data.title, data.author); + } + }); + return docRef.firestore + .batch() + .set(docRef, new Post('post', 'author')) + .commit() + .then(() => docRef.get()) + .then(snapshot => { + expect(snapshot.exists).to.equal(true); + expect(snapshot.data()!.byline()).to.deep.equal('post, by author'); + }); + }); + }); + }); }); diff --git a/packages/firestore/test/integration/api/database.test.ts b/packages/firestore/test/integration/api/database.test.ts index c157cb2d833..e9c8d738021 100644 --- a/packages/firestore/test/integration/api/database.test.ts +++ b/packages/firestore/test/integration/api/database.test.ts @@ -448,7 +448,7 @@ apiDescribe('Database', (persistence: boolean) => { .update({ owner: 'abc' }) .then( () => Promise.reject('update should have failed.'), - (err: firestore.FirestoreError) => { + err => { expect(err.message).to.exist; // TODO: Change this to just match "no document to update" once the // backend response is consistent. @@ -1229,4 +1229,110 @@ apiDescribe('Database', (persistence: boolean) => { await firestore.waitForPendingWrites(); }); }); + + // PORTING NOTE: These tests are for FirestoreDataConverter support and apply + // only to web. + apiDescribe('withConverter() support', (persistence: boolean) => { + class Post { + constructor(readonly title: string, readonly author: string) {} + byline(): string { + return this.title + ', by ' + this.author; + } + } + + const postConverter = { + toFirestore(post: Post): firestore.DocumentData { + return { title: post.title, author: post.author }; + }, + fromFirestore( + snapshot: firestore.QueryDocumentSnapshot, + options: firestore.SnapshotOptions + ): Post { + const data = snapshot.data(options); + return new Post(data.title, data.author); + } + }; + + it('for DocumentReference.withConverter()', () => { + return withTestDb(persistence, async db => { + const docRef = db + .collection('posts') + .doc() + .withConverter(postConverter); + + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + expect(post).to.not.equal(undefined); + expect(post!.byline()).to.equal('post, by author'); + }); + }); + + it('for CollectionReference.withConverter()', () => { + return withTestDb(persistence, async db => { + const docRef = db + .collection('posts') + .withConverter(postConverter) + .doc(); + + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + const post = postData.data(); + expect(post).to.not.equal(undefined); + expect(post!.byline()).to.equal('post, by author'); + }); + }); + + it('for Query.withConverter()', () => { + return withTestDb(persistence, async db => { + await db + .doc('postings/post1') + .set({ title: 'post1', author: 'author1' }); + await db + .doc('postings/post2') + .set({ title: 'post2', author: 'author2' }); + const posts = await db + .collectionGroup('postings') + .withConverter(postConverter) + .get(); + expect(posts.size).to.equal(2); + expect(posts.docs[0].data()!.byline()).to.equal('post1, by author1'); + }); + }); + + it('calls DocumentSnapshot.data() with specified SnapshotOptions', () => { + return withTestDb(persistence, async db => { + const docRef = db.doc('some/doc').withConverter({ + toFirestore(post: Post): firestore.DocumentData { + return { title: post.title, author: post.author }; + }, + fromFirestore( + snapshot: firestore.QueryDocumentSnapshot, + options: firestore.SnapshotOptions + ): Post { + // Check that options were passed in properly. + expect(options).to.deep.equal({ serverTimestamps: 'estimate' }); + + const data = snapshot.data(options); + return new Post(data.title, data.author); + } + }); + + await docRef.set(new Post('post', 'author')); + const postData = await docRef.get(); + postData.data({ serverTimestamps: 'estimate' }); + }); + }); + + it('drops the converter when calling CollectionReference.parent()', () => { + return withTestDb(persistence, async db => { + const postsCollection = db + .collection('users/user1/posts') + .withConverter(postConverter); + + const usersCollection = postsCollection.parent; + expect(usersCollection!.isEqual(db.doc('users/user1'))).to.be.true; + }); + }); + }); }); diff --git a/packages/firestore/test/integration/api/transactions.test.ts b/packages/firestore/test/integration/api/transactions.test.ts index a1cd6c15c85..35c743b3b2b 100644 --- a/packages/firestore/test/integration/api/transactions.test.ts +++ b/packages/firestore/test/integration/api/transactions.test.ts @@ -889,4 +889,47 @@ apiDescribe('Database transactions', (persistence: boolean) => { }); }); }); + + // PORTING NOTE: These tests are for FirestoreDataConverter support and apply + // only to web. + apiDescribe('withConverter() support', (persistence: boolean) => { + class Post { + constructor(readonly title: string, readonly author: string) {} + byline(): string { + return this.title + ', by ' + this.author; + } + } + + it('for Transaction.set() and Transaction.get()', () => { + return integrationHelpers.withTestDb(persistence, db => { + const docRef = db + .collection('posts') + .doc() + .withConverter({ + toFirestore(post: Post): firestore.DocumentData { + return { title: post.title, author: post.author }; + }, + fromFirestore( + snapshot: firestore.QueryDocumentSnapshot, + options: firestore.SnapshotOptions + ): Post { + const data = snapshot.data(options); + return new Post(data.title, data.author); + } + }); + return docRef.set(new Post('post', 'author')).then(() => { + return db + .runTransaction(async transaction => { + const snapshot = await transaction.get(docRef); + expect(snapshot.data()!.byline()).to.equal('post, by author'); + transaction.set(docRef, new Post('new post', 'author')); + }) + .then(async () => { + const snapshot = await docRef.get(); + expect(snapshot.data()!.byline()).to.equal('new post, by author'); + }); + }); + }); + }); + }); }); diff --git a/packages/firestore/test/util/test_platform.ts b/packages/firestore/test/util/test_platform.ts index a1974fa4e25..3d09b59dd46 100644 --- a/packages/firestore/test/util/test_platform.ts +++ b/packages/firestore/test/util/test_platform.ts @@ -45,7 +45,8 @@ export class FakeWindow { }); this.fakeIndexedDb = fakeIndexedDb || - (typeof window !== 'undefined' && window.indexedDB) || null; + (typeof window !== 'undefined' && window.indexedDB) || + null; } get localStorage(): Storage {