From 5bc6afb75b5267bad5940c32458c315e5394321d Mon Sep 17 00:00:00 2001 From: Brian Chen Date: Wed, 18 Aug 2021 18:50:06 -0500 Subject: [PATCH] Firestore: QoL improvements for converters (#5268) --- .changeset/dirty-pandas-pay.md | 10 + common/api-review/firestore-lite.api.md | 56 +- common/api-review/firestore.api.md | 1014 +++++++++-------- packages/firebase/index.d.ts | 10 +- packages/firestore/compat/config.ts | 2 +- packages/firestore/exp/api.ts | 9 + packages/firestore/lite/index.ts | 11 +- packages/firestore/src/api/database.ts | 29 +- packages/firestore/src/exp/reference.ts | 2 + packages/firestore/src/exp/reference_impl.ts | 24 +- packages/firestore/src/exp/snapshot.ts | 28 +- packages/firestore/src/lite/reference.ts | 41 +- packages/firestore/src/lite/reference_impl.ts | 31 +- packages/firestore/src/lite/snapshot.ts | 21 +- packages/firestore/src/lite/transaction.ts | 22 +- packages/firestore/src/lite/types.ts | 72 ++ .../firestore/src/lite/user_data_reader.ts | 13 +- packages/firestore/src/lite/write_batch.ts | 25 +- packages/firestore/test/lite/helpers.ts | 14 +- .../firestore/test/lite/integration.test.ts | 539 ++++++++- 20 files changed, 1367 insertions(+), 606 deletions(-) create mode 100644 .changeset/dirty-pandas-pay.md create mode 100644 packages/firestore/src/lite/types.ts diff --git a/.changeset/dirty-pandas-pay.md b/.changeset/dirty-pandas-pay.md new file mode 100644 index 00000000000..a582ac41378 --- /dev/null +++ b/.changeset/dirty-pandas-pay.md @@ -0,0 +1,10 @@ +--- +'firebase': major +'@firebase/firestore': major +--- + +This change contains multiple quality-of-life improvements when using the `FirestoreDataConverter` in `@firebase/firestore/lite` and `@firebase/firestore`: +- Support for passing in `FieldValue` property values when using a converter (via `WithFieldValue` and `PartialWithFieldValue`). +- Support for omitting properties in nested fields when performing a set operation with `{merge: true}` with a converter (via `PartialWithFieldValue`). +- Support for typed update operations when using a converter (via the newly typed `UpdateData`). Improperly typed fields in +update operations on typed document references will no longer compile. diff --git a/common/api-review/firestore-lite.api.md b/common/api-review/firestore-lite.api.md index 454a4937c5d..0d3d3c0ea18 100644 --- a/common/api-review/firestore-lite.api.md +++ b/common/api-review/firestore-lite.api.md @@ -9,7 +9,12 @@ import { FirebaseApp } from '@firebase/app-exp'; import { LogLevelString as LogLevel } from '@firebase/logger'; // @public -export function addDoc(reference: CollectionReference, data: T): Promise>; +export function addDoc(reference: CollectionReference, data: WithFieldValue): Promise>; + +// @public +export type AddPrefixToKeys> = { + [K in keyof T & string as `${Prefix}.${K}`]+?: T[K]; +}; // @public export function arrayRemove(...elements: unknown[]): FieldValue; @@ -132,8 +137,8 @@ export class Firestore { // @public export interface FirestoreDataConverter { fromFirestore(snapshot: QueryDocumentSnapshot): T; - toFirestore(modelObject: T): DocumentData; - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + toFirestore(modelObject: WithFieldValue): DocumentData; + toFirestore(modelObject: PartialWithFieldValue, options: SetOptions): DocumentData; } // @public @@ -182,12 +187,25 @@ export function limitToLast(limit: number): QueryConstraint; export { LogLevel } +// @public +export type NestedUpdateFields> = UnionToIntersection<{ + [K in keyof T & string]: T[K] extends Record ? AddPrefixToKeys> : never; +}[keyof T & string]>; + // @public export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint; // @public export type OrderByDirection = 'desc' | 'asc'; +// @public +export type PartialWithFieldValue = T extends Primitive ? T : T extends {} ? { + [K in keyof T]?: PartialWithFieldValue | FieldValue; +} : Partial; + +// @public +export type Primitive = string | number | boolean | undefined | null; + // @public export class Query { protected constructor(); @@ -237,10 +255,10 @@ export function runTransaction(firestore: Firestore, updateFunction: (transac export function serverTimestamp(): FieldValue; // @public -export function setDoc(reference: DocumentReference, data: T): Promise; +export function setDoc(reference: DocumentReference, data: WithFieldValue): Promise; // @public -export function setDoc(reference: DocumentReference, data: Partial, options: SetOptions): Promise; +export function setDoc(reference: DocumentReference, data: PartialWithFieldValue, options: SetOptions): Promise; // @public export function setLogLevel(logLevel: LogLevel): void; @@ -302,19 +320,22 @@ export class Timestamp { export class Transaction { delete(documentRef: DocumentReference): this; get(documentRef: DocumentReference): Promise>; - set(documentRef: DocumentReference, data: T): this; - set(documentRef: DocumentReference, data: Partial, options: SetOptions): this; - update(documentRef: DocumentReference, data: UpdateData): this; + set(documentRef: DocumentReference, data: WithFieldValue): this; + set(documentRef: DocumentReference, data: PartialWithFieldValue, options: SetOptions): this; + update(documentRef: DocumentReference, data: UpdateData): this; update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): this; } // @public -export interface UpdateData { - [fieldPath: string]: any; -} +export type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; // @public -export function updateDoc(reference: DocumentReference, data: UpdateData): Promise; +export type UpdateData = T extends Primitive ? T : T extends Map ? Map, UpdateData> : T extends {} ? { + [K in keyof T]?: UpdateData | FieldValue; +} & NestedUpdateFields : Partial; + +// @public +export function updateDoc(reference: DocumentReference, data: UpdateData): Promise; // @public export function updateDoc(reference: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): Promise; @@ -325,13 +346,18 @@ export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value // @public export type WhereFilterOp = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'array-contains-any' | 'not-in'; +// @public +export type WithFieldValue = T extends Primitive ? T : T extends {} ? { + [K in keyof T]: WithFieldValue | FieldValue; +} : Partial; + // @public export class WriteBatch { commit(): Promise; delete(documentRef: DocumentReference): WriteBatch; - set(documentRef: DocumentReference, data: T): WriteBatch; - set(documentRef: DocumentReference, data: Partial, options: SetOptions): WriteBatch; - update(documentRef: DocumentReference, data: UpdateData): WriteBatch; + set(documentRef: DocumentReference, data: WithFieldValue): WriteBatch; + set(documentRef: DocumentReference, data: PartialWithFieldValue, options: SetOptions): WriteBatch; + update(documentRef: DocumentReference, data: UpdateData): WriteBatch; update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): WriteBatch; } diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index f650f3d655e..8d9ba3034ae 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -1,494 +1,520 @@ -## API Report File for "@firebase/firestore" - -> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). - -```ts - -import { EmulatorMockTokenOptions } from '@firebase/util'; -import { FirebaseApp } from '@firebase/app-exp'; -import { LogLevelString as LogLevel } from '@firebase/logger'; - -// @public -export function addDoc(reference: CollectionReference, data: T): Promise>; - -// @public -export function arrayRemove(...elements: unknown[]): FieldValue; - -// @public -export function arrayUnion(...elements: unknown[]): FieldValue; - -// @public -export class Bytes { - static fromBase64String(base64: string): Bytes; - static fromUint8Array(array: Uint8Array): Bytes; - isEqual(other: Bytes): boolean; - toBase64(): string; - toString(): string; - toUint8Array(): Uint8Array; -} - -// @public -export const CACHE_SIZE_UNLIMITED = -1; - -// @public -export function clearIndexedDbPersistence(firestore: Firestore): Promise; - -// @public -export function collection(firestore: Firestore, path: string, ...pathSegments: string[]): CollectionReference; - -// @public -export function collection(reference: CollectionReference, path: string, ...pathSegments: string[]): CollectionReference; - -// @public -export function collection(reference: DocumentReference, path: string, ...pathSegments: string[]): CollectionReference; - -// @public -export function collectionGroup(firestore: Firestore, collectionId: string): Query; - -// @public -export class CollectionReference extends Query { - get id(): string; - get parent(): DocumentReference | null; - get path(): string; - readonly type = "collection"; - withConverter(converter: FirestoreDataConverter): CollectionReference; - withConverter(converter: null): CollectionReference; -} - -// @public -export function connectFirestoreEmulator(firestore: Firestore, host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions | string; -}): void; - -// @public -export function deleteDoc(reference: DocumentReference): Promise; - -// @public -export function deleteField(): FieldValue; - -// @public -export function disableNetwork(firestore: Firestore): Promise; - -// @public -export function doc(firestore: Firestore, path: string, ...pathSegments: string[]): DocumentReference; - -// @public -export function doc(reference: CollectionReference, path?: string, ...pathSegments: string[]): DocumentReference; - -// @public -export function doc(reference: DocumentReference, path: string, ...pathSegments: string[]): DocumentReference; - -// @public -export interface DocumentChange { - readonly doc: QueryDocumentSnapshot; - readonly newIndex: number; - readonly oldIndex: number; - readonly type: DocumentChangeType; -} - -// @public -export type DocumentChangeType = 'added' | 'removed' | 'modified'; - -// @public -export interface DocumentData { - [field: string]: any; -} - -// @public -export function documentId(): FieldPath; - -// @public -export class DocumentReference { - readonly converter: FirestoreDataConverter | null; - readonly firestore: Firestore; - get id(): string; - get parent(): CollectionReference; - get path(): string; - readonly type = "document"; - withConverter(converter: FirestoreDataConverter): DocumentReference; - withConverter(converter: null): DocumentReference; -} - -// @public -export class DocumentSnapshot { - protected constructor(); - data(options?: SnapshotOptions): T | undefined; - exists(): this is QueryDocumentSnapshot; - get(fieldPath: string | FieldPath, options?: SnapshotOptions): any; - get id(): string; - readonly metadata: SnapshotMetadata; - get ref(): DocumentReference; -} - -// @public -export function enableIndexedDbPersistence(firestore: Firestore, persistenceSettings?: PersistenceSettings): Promise; - -// @public -export function enableMultiTabIndexedDbPersistence(firestore: Firestore): Promise; - -// @public -export function enableNetwork(firestore: Firestore): Promise; - -// @public -export function endAt(snapshot: DocumentSnapshot): QueryConstraint; - -// @public -export function endAt(...fieldValues: unknown[]): QueryConstraint; - -// @public -export function endBefore(snapshot: DocumentSnapshot): QueryConstraint; - -// @public -export function endBefore(...fieldValues: unknown[]): QueryConstraint; - -// @public -export class FieldPath { - constructor(...fieldNames: string[]); - isEqual(other: FieldPath): boolean; -} - -// @public -export abstract class FieldValue { - abstract isEqual(other: FieldValue): boolean; -} - -// @public -export class Firestore { - get app(): FirebaseApp; - toJSON(): object; - type: 'firestore-lite' | 'firestore'; -} - -// @public -export interface FirestoreDataConverter { - fromFirestore(snapshot: QueryDocumentSnapshot, options?: SnapshotOptions): T; - toFirestore(modelObject: T): DocumentData; - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; -} - -// @public -export class FirestoreError extends Error { - readonly code: FirestoreErrorCode; - readonly message: string; - readonly name: string; - readonly stack?: string; -} - -// @public -export type FirestoreErrorCode = 'cancelled' | 'unknown' | 'invalid-argument' | 'deadline-exceeded' | 'not-found' | 'already-exists' | 'permission-denied' | 'resource-exhausted' | 'failed-precondition' | 'aborted' | 'out-of-range' | 'unimplemented' | 'internal' | 'unavailable' | 'data-loss' | 'unauthenticated'; - -// @public -export interface FirestoreSettings { - cacheSizeBytes?: number; - experimentalAutoDetectLongPolling?: boolean; - experimentalForceLongPolling?: boolean; - host?: string; - ignoreUndefinedProperties?: boolean; - ssl?: boolean; -} - -// @public -export class GeoPoint { - constructor(latitude: number, longitude: number); - isEqual(other: GeoPoint): boolean; - get latitude(): number; - get longitude(): number; - toJSON(): { - latitude: number; - longitude: number; - }; -} - -// @public -export function getDoc(reference: DocumentReference): Promise>; - -// @public -export function getDocFromCache(reference: DocumentReference): Promise>; - -// @public -export function getDocFromServer(reference: DocumentReference): Promise>; - -// @public -export function getDocs(query: Query): Promise>; - -// @public -export function getDocsFromCache(query: Query): Promise>; - -// @public -export function getDocsFromServer(query: Query): Promise>; - -// @public -export function getFirestore(app?: FirebaseApp): Firestore; - -// @public -export function increment(n: number): FieldValue; - -// @public -export function initializeFirestore(app: FirebaseApp, settings: FirestoreSettings): Firestore; - -// @public -export function limit(limit: number): QueryConstraint; - -// @public -export function limitToLast(limit: number): QueryConstraint; - -// @public -export function loadBundle(firestore: Firestore, bundleData: ReadableStream | ArrayBuffer | string): LoadBundleTask; - -// @public -export class LoadBundleTask implements PromiseLike { - catch(onRejected: (a: Error) => R | PromiseLike): Promise; - onProgress(next?: (progress: LoadBundleTaskProgress) => unknown, error?: (err: Error) => unknown, complete?: () => void): void; - then(onFulfilled?: (a: LoadBundleTaskProgress) => T | PromiseLike, onRejected?: (a: Error) => R | PromiseLike): Promise; -} - -// @public -export interface LoadBundleTaskProgress { - bytesLoaded: number; - documentsLoaded: number; - taskState: TaskState; - totalBytes: number; - totalDocuments: number; -} - -export { LogLevel } - -// @public -export function namedQuery(firestore: Firestore, name: string): Promise; - -// @public -export function onSnapshot(reference: DocumentReference, observer: { - next?: (snapshot: DocumentSnapshot) => void; - error?: (error: FirestoreError) => void; - complete?: () => void; -}): Unsubscribe; - -// @public -export function onSnapshot(reference: DocumentReference, options: SnapshotListenOptions, observer: { - next?: (snapshot: DocumentSnapshot) => void; - error?: (error: FirestoreError) => void; - complete?: () => void; -}): Unsubscribe; - -// @public -export function onSnapshot(reference: DocumentReference, onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; - -// @public -export function onSnapshot(reference: DocumentReference, options: SnapshotListenOptions, onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; - -// @public -export function onSnapshot(query: Query, observer: { - next?: (snapshot: QuerySnapshot) => void; - error?: (error: FirestoreError) => void; - complete?: () => void; -}): Unsubscribe; - -// @public -export function onSnapshot(query: Query, options: SnapshotListenOptions, observer: { - next?: (snapshot: QuerySnapshot) => void; - error?: (error: FirestoreError) => void; - complete?: () => void; -}): Unsubscribe; - -// @public -export function onSnapshot(query: Query, onNext: (snapshot: QuerySnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; - -// @public -export function onSnapshot(query: Query, options: SnapshotListenOptions, onNext: (snapshot: QuerySnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; - -// @public -export function onSnapshotsInSync(firestore: Firestore, observer: { - next?: (value: void) => void; - error?: (error: FirestoreError) => void; - complete?: () => void; -}): Unsubscribe; - -// @public -export function onSnapshotsInSync(firestore: Firestore, onSync: () => void): Unsubscribe; - -// @public -export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint; - -// @public -export type OrderByDirection = 'desc' | 'asc'; - -// @public -export interface PersistenceSettings { - forceOwnership?: boolean; -} - -// @public -export class Query { - protected constructor(); - readonly converter: FirestoreDataConverter | null; - readonly firestore: Firestore; - readonly type: 'query' | 'collection'; - withConverter(converter: null): Query; - withConverter(converter: FirestoreDataConverter): Query; -} - -// @public -export function query(query: Query, ...queryConstraints: QueryConstraint[]): Query; - -// @public -export abstract class QueryConstraint { - abstract readonly type: QueryConstraintType; -} - -// @public -export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore'; - -// @public -export class QueryDocumentSnapshot extends DocumentSnapshot { - // @override - data(options?: SnapshotOptions): T; -} - -// @public -export function queryEqual(left: Query, right: Query): boolean; - -// @public -export class QuerySnapshot { - docChanges(options?: SnapshotListenOptions): Array>; - get docs(): Array>; - get empty(): boolean; - forEach(callback: (result: QueryDocumentSnapshot) => void, thisArg?: unknown): void; - readonly metadata: SnapshotMetadata; - readonly query: Query; - get size(): number; -} - -// @public -export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; - -// @public -export function runTransaction(firestore: Firestore, updateFunction: (transaction: Transaction) => Promise): Promise; - -// @public -export function serverTimestamp(): FieldValue; - -// @public -export function setDoc(reference: DocumentReference, data: T): Promise; - -// @public -export function setDoc(reference: DocumentReference, data: Partial, options: SetOptions): Promise; - -// @public -export function setLogLevel(logLevel: LogLevel): void; - -// @public -export type SetOptions = { - readonly merge?: boolean; -} | { - readonly mergeFields?: Array; -}; - -// @public -export function snapshotEqual(left: DocumentSnapshot | QuerySnapshot, right: DocumentSnapshot | QuerySnapshot): boolean; - -// @public -export interface SnapshotListenOptions { - readonly includeMetadataChanges?: boolean; -} - -// @public -export class SnapshotMetadata { - readonly fromCache: boolean; - readonly hasPendingWrites: boolean; - isEqual(other: SnapshotMetadata): boolean; -} - -// @public -export interface SnapshotOptions { - readonly serverTimestamps?: 'estimate' | 'previous' | 'none'; -} - -// @public -export function startAfter(snapshot: DocumentSnapshot): QueryConstraint; - -// @public -export function startAfter(...fieldValues: unknown[]): QueryConstraint; - -// @public -export function startAt(snapshot: DocumentSnapshot): QueryConstraint; - -// @public -export function startAt(...fieldValues: unknown[]): QueryConstraint; - -// @public -export type TaskState = 'Error' | 'Running' | 'Success'; - -// @public -export function terminate(firestore: Firestore): Promise; - -// @public -export class Timestamp { - constructor( - seconds: number, - nanoseconds: number); - static fromDate(date: Date): Timestamp; - static fromMillis(milliseconds: number): Timestamp; - isEqual(other: Timestamp): boolean; - readonly nanoseconds: number; - static now(): Timestamp; - readonly seconds: number; - toDate(): Date; - toJSON(): { - seconds: number; - nanoseconds: number; - }; - toMillis(): number; - toString(): string; - valueOf(): string; -} - -// @public -export class Transaction { - delete(documentRef: DocumentReference): this; - get(documentRef: DocumentReference): Promise>; - set(documentRef: DocumentReference, data: T): this; - set(documentRef: DocumentReference, data: Partial, options: SetOptions): this; - update(documentRef: DocumentReference, data: UpdateData): this; - update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): this; -} - -// @public -export interface Unsubscribe { - (): void; -} - -// @public -export interface UpdateData { - [fieldPath: string]: any; -} - -// @public -export function updateDoc(reference: DocumentReference, data: UpdateData): Promise; - -// @public -export function updateDoc(reference: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): Promise; - -// @public -export function waitForPendingWrites(firestore: Firestore): Promise; - -// @public -export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryConstraint; - -// @public -export type WhereFilterOp = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'array-contains-any' | 'not-in'; - -// @public -export class WriteBatch { - commit(): Promise; - delete(documentRef: DocumentReference): WriteBatch; - set(documentRef: DocumentReference, data: T): WriteBatch; - set(documentRef: DocumentReference, data: Partial, options: SetOptions): WriteBatch; - update(documentRef: DocumentReference, data: UpdateData): WriteBatch; - update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): WriteBatch; -} - -// @public -export function writeBatch(firestore: Firestore): WriteBatch; - - -``` +## API Report File for "@firebase/firestore" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import { EmulatorMockTokenOptions } from '@firebase/util'; +import { FirebaseApp } from '@firebase/app-exp'; +import { LogLevelString as LogLevel } from '@firebase/logger'; + +// @public +export function addDoc(reference: CollectionReference, data: WithFieldValue): Promise>; + +// @public +export type AddPrefixToKeys> = { + [K in keyof T & string as `${Prefix}.${K}`]+?: T[K]; +}; + +// @public +export function arrayRemove(...elements: unknown[]): FieldValue; + +// @public +export function arrayUnion(...elements: unknown[]): FieldValue; + +// @public +export class Bytes { + static fromBase64String(base64: string): Bytes; + static fromUint8Array(array: Uint8Array): Bytes; + isEqual(other: Bytes): boolean; + toBase64(): string; + toString(): string; + toUint8Array(): Uint8Array; +} + +// @public +export const CACHE_SIZE_UNLIMITED = -1; + +// @public +export function clearIndexedDbPersistence(firestore: Firestore): Promise; + +// @public +export function collection(firestore: Firestore, path: string, ...pathSegments: string[]): CollectionReference; + +// @public +export function collection(reference: CollectionReference, path: string, ...pathSegments: string[]): CollectionReference; + +// @public +export function collection(reference: DocumentReference, path: string, ...pathSegments: string[]): CollectionReference; + +// @public +export function collectionGroup(firestore: Firestore, collectionId: string): Query; + +// @public +export class CollectionReference extends Query { + get id(): string; + get parent(): DocumentReference | null; + get path(): string; + readonly type = "collection"; + withConverter(converter: FirestoreDataConverter): CollectionReference; + withConverter(converter: null): CollectionReference; +} + +// @public +export function connectFirestoreEmulator(firestore: Firestore, host: string, port: number, options?: { + mockUserToken?: EmulatorMockTokenOptions | string; +}): void; + +// @public +export function deleteDoc(reference: DocumentReference): Promise; + +// @public +export function deleteField(): FieldValue; + +// @public +export function disableNetwork(firestore: Firestore): Promise; + +// @public +export function doc(firestore: Firestore, path: string, ...pathSegments: string[]): DocumentReference; + +// @public +export function doc(reference: CollectionReference, path?: string, ...pathSegments: string[]): DocumentReference; + +// @public +export function doc(reference: DocumentReference, path: string, ...pathSegments: string[]): DocumentReference; + +// @public +export interface DocumentChange { + readonly doc: QueryDocumentSnapshot; + readonly newIndex: number; + readonly oldIndex: number; + readonly type: DocumentChangeType; +} + +// @public +export type DocumentChangeType = 'added' | 'removed' | 'modified'; + +// @public +export interface DocumentData { + [field: string]: any; +} + +// @public +export function documentId(): FieldPath; + +// @public +export class DocumentReference { + readonly converter: FirestoreDataConverter | null; + readonly firestore: Firestore; + get id(): string; + get parent(): CollectionReference; + get path(): string; + readonly type = "document"; + withConverter(converter: FirestoreDataConverter): DocumentReference; + withConverter(converter: null): DocumentReference; +} + +// @public +export class DocumentSnapshot { + protected constructor(); + data(options?: SnapshotOptions): T | undefined; + exists(): this is QueryDocumentSnapshot; + get(fieldPath: string | FieldPath, options?: SnapshotOptions): any; + get id(): string; + readonly metadata: SnapshotMetadata; + get ref(): DocumentReference; +} + +// @public +export function enableIndexedDbPersistence(firestore: Firestore, persistenceSettings?: PersistenceSettings): Promise; + +// @public +export function enableMultiTabIndexedDbPersistence(firestore: Firestore): Promise; + +// @public +export function enableNetwork(firestore: Firestore): Promise; + +// @public +export function endAt(snapshot: DocumentSnapshot): QueryConstraint; + +// @public +export function endAt(...fieldValues: unknown[]): QueryConstraint; + +// @public +export function endBefore(snapshot: DocumentSnapshot): QueryConstraint; + +// @public +export function endBefore(...fieldValues: unknown[]): QueryConstraint; + +// @public +export class FieldPath { + constructor(...fieldNames: string[]); + isEqual(other: FieldPath): boolean; +} + +// @public +export abstract class FieldValue { + abstract isEqual(other: FieldValue): boolean; +} + +// @public +export class Firestore { + get app(): FirebaseApp; + toJSON(): object; + type: 'firestore-lite' | 'firestore'; +} + +// @public +export interface FirestoreDataConverter { + fromFirestore(snapshot: QueryDocumentSnapshot, options?: SnapshotOptions): T; + toFirestore(modelObject: WithFieldValue): DocumentData; + toFirestore(modelObject: PartialWithFieldValue, options: SetOptions): DocumentData; +} + +// @public +export class FirestoreError extends Error { + readonly code: FirestoreErrorCode; + readonly message: string; + readonly name: string; + readonly stack?: string; +} + +// @public +export type FirestoreErrorCode = 'cancelled' | 'unknown' | 'invalid-argument' | 'deadline-exceeded' | 'not-found' | 'already-exists' | 'permission-denied' | 'resource-exhausted' | 'failed-precondition' | 'aborted' | 'out-of-range' | 'unimplemented' | 'internal' | 'unavailable' | 'data-loss' | 'unauthenticated'; + +// @public +export interface FirestoreSettings { + cacheSizeBytes?: number; + experimentalAutoDetectLongPolling?: boolean; + experimentalForceLongPolling?: boolean; + host?: string; + ignoreUndefinedProperties?: boolean; + ssl?: boolean; +} + +// @public +export class GeoPoint { + constructor(latitude: number, longitude: number); + isEqual(other: GeoPoint): boolean; + get latitude(): number; + get longitude(): number; + toJSON(): { + latitude: number; + longitude: number; + }; +} + +// @public +export function getDoc(reference: DocumentReference): Promise>; + +// @public +export function getDocFromCache(reference: DocumentReference): Promise>; + +// @public +export function getDocFromServer(reference: DocumentReference): Promise>; + +// @public +export function getDocs(query: Query): Promise>; + +// @public +export function getDocsFromCache(query: Query): Promise>; + +// @public +export function getDocsFromServer(query: Query): Promise>; + +// @public +export function getFirestore(app?: FirebaseApp): Firestore; + +// @public +export function increment(n: number): FieldValue; + +// @public +export function initializeFirestore(app: FirebaseApp, settings: FirestoreSettings): Firestore; + +// @public +export function limit(limit: number): QueryConstraint; + +// @public +export function limitToLast(limit: number): QueryConstraint; + +// @public +export function loadBundle(firestore: Firestore, bundleData: ReadableStream | ArrayBuffer | string): LoadBundleTask; + +// @public +export class LoadBundleTask implements PromiseLike { + catch(onRejected: (a: Error) => R | PromiseLike): Promise; + onProgress(next?: (progress: LoadBundleTaskProgress) => unknown, error?: (err: Error) => unknown, complete?: () => void): void; + then(onFulfilled?: (a: LoadBundleTaskProgress) => T | PromiseLike, onRejected?: (a: Error) => R | PromiseLike): Promise; +} + +// @public +export interface LoadBundleTaskProgress { + bytesLoaded: number; + documentsLoaded: number; + taskState: TaskState; + totalBytes: number; + totalDocuments: number; +} + +export { LogLevel } + +// @public +export function namedQuery(firestore: Firestore, name: string): Promise; + +// @public +export type NestedUpdateFields> = UnionToIntersection<{ + [K in keyof T & string]: T[K] extends Record ? AddPrefixToKeys> : never; +}[keyof T & string]>; + +// @public +export function onSnapshot(reference: DocumentReference, observer: { + next?: (snapshot: DocumentSnapshot) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; + +// @public +export function onSnapshot(reference: DocumentReference, options: SnapshotListenOptions, observer: { + next?: (snapshot: DocumentSnapshot) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; + +// @public +export function onSnapshot(reference: DocumentReference, onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; + +// @public +export function onSnapshot(reference: DocumentReference, options: SnapshotListenOptions, onNext: (snapshot: DocumentSnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; + +// @public +export function onSnapshot(query: Query, observer: { + next?: (snapshot: QuerySnapshot) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; + +// @public +export function onSnapshot(query: Query, options: SnapshotListenOptions, observer: { + next?: (snapshot: QuerySnapshot) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; + +// @public +export function onSnapshot(query: Query, onNext: (snapshot: QuerySnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; + +// @public +export function onSnapshot(query: Query, options: SnapshotListenOptions, onNext: (snapshot: QuerySnapshot) => void, onError?: (error: FirestoreError) => void, onCompletion?: () => void): Unsubscribe; + +// @public +export function onSnapshotsInSync(firestore: Firestore, observer: { + next?: (value: void) => void; + error?: (error: FirestoreError) => void; + complete?: () => void; +}): Unsubscribe; + +// @public +export function onSnapshotsInSync(firestore: Firestore, onSync: () => void): Unsubscribe; + +// @public +export function orderBy(fieldPath: string | FieldPath, directionStr?: OrderByDirection): QueryConstraint; + +// @public +export type OrderByDirection = 'desc' | 'asc'; + +// @public +export type PartialWithFieldValue = T extends Primitive ? T : T extends {} ? { + [K in keyof T]?: PartialWithFieldValue | FieldValue; +} : Partial; + +// @public +export interface PersistenceSettings { + forceOwnership?: boolean; +} + +// @public +export type Primitive = string | number | boolean | undefined | null; + +// @public +export class Query { + protected constructor(); + readonly converter: FirestoreDataConverter | null; + readonly firestore: Firestore; + readonly type: 'query' | 'collection'; + withConverter(converter: null): Query; + withConverter(converter: FirestoreDataConverter): Query; +} + +// @public +export function query(query: Query, ...queryConstraints: QueryConstraint[]): Query; + +// @public +export abstract class QueryConstraint { + abstract readonly type: QueryConstraintType; +} + +// @public +export type QueryConstraintType = 'where' | 'orderBy' | 'limit' | 'limitToLast' | 'startAt' | 'startAfter' | 'endAt' | 'endBefore'; + +// @public +export class QueryDocumentSnapshot extends DocumentSnapshot { + // @override + data(options?: SnapshotOptions): T; +} + +// @public +export function queryEqual(left: Query, right: Query): boolean; + +// @public +export class QuerySnapshot { + docChanges(options?: SnapshotListenOptions): Array>; + get docs(): Array>; + get empty(): boolean; + forEach(callback: (result: QueryDocumentSnapshot) => void, thisArg?: unknown): void; + readonly metadata: SnapshotMetadata; + readonly query: Query; + get size(): number; +} + +// @public +export function refEqual(left: DocumentReference | CollectionReference, right: DocumentReference | CollectionReference): boolean; + +// @public +export function runTransaction(firestore: Firestore, updateFunction: (transaction: Transaction) => Promise): Promise; + +// @public +export function serverTimestamp(): FieldValue; + +// @public +export function setDoc(reference: DocumentReference, data: WithFieldValue): Promise; + +// @public +export function setDoc(reference: DocumentReference, data: PartialWithFieldValue, options: SetOptions): Promise; + +// @public +export function setLogLevel(logLevel: LogLevel): void; + +// @public +export type SetOptions = { + readonly merge?: boolean; +} | { + readonly mergeFields?: Array; +}; + +// @public +export function snapshotEqual(left: DocumentSnapshot | QuerySnapshot, right: DocumentSnapshot | QuerySnapshot): boolean; + +// @public +export interface SnapshotListenOptions { + readonly includeMetadataChanges?: boolean; +} + +// @public +export class SnapshotMetadata { + readonly fromCache: boolean; + readonly hasPendingWrites: boolean; + isEqual(other: SnapshotMetadata): boolean; +} + +// @public +export interface SnapshotOptions { + readonly serverTimestamps?: 'estimate' | 'previous' | 'none'; +} + +// @public +export function startAfter(snapshot: DocumentSnapshot): QueryConstraint; + +// @public +export function startAfter(...fieldValues: unknown[]): QueryConstraint; + +// @public +export function startAt(snapshot: DocumentSnapshot): QueryConstraint; + +// @public +export function startAt(...fieldValues: unknown[]): QueryConstraint; + +// @public +export type TaskState = 'Error' | 'Running' | 'Success'; + +// @public +export function terminate(firestore: Firestore): Promise; + +// @public +export class Timestamp { + constructor( + seconds: number, + nanoseconds: number); + static fromDate(date: Date): Timestamp; + static fromMillis(milliseconds: number): Timestamp; + isEqual(other: Timestamp): boolean; + readonly nanoseconds: number; + static now(): Timestamp; + readonly seconds: number; + toDate(): Date; + toJSON(): { + seconds: number; + nanoseconds: number; + }; + toMillis(): number; + toString(): string; + valueOf(): string; +} + +// @public +export class Transaction { + delete(documentRef: DocumentReference): this; + get(documentRef: DocumentReference): Promise>; + set(documentRef: DocumentReference, data: WithFieldValue): this; + set(documentRef: DocumentReference, data: PartialWithFieldValue, options: SetOptions): this; + update(documentRef: DocumentReference, data: UpdateData): this; + update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): this; +} + +// @public +export type UnionToIntersection = (U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never; + +// @public +export interface Unsubscribe { + (): void; +} + +// @public +export type UpdateData = T extends Primitive ? T : T extends Map ? Map, UpdateData> : T extends {} ? { + [K in keyof T]?: UpdateData | FieldValue; +} & NestedUpdateFields : Partial; + +// @public +export function updateDoc(reference: DocumentReference, data: UpdateData): Promise; + +// @public +export function updateDoc(reference: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): Promise; + +// @public +export function waitForPendingWrites(firestore: Firestore): Promise; + +// @public +export function where(fieldPath: string | FieldPath, opStr: WhereFilterOp, value: unknown): QueryConstraint; + +// @public +export type WhereFilterOp = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'array-contains-any' | 'not-in'; + +// @public +export type WithFieldValue = T extends Primitive ? T : T extends {} ? { + [K in keyof T]: WithFieldValue | FieldValue; +} : Partial; + +// @public +export class WriteBatch { + commit(): Promise; + delete(documentRef: DocumentReference): WriteBatch; + set(documentRef: DocumentReference, data: WithFieldValue): WriteBatch; + set(documentRef: DocumentReference, data: PartialWithFieldValue, options: SetOptions): WriteBatch; + update(documentRef: DocumentReference, data: UpdateData): WriteBatch; + update(documentRef: DocumentReference, field: string | FieldPath, value: unknown, ...moreFieldsAndValues: unknown[]): WriteBatch; +} + +// @public +export function writeBatch(firestore: Firestore): WriteBatch; + + +``` diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index 4a8a58dc713..1aec8b84575 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -7852,9 +7852,13 @@ declare namespace firebase.storage { * @param port - The emulator port (ex: 5001) * @param options.mockUserToken the mock auth token to use for unit testing Security Rules */ - useEmulator(host: string, port: number, options?: { - mockUserToken?: EmulatorMockTokenOptions | string; - }): void; + useEmulator( + host: string, + port: number, + options?: { + mockUserToken?: EmulatorMockTokenOptions | string; + } + ): void; } /** diff --git a/packages/firestore/compat/config.ts b/packages/firestore/compat/config.ts index a33ed09181f..295ecdc8ecc 100644 --- a/packages/firestore/compat/config.ts +++ b/packages/firestore/compat/config.ts @@ -22,7 +22,7 @@ import { _FirebaseNamespace } from '@firebase/app-types/private'; import { Component, ComponentType } from '@firebase/component'; import { - FirebaseFirestore, + Firestore as FirebaseFirestore, CACHE_SIZE_UNLIMITED, GeoPoint, Timestamp diff --git a/packages/firestore/exp/api.ts b/packages/firestore/exp/api.ts index 9ede5594455..f5a0873609e 100644 --- a/packages/firestore/exp/api.ts +++ b/packages/firestore/exp/api.ts @@ -64,6 +64,8 @@ export { SetOptions, DocumentData, UpdateData, + WithFieldValue, + PartialWithFieldValue, refEqual, queryEqual } from '../src/exp/reference'; @@ -129,3 +131,10 @@ export { CACHE_SIZE_UNLIMITED } from '../src/exp/database'; export { FirestoreErrorCode, FirestoreError } from '../src/util/error'; export { AbstractUserDataWriter } from '../src/lite/user_data_writer'; + +export { + Primitive, + NestedUpdateFields, + AddPrefixToKeys, + UnionToIntersection +} from '../src/lite/types'; diff --git a/packages/firestore/lite/index.ts b/packages/firestore/lite/index.ts index 5ed139fcaae..eb4ceef313a 100644 --- a/packages/firestore/lite/index.ts +++ b/packages/firestore/lite/index.ts @@ -38,9 +38,11 @@ export { } from '../src/lite/database'; export { - SetOptions, DocumentData, UpdateData, + WithFieldValue, + PartialWithFieldValue, + SetOptions, DocumentReference, Query, CollectionReference, @@ -76,6 +78,13 @@ export { getDocs } from '../src/lite/reference_impl'; +export { + Primitive, + NestedUpdateFields, + AddPrefixToKeys, + UnionToIntersection +} from '../src/lite/types'; + // TOOD(firestorelite): Add tests when Queries are usable export { FieldPath, documentId } from '../src/lite/field_path'; diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 8ead323e3d0..d8e4847233e 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -106,6 +106,7 @@ import { AbstractUserDataWriter } from '../../exp/index'; // import from the exp public API import { DatabaseId } from '../core/database_info'; +import { PartialWithFieldValue, WithFieldValue } from '../lite/reference'; import { UntypedFirestoreDataConverter } from '../lite/user_data_reader'; import { DocumentKey } from '../model/document_key'; import { FieldPath, ResourcePath } from '../model/path'; @@ -452,9 +453,9 @@ export class Transaction implements PublicTransaction, Compat { const ref = castReference(documentRef); if (options) { validateSetOptions('Transaction.set', options); - this._delegate.set(ref, data, options); + this._delegate.set(ref, data as PartialWithFieldValue, options); } else { - this._delegate.set(ref, data); + this._delegate.set(ref, data as WithFieldValue); } return this; } @@ -513,9 +514,9 @@ export class WriteBatch implements PublicWriteBatch, Compat { const ref = castReference(documentRef); if (options) { validateSetOptions('WriteBatch.set', options); - this._delegate.set(ref, data, options); + this._delegate.set(ref, data as PartialWithFieldValue, options); } else { - this._delegate.set(ref, data); + this._delegate.set(ref, data as WithFieldValue); } return this; } @@ -597,19 +598,19 @@ class FirestoreDataConverter ); } - toFirestore(modelObject: U): PublicDocumentData; + toFirestore(modelObject: WithFieldValue): PublicDocumentData; toFirestore( - modelObject: Partial, + modelObject: PartialWithFieldValue, options: PublicSetOptions ): PublicDocumentData; toFirestore( - modelObject: U | Partial, + modelObject: WithFieldValue | PartialWithFieldValue, options?: PublicSetOptions ): PublicDocumentData { if (!options) { return this._delegate.toFirestore(modelObject as U); } else { - return this._delegate.toFirestore(modelObject, options); + return this._delegate.toFirestore(modelObject as Partial, options); } } @@ -733,7 +734,15 @@ export class DocumentReference set(value: T | Partial, options?: PublicSetOptions): Promise { options = validateSetOptions('DocumentReference.set', options); try { - return setDoc(this._delegate, value, options); + if (options) { + return setDoc( + this._delegate, + value as PartialWithFieldValue, + options + ); + } else { + return setDoc(this._delegate, value as WithFieldValue); + } } catch (e) { throw replaceFunctionName(e, 'setDoc()', 'DocumentReference.set()'); } @@ -1287,7 +1296,7 @@ export class CollectionReference } add(data: T): Promise> { - return addDoc(this._delegate, data).then( + return addDoc(this._delegate, data as WithFieldValue).then( docRef => new DocumentReference(this.firestore, docRef) ); } diff --git a/packages/firestore/src/exp/reference.ts b/packages/firestore/src/exp/reference.ts index 64e4155a2b7..735075fae76 100644 --- a/packages/firestore/src/exp/reference.ts +++ b/packages/firestore/src/exp/reference.ts @@ -26,5 +26,7 @@ export { SetOptions, DocumentData, UpdateData, + WithFieldValue, + PartialWithFieldValue, refEqual } from '../lite/reference'; diff --git a/packages/firestore/src/exp/reference_impl.ts b/packages/firestore/src/exp/reference_impl.ts index e603a4ceacc..53779d13313 100644 --- a/packages/firestore/src/exp/reference_impl.ts +++ b/packages/firestore/src/exp/reference_impl.ts @@ -42,9 +42,11 @@ import { CollectionReference, doc, DocumentReference, + PartialWithFieldValue, Query, SetOptions, - UpdateData + UpdateData, + WithFieldValue } from '../lite/reference'; import { applyFirestoreDataConverter } from '../lite/reference_impl'; import { @@ -243,7 +245,7 @@ export function getDocsFromServer( */ export function setDoc( reference: DocumentReference, - data: T + data: WithFieldValue ): Promise; /** * Writes to the document referred to by the specified `DocumentReference`. If @@ -258,12 +260,12 @@ export function setDoc( */ export function setDoc( reference: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): Promise; export function setDoc( reference: DocumentReference, - data: T, + data: PartialWithFieldValue, options?: SetOptions ): Promise { reference = cast>(reference, DocumentReference); @@ -271,7 +273,7 @@ export function setDoc( const convertedValue = applyFirestoreDataConverter( reference.converter, - data, + data as WithFieldValue, options ); const dataReader = newUserDataReader(firestore); @@ -300,9 +302,9 @@ export function setDoc( * @returns A `Promise` resolved once the data has been successfully written * to the backend (note that it won't resolve while you're offline). */ -export function updateDoc( - reference: DocumentReference, - data: UpdateData +export function updateDoc( + reference: DocumentReference, + data: UpdateData ): Promise; /** * Updates fields in the document referred to by the specified @@ -325,9 +327,9 @@ export function updateDoc( value: unknown, ...moreFieldsAndValues: unknown[] ): Promise; -export function updateDoc( +export function updateDoc( reference: DocumentReference, - fieldOrUpdateData: string | FieldPath | UpdateData, + fieldOrUpdateData: string | FieldPath | UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] ): Promise { @@ -393,7 +395,7 @@ export function deleteDoc( */ export function addDoc( reference: CollectionReference, - data: T + data: WithFieldValue ): Promise> { const firestore = cast(reference.firestore, Firestore); diff --git a/packages/firestore/src/exp/snapshot.ts b/packages/firestore/src/exp/snapshot.ts index bc729e01dae..b10c721ebca 100644 --- a/packages/firestore/src/exp/snapshot.ts +++ b/packages/firestore/src/exp/snapshot.ts @@ -18,7 +18,14 @@ import { newQueryComparator } from '../core/query'; import { ChangeType, ViewSnapshot } from '../core/view_snapshot'; import { FieldPath } from '../lite/field_path'; -import { DocumentData, Query, queryEqual, SetOptions } from '../lite/reference'; +import { + DocumentData, + PartialWithFieldValue, + Query, + queryEqual, + SetOptions, + WithFieldValue +} from '../lite/reference'; import { DocumentSnapshot as LiteDocumentSnapshot, fieldPathFromArgument, @@ -52,7 +59,7 @@ import { SnapshotListenOptions } from './reference_impl'; * } * * const postConverter = { - * toFirestore(post: Post): firebase.firestore.DocumentData { + * toFirestore(post: WithFieldValue): firebase.firestore.DocumentData { * return {title: post.title, author: post.author}; * }, * fromFirestore( @@ -82,17 +89,28 @@ export interface FirestoreDataConverter * Called by the Firestore SDK to convert a custom model object of type `T` * into a plain JavaScript object (suitable for writing directly to the * Firestore database). To use `set()` with `merge` and `mergeFields`, - * `toFirestore()` must be defined with `Partial`. + * `toFirestore()` must be defined with `PartialWithFieldValue`. + * + * The `WithFieldValue` type extends `T` to also allow FieldValues such as + * {@link (deleteField:1)} to be used as property values. */ - toFirestore(modelObject: T): DocumentData; + toFirestore(modelObject: WithFieldValue): DocumentData; /** * Called by the Firestore SDK to convert a custom model object of type `T` * into a plain JavaScript object (suitable for writing directly to the * Firestore database). Used with {@link (setDoc:1)}, {@link (WriteBatch.set:1)} * and {@link (Transaction.set:1)} with `merge:true` or `mergeFields`. + * + * The `PartialWithFieldValue` type extends `Partial` to allow + * FieldValues such as {@link (arrayUnion:1)} to be used as property values. + * It also supports nested `Partial` by allowing nested fields to be + * omitted. */ - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + toFirestore( + modelObject: PartialWithFieldValue, + options: SetOptions + ): DocumentData; /** * Called by the Firestore SDK to convert Firestore data into an object of diff --git a/packages/firestore/src/lite/reference.ts b/packages/firestore/src/lite/reference.ts index 046773041c6..50e6d9a652c 100644 --- a/packages/firestore/src/lite/reference.ts +++ b/packages/firestore/src/lite/reference.ts @@ -36,7 +36,9 @@ import { AutoId } from '../util/misc'; import { Firestore } from './database'; import { FieldPath } from './field_path'; +import { FieldValue } from './field_value'; import { FirestoreDataConverter } from './snapshot'; +import { NestedUpdateFields, Primitive } from './types'; /** * Document data (for use with {@link @firebase/firestore/lite#(setDoc:1)}) consists of fields mapped to @@ -49,15 +51,38 @@ export interface DocumentData { } /** - * Update data (for use with {@link @firebase/firestore/lite#(updateDoc:1)}) consists of field paths (e.g. - * 'foo' or 'foo.baz') mapped to values. Fields that contain dots reference - * nested fields within the document. + * Similar to Typescript's `Partial`, but allows nested fields to be + * omitted and FieldValues to be passed in as property values. */ -export interface UpdateData { - /** A mapping between a dot-separated field path and its value. */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - [fieldPath: string]: any; -} +export type PartialWithFieldValue = T extends Primitive + ? T + : T extends {} + ? { [K in keyof T]?: PartialWithFieldValue | FieldValue } + : Partial; + +/** + * Allows FieldValues to be passed in as a property value while maintaining + * type safety. + */ +export type WithFieldValue = T extends Primitive + ? T + : T extends {} + ? { [K in keyof T]: WithFieldValue | FieldValue } + : Partial; + +/** + * Update data (for use with {@link (updateDoc:1)}) that consists of field paths + * (e.g. 'foo' or 'foo.baz') mapped to values. Fields that contain dots + * reference nested fields within the document. FieldValues can be passed in + * as property values. + */ +export type UpdateData = T extends Primitive + ? T + : T extends Map + ? Map, UpdateData> + : T extends {} + ? { [K in keyof T]?: UpdateData | FieldValue } & NestedUpdateFields + : Partial; /** * An options object that configures the behavior of {@link @firebase/firestore/lite#(setDoc:1)}, {@link diff --git a/packages/firestore/src/lite/reference_impl.ts b/packages/firestore/src/lite/reference_impl.ts index aa9c1687d3c..5bb527d1236 100644 --- a/packages/firestore/src/lite/reference_impl.ts +++ b/packages/firestore/src/lite/reference_impl.ts @@ -41,9 +41,11 @@ import { CollectionReference, doc, DocumentReference, + PartialWithFieldValue, Query, SetOptions, - UpdateData + UpdateData, + WithFieldValue } from './reference'; import { DocumentSnapshot, @@ -71,7 +73,7 @@ import { AbstractUserDataWriter } from './user_data_writer'; */ export function applyFirestoreDataConverter( converter: UntypedFirestoreDataConverter | null, - value: T, + value: WithFieldValue | PartialWithFieldValue, options?: PublicSetOptions ): PublicDocumentData { let convertedValue; @@ -82,7 +84,7 @@ export function applyFirestoreDataConverter( // eslint-disable-next-line @typescript-eslint/no-explicit-any convertedValue = (converter as any).toFirestore(value, options); } else { - convertedValue = converter.toFirestore(value); + convertedValue = converter.toFirestore(value as WithFieldValue); } } else { convertedValue = value as PublicDocumentData; @@ -197,7 +199,7 @@ export function getDocs(query: Query): Promise> { */ export function setDoc( reference: DocumentReference, - data: T + data: WithFieldValue ): Promise; /** * Writes to the document referred to by the specified `DocumentReference`. If @@ -217,12 +219,12 @@ export function setDoc( */ export function setDoc( reference: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): Promise; export function setDoc( reference: DocumentReference, - data: T, + data: PartialWithFieldValue, options?: SetOptions ): Promise { reference = cast>(reference, DocumentReference); @@ -264,9 +266,9 @@ export function setDoc( * @returns A `Promise` resolved once the data has been successfully written * to the backend. */ -export function updateDoc( - reference: DocumentReference, - data: UpdateData +export function updateDoc( + reference: DocumentReference, + data: UpdateData ): Promise; /** * Updates fields in the document referred to by the specified @@ -294,9 +296,9 @@ export function updateDoc( value: unknown, ...moreFieldsAndValues: unknown[] ): Promise; -export function updateDoc( +export function updateDoc( reference: DocumentReference, - fieldOrUpdateData: string | FieldPath | UpdateData, + fieldOrUpdateData: string | FieldPath | UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] ): Promise { @@ -373,12 +375,15 @@ export function deleteDoc( */ export function addDoc( reference: CollectionReference, - data: T + data: WithFieldValue ): Promise> { reference = cast>(reference, CollectionReference); const docRef = doc(reference); - const convertedValue = applyFirestoreDataConverter(reference.converter, data); + const convertedValue = applyFirestoreDataConverter( + reference.converter, + data as PartialWithFieldValue + ); const dataReader = newUserDataReader(reference.firestore); const parsed = parseSetData( diff --git a/packages/firestore/src/lite/snapshot.ts b/packages/firestore/src/lite/snapshot.ts index 725f994df5d..100a0b2f16d 100644 --- a/packages/firestore/src/lite/snapshot.ts +++ b/packages/firestore/src/lite/snapshot.ts @@ -27,9 +27,11 @@ import { FieldPath } from './field_path'; import { DocumentData, DocumentReference, + PartialWithFieldValue, Query, queryEqual, - SetOptions + SetOptions, + WithFieldValue } from './reference'; import { fieldPathFromDotSeparatedString, @@ -55,7 +57,7 @@ import { AbstractUserDataWriter } from './user_data_writer'; * } * * const postConverter = { - * toFirestore(post: Post): firebase.firestore.DocumentData { + * toFirestore(post: WithFieldValue): firebase.firestore.DocumentData { * return {title: post.title, author: post.author}; * }, * fromFirestore(snapshot: firebase.firestore.QueryDocumentSnapshot): Post { @@ -82,16 +84,27 @@ export interface FirestoreDataConverter { * into a plain Javascript object (suitable for writing directly to the * Firestore database). Used with {@link @firebase/firestore/lite#(setDoc:1)}, {@link @firebase/firestore/lite#(WriteBatch.set:1)} * and {@link @firebase/firestore/lite#(Transaction.set:1)}. + * + * The `WithFieldValue` type extends `T` to also allow FieldValues such as + * {@link (deleteField:1)} to be used as property values. */ - toFirestore(modelObject: T): DocumentData; + toFirestore(modelObject: WithFieldValue): DocumentData; /** * Called by the Firestore SDK to convert a custom model object of type `T` * into a plain Javascript object (suitable for writing directly to the * Firestore database). Used with {@link @firebase/firestore/lite#(setDoc:1)}, {@link @firebase/firestore/lite#(WriteBatch.set:1)} * and {@link @firebase/firestore/lite#(Transaction.set:1)} with `merge:true` or `mergeFields`. + * + * The `PartialWithFieldValue` type extends `Partial` to allow + * FieldValues such as {@link (arrayUnion:1)} to be used as property values. + * It also supports nested `Partial` by allowing nested fields to be + * omitted. */ - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + toFirestore( + modelObject: PartialWithFieldValue, + options: SetOptions + ): DocumentData; /** * Called by the Firestore SDK to convert Firestore data into an object of diff --git a/packages/firestore/src/lite/transaction.ts b/packages/firestore/src/lite/transaction.ts index b9adbb67931..aadba43dc63 100644 --- a/packages/firestore/src/lite/transaction.ts +++ b/packages/firestore/src/lite/transaction.ts @@ -27,7 +27,13 @@ import { Deferred } from '../util/promise'; import { getDatastore } from './components'; import { Firestore } from './database'; import { FieldPath } from './field_path'; -import { DocumentReference, SetOptions, UpdateData } from './reference'; +import { + DocumentReference, + PartialWithFieldValue, + SetOptions, + UpdateData, + WithFieldValue +} from './reference'; import { applyFirestoreDataConverter, LiteUserDataWriter @@ -114,7 +120,7 @@ export class Transaction { * @param data - An object of the fields and values for the document. * @returns This `Transaction` instance. Used for chaining method calls. */ - set(documentRef: DocumentReference, data: T): this; + set(documentRef: DocumentReference, data: WithFieldValue): this; /** * Writes to the document referred to by the provided {@link * DocumentReference}. If the document does not exist yet, it will be created. @@ -128,12 +134,12 @@ export class Transaction { */ set( documentRef: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): this; set( documentRef: DocumentReference, - value: T, + value: PartialWithFieldValue, options?: SetOptions ): this { const ref = validateReference(documentRef, this._firestore); @@ -165,7 +171,7 @@ export class Transaction { * within the document. * @returns This `Transaction` instance. Used for chaining method calls. */ - update(documentRef: DocumentReference, data: UpdateData): this; + update(documentRef: DocumentReference, data: UpdateData): this; /** * Updates fields in the document referred to by the provided {@link * DocumentReference}. The update will fail if applied to a document that does @@ -186,9 +192,9 @@ export class Transaction { value: unknown, ...moreFieldsAndValues: unknown[] ): this; - update( - documentRef: DocumentReference, - fieldOrUpdateData: string | FieldPath | UpdateData, + update( + documentRef: DocumentReference, + fieldOrUpdateData: string | FieldPath | UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] ): this { diff --git a/packages/firestore/src/lite/types.ts b/packages/firestore/src/lite/types.ts new file mode 100644 index 00000000000..63d1b1cc752 --- /dev/null +++ b/packages/firestore/src/lite/types.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2021 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { UpdateData } from './reference'; + +/** + * These types primarily exist to support the `UpdateData`, + * `WithFieldValue`, and `PartialWithFieldValue` types and are not consumed + * directly by the end developer. + */ + +/** Primitive types. */ +export type Primitive = string | number | boolean | undefined | null; + +/** + * For each field (e.g. 'bar'), find all nested keys (e.g. {'bar.baz': T1, + * 'bar.qux': T2}). Intersect them together to make a single map containing + * all possible keys that are all marked as optional + */ +export type NestedUpdateFields> = + UnionToIntersection< + { + // Check that T[K] extends Record to only allow nesting for map values. + [K in keyof T & string]: T[K] extends Record + ? // Recurse into the map and add the prefix in front of each key + // (e.g. Prefix 'bar.' to create: 'bar.baz' and 'bar.qux'. + AddPrefixToKeys> + : // TypedUpdateData is always a map of values. + never; + }[keyof T & string] // Also include the generated prefix-string keys. + >; + +/** + * Returns a new map where every key is prefixed with the outer key appended + * to a dot. + */ +export type AddPrefixToKeys< + Prefix extends string, + T extends Record +> = + // Remap K => Prefix.K. See https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as + { [K in keyof T & string as `${Prefix}.${K}`]+?: T[K] }; + +/** + * Given a union type `U = T1 | T2 | ...`, returns an intersected type + * `(T1 & T2 & ...)`. + * + * Uses distributive conditional types and inference from conditional types. + * This works because multiple candidates for the same type variable in + * contra-variant positions causes an intersection type to be inferred. + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-inference-in-conditional-types + * https://stackoverflow.com/questions/50374908/transform-union-type-to-intersection-type + */ +export type UnionToIntersection = ( + U extends unknown ? (k: U) => void : never +) extends (k: infer I) => void + ? I + : never; diff --git a/packages/firestore/src/lite/user_data_reader.ts b/packages/firestore/src/lite/user_data_reader.ts index 26fdf6c23c0..c5fbb152872 100644 --- a/packages/firestore/src/lite/user_data_reader.ts +++ b/packages/firestore/src/lite/user_data_reader.ts @@ -63,7 +63,11 @@ import { Firestore } from './database'; import { FieldPath } from './field_path'; import { FieldValue } from './field_value'; import { GeoPoint } from './geo_point'; -import { DocumentReference } from './reference'; +import { + DocumentReference, + PartialWithFieldValue, + WithFieldValue +} from './reference'; import { Timestamp } from './timestamp'; const RESERVED_FIELD_REGEX = /^__.*__$/; @@ -73,8 +77,11 @@ const RESERVED_FIELD_REGEX = /^__.*__$/; * lite, firestore-exp and classic SDK. */ export interface UntypedFirestoreDataConverter { - toFirestore(modelObject: T): DocumentData; - toFirestore(modelObject: Partial, options: SetOptions): DocumentData; + toFirestore(modelObject: WithFieldValue): DocumentData; + toFirestore( + modelObject: PartialWithFieldValue, + options: SetOptions + ): DocumentData; fromFirestore(snapshot: unknown, options?: unknown): T; } diff --git a/packages/firestore/src/lite/write_batch.ts b/packages/firestore/src/lite/write_batch.ts index ea73fb18eb4..eaf97332a1c 100644 --- a/packages/firestore/src/lite/write_batch.ts +++ b/packages/firestore/src/lite/write_batch.ts @@ -25,7 +25,13 @@ import { cast } from '../util/input_validation'; import { getDatastore } from './components'; import { Firestore } from './database'; import { FieldPath } from './field_path'; -import { DocumentReference, SetOptions, UpdateData } from './reference'; +import { + DocumentReference, + PartialWithFieldValue, + SetOptions, + UpdateData, + WithFieldValue +} from './reference'; import { applyFirestoreDataConverter } from './reference_impl'; import { newUserDataReader, @@ -67,7 +73,10 @@ export class WriteBatch { * @param data - An object of the fields and values for the document. * @returns This `WriteBatch` instance. Used for chaining method calls. */ - set(documentRef: DocumentReference, data: T): WriteBatch; + set( + documentRef: DocumentReference, + data: WithFieldValue + ): WriteBatch; /** * Writes to the document referred to by the provided {@link * DocumentReference}. If the document does not exist yet, it will be created. @@ -81,12 +90,12 @@ export class WriteBatch { */ set( documentRef: DocumentReference, - data: Partial, + data: PartialWithFieldValue, options: SetOptions ): WriteBatch; set( documentRef: DocumentReference, - data: T, + data: WithFieldValue | PartialWithFieldValue, options?: SetOptions ): WriteBatch { this._verifyNotCommitted(); @@ -120,7 +129,7 @@ export class WriteBatch { * within the document. * @returns 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 {@link * DocumentReference}. The update will fail if applied to a document that does @@ -141,9 +150,9 @@ export class WriteBatch { value: unknown, ...moreFieldsAndValues: unknown[] ): WriteBatch; - update( - documentRef: DocumentReference, - fieldOrUpdateData: string | FieldPath | UpdateData, + update( + documentRef: DocumentReference, + fieldOrUpdateData: string | FieldPath | UpdateData, value?: unknown, ...moreFieldsAndValues: unknown[] ): WriteBatch { diff --git a/packages/firestore/test/lite/helpers.ts b/packages/firestore/test/lite/helpers.ts index bd57fb14231..964592f5621 100644 --- a/packages/firestore/test/lite/helpers.ts +++ b/packages/firestore/test/lite/helpers.ts @@ -26,7 +26,8 @@ import { DocumentData, CollectionReference, DocumentReference, - SetOptions + SetOptions, + PartialWithFieldValue } from '../../src/lite/reference'; import { setDoc } from '../../src/lite/reference_impl'; import { FirestoreSettings } from '../../src/lite/settings'; @@ -102,7 +103,11 @@ export function withTestCollection( // Used for testing the FirestoreDataConverter. export class Post { - constructor(readonly title: string, readonly author: string) {} + constructor( + readonly title: string, + readonly author: string, + readonly id = 1 + ) {} byline(): string { return this.title + ', by ' + this.author; } @@ -119,7 +124,10 @@ export const postConverter = { }; export const postConverterMerge = { - toFirestore(post: Partial, options?: SetOptions): DocumentData { + toFirestore( + post: PartialWithFieldValue, + options?: SetOptions + ): DocumentData { if ( options && ((options as { merge: true }).merge || diff --git a/packages/firestore/test/lite/integration.test.ts b/packages/firestore/test/lite/integration.test.ts index 18b3e8b287b..a0a803d6b13 100644 --- a/packages/firestore/test/lite/integration.test.ts +++ b/packages/firestore/test/lite/integration.test.ts @@ -56,8 +56,10 @@ import { queryEqual, collectionGroup, SetOptions, - UpdateData, - DocumentData + DocumentData, + WithFieldValue, + PartialWithFieldValue, + UpdateData } from '../../src/lite/reference'; import { addDoc, @@ -67,7 +69,11 @@ import { setDoc, updateDoc } from '../../src/lite/reference_impl'; -import { snapshotEqual, QuerySnapshot } from '../../src/lite/snapshot'; +import { + snapshotEqual, + QuerySnapshot, + QueryDocumentSnapshot +} from '../../src/lite/snapshot'; import { Timestamp } from '../../src/lite/timestamp'; import { runTransaction } from '../../src/lite/transaction'; import { writeBatch } from '../../src/lite/write_batch'; @@ -337,15 +343,18 @@ describe('getDoc()', () => { * DocumentReference-based mutation API. */ interface MutationTester { - set(documentRef: DocumentReference, data: T): Promise; set( documentRef: DocumentReference, - data: Partial, + data: WithFieldValue + ): Promise; + set( + documentRef: DocumentReference, + data: PartialWithFieldValue, options: SetOptions ): Promise; - update( - documentRef: DocumentReference, - data: UpdateData + update( + documentRef: DocumentReference, + data: UpdateData ): Promise; update( documentRef: DocumentReference, @@ -372,7 +381,7 @@ describe('WriteBatch', () => { set( ref: DocumentReference, - data: T | Partial, + data: PartialWithFieldValue, options?: SetOptions ): Promise { const batch = writeBatch(ref.firestore); @@ -382,9 +391,9 @@ describe('WriteBatch', () => { return batch.commit(); } - update( - ref: DocumentReference, - dataOrField: UpdateData | string | FieldPath, + update( + ref: DocumentReference, + dataOrField: UpdateData | string | FieldPath, value?: unknown, ...moreFieldsAndValues: unknown[] ): Promise { @@ -435,21 +444,21 @@ describe('Transaction', () => { set( ref: DocumentReference, - data: T | Partial, + data: PartialWithFieldValue, options?: SetOptions ): Promise { return runTransaction(ref.firestore, async transaction => { if (options) { transaction.set(ref, data, options); } else { - transaction.set(ref, data); + transaction.set(ref, data as WithFieldValue); } }); } - update( - ref: DocumentReference, - dataOrField: UpdateData | string | FieldPath, + update( + ref: DocumentReference, + dataOrField: UpdateData | string | FieldPath, value?: unknown, ...moreFieldsAndValues: unknown[] ): Promise { @@ -462,7 +471,7 @@ describe('Transaction', () => { ...moreFieldsAndValues ); } else { - transaction.update(ref, dataOrField as UpdateData); + transaction.update(ref, dataOrField as UpdateData); } }); } @@ -580,7 +589,11 @@ function genericMutationTests( const coll = collection(db, 'posts'); const ref = doc(coll, 'post').withConverter(postConverterMerge); await setDoc(ref, new Post('walnut', 'author')); - await setDoc(ref, { title: 'olive' }, { merge: true }); + await setDoc( + ref, + { title: 'olive', id: increment(2) }, + { merge: true } + ); const postDoc = await getDoc(ref); expect(postDoc.get('title')).to.equal('olive'); expect(postDoc.get('author')).to.equal('author'); @@ -1219,4 +1232,492 @@ describe('withConverter() support', () => { ); }); }); + + describe('types test', () => { + class TestObject { + constructor( + readonly outerString: string, + readonly outerArr: string[], + readonly nested: { + innerNested: { + innerNestedNum: number; + }; + innerArr: number[]; + timestamp: Timestamp; + } + ) {} + } + + const testConverter = { + toFirestore(testObj: WithFieldValue) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + } + }; + + const initialData = { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2 + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }; + + describe('NestedPartial', () => { + const testConverterMerge = { + toFirestore( + testObj: PartialWithFieldValue, + options?: SetOptions + ) { + return { ...testObj }; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): TestObject { + const data = snapshot.data(); + return new TestObject(data.outerString, data.outerArr, data.nested); + } + }; + + it('supports FieldValues', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + + // Allow Field Values in nested partials. + await setDoc( + ref, + { + outerString: deleteField(), + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }, + { merge: true } + ); + + // Allow setting FieldValue on entire object field. + await setDoc( + ref, + { + nested: deleteField() + }, + { merge: true } + ); + }); + }); + + it('validates types in outer and inner fields', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + + // Check top-level fields. + await setDoc( + ref, + { + // @ts-expect-error + outerString: 3, + // @ts-expect-error + outerArr: null + }, + { merge: true } + ); + + // Check nested fields. + await setDoc( + ref, + { + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string' + }, + // @ts-expect-error + innerArr: null + } + }, + { merge: true } + ); + await setDoc( + ref, + { + // @ts-expect-error + nested: 3 + }, + { merge: true } + ); + }); + }); + + it('checks for nonexistent properties', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverterMerge); + // Top-level property. + await setDoc( + ref, + { + // @ts-expect-error + nonexistent: 'foo' + }, + { merge: true } + ); + + // Nested property + await setDoc( + ref, + { + nested: { + // @ts-expect-error + nonexistent: 'foo' + } + }, + { merge: true } + ); + }); + }); + }); + + describe('WithFieldValue', () => { + it('supports FieldValues', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + await setDoc(ref, { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + + it('requires all fields to be present', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Allow Field Values and nested partials. + // @ts-expect-error + await setDoc(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + + it('validates inner and outer fields', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + await setDoc(ref, { + outerString: 'foo', + // @ts-expect-error + outerArr: 2, + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string' + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + + it('checks for nonexistent properties', async () => { + return withTestDoc(async doc => { + const ref = doc.withConverter(testConverter); + + // Top-level nonexistent fields should error + await setDoc(ref, { + outerString: 'foo', + // @ts-expect-error + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2 + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + + // Nested nonexistent fields should error + await setDoc(ref, { + outerString: 'foo', + outerNum: 3, + outerArr: [], + nested: { + innerNested: { + // @ts-expect-error + nonexistent: 'string', + innerNestedNum: 2 + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + }); + + describe('UpdateData', () => { + it('supports FieldValues', () => { + return withTestDocAndInitialData(initialData, async docRef => { + await updateDoc(docRef.withConverter(testConverter), { + outerString: deleteField(), + nested: { + innerNested: { + innerNestedNum: increment(2) + }, + innerArr: arrayUnion(3) + } + }); + }); + }); + + it('validates inner and outer fields', async () => { + return withTestDocAndInitialData(initialData, async docRef => { + await updateDoc(docRef.withConverter(testConverter), { + // @ts-expect-error + outerString: 3, + nested: { + innerNested: { + // @ts-expect-error + innerNestedNum: 'string' + }, + // @ts-expect-error + innerArr: 2 + } + }); + }); + }); + + it('supports string-separated fields', () => { + return withTestDocAndInitialData(initialData, async docRef => { + const testDocRef: DocumentReference = + docRef.withConverter(testConverter); + await updateDoc(testDocRef, { + // @ts-expect-error + outerString: 3, + // @ts-expect-error + 'nested.innerNested.innerNestedNum': 'string', + // @ts-expect-error + 'nested.innerArr': 3, + 'nested.timestamp': serverTimestamp() + }); + + // String comprehension works in nested fields. + await updateDoc(testDocRef, { + nested: { + innerNested: { + // @ts-expect-error + 'innerNestedNum': 'string' + }, + // @ts-expect-error + 'innerArr': 3 + } + }); + }); + }); + + it('checks for nonexistent fields', () => { + return withTestDocAndInitialData(initialData, async docRef => { + const testDocRef: DocumentReference = + docRef.withConverter(testConverter); + + // Top-level fields. + await updateDoc(testDocRef, { + // @ts-expect-error + nonexistent: 'foo' + }); + + // Nested Fields. + await updateDoc(testDocRef, { + nested: { + // @ts-expect-error + nonexistent: 'foo' + } + }); + + // String fields. + await updateDoc(testDocRef, { + // @ts-expect-error + 'nonexistent': 'foo' + }); + await updateDoc(testDocRef, { + // @ts-expect-error + 'nested.nonexistent': 'foo' + }); + }); + }); + }); + + describe('methods', () => { + it('addDoc()', () => { + return withTestDb(async db => { + const ref = collection(db, 'testobj').withConverter(testConverter); + + // Requires all fields to be present + // @ts-expect-error + await addDoc(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2 + }, + innerArr: [], + timestamp: serverTimestamp() + } + }); + }); + }); + + it('WriteBatch.set()', () => { + return withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter( + testConverter + ); + const batch = writeBatch(db); + + // Requires full object if {merge: true} is not set. + // @ts-expect-error + batch.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + + batch.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }, + { merge: true } + ); + }); + }); + + it('WriteBatch.update()', () => { + return withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter( + testConverter + ); + const batch = writeBatch(db); + + batch.update(ref, { + outerArr: [], + nested: { + 'innerNested.innerNestedNum': increment(1), + 'innerArr': arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + + it('Transaction.set()', () => { + return withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter( + testConverter + ); + + return runTransaction(db, async tx => { + // Requires full object if {merge: true} is not set. + // @ts-expect-error + tx.set(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + + tx.set( + ref, + { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }, + { merge: true } + ); + }); + }); + }); + + it('Transaction.update()', () => { + return withTestDb(async db => { + const ref = doc(collection(db, 'testobj')).withConverter( + testConverter + ); + await setDoc(ref, { + outerString: 'foo', + outerArr: [], + nested: { + innerNested: { + innerNestedNum: 2 + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + + return runTransaction(db, async tx => { + tx.update(ref, { + outerArr: [], + nested: { + innerNested: { + innerNestedNum: increment(1) + }, + innerArr: arrayUnion(2), + timestamp: serverTimestamp() + } + }); + }); + }); + }); + }); + }); });