Skip to content

Commit e87b617

Browse files
author
Brian Chen
committed
add support for TypedUpdateData + sanity tests
1 parent f5a00fc commit e87b617

File tree

4 files changed

+193
-15
lines changed

4 files changed

+193
-15
lines changed

packages/firestore/src/exp/reference_impl.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
NestedPartialWithFieldValue,
4646
Query,
4747
SetOptions,
48+
TypedUpdateData,
4849
UpdateData,
4950
WithFieldValue
5051
} from '../lite/reference';
@@ -302,9 +303,9 @@ export function setDoc<T>(
302303
* @returns A Promise resolved once the data has been successfully written
303304
* to the backend (note that it won't resolve while you're offline).
304305
*/
305-
export function updateDoc(
306-
reference: DocumentReference<unknown>,
307-
data: UpdateData
306+
export function updateDoc<T>(
307+
reference: DocumentReference<T>,
308+
data: TypedUpdateData<T>
308309
): Promise<void>;
309310
/**
310311
* Updates fields in the document referred to by the specified
@@ -327,9 +328,9 @@ export function updateDoc(
327328
value: unknown,
328329
...moreFieldsAndValues: unknown[]
329330
): Promise<void>;
330-
export function updateDoc(
331+
export function updateDoc<T>(
331332
reference: DocumentReference<unknown>,
332-
fieldOrUpdateData: string | FieldPath | UpdateData,
333+
fieldOrUpdateData: string | FieldPath | TypedUpdateData<T>,
333334
value?: unknown,
334335
...moreFieldsAndValues: unknown[]
335336
): Promise<void> {

packages/firestore/src/lite/reference.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,39 @@ export interface UpdateData {
7474
// eslint-disable-next-line @typescript-eslint/no-explicit-any
7575
[fieldPath: string]: any;
7676
}
77+
// Represents an update object to Firestore document data, which can contain either fields like {a: 2}
78+
// or dot-separated paths such as {"a.b" : 2} (which updates the nested property "b" in map field "a").
79+
export type TypedUpdateData<T> = T extends Builtin
80+
? T
81+
: T extends Map<infer K, infer V>
82+
? Map<TypedUpdateData<K>, TypedUpdateData<V>>
83+
: T extends {}
84+
? { [K in keyof T]?: TypedUpdateData<T[K]> | FieldValue } &
85+
NestedUpdateFields<T>
86+
: Partial<T>;
87+
88+
// For each field (e.g. "bar"), calculate its nested keys (e.g. {"bar.baz": T1, "bar.quax": T2}), and then
89+
// intersect them together to make one giant map containing all possible keys (all marked as optional).
90+
type NestedUpdateFields<T extends Record<string, any>> = UnionToIntersection<
91+
{
92+
[K in keyof T & string]: T[K] extends Record<string, any> // Only allow nesting for map values
93+
? AddPrefixToKeys<K, TypedUpdateData<T[K]>> // Recurse into map and add "bar." in front of every key
94+
: never;
95+
}[keyof T & string]
96+
>;
97+
98+
// Return a new map where every key is prepended with Prefix + dot.
99+
type AddPrefixToKeys<Prefix extends string, T extends Record<string, any>> =
100+
// Remap K => Prefix.K. See https://www.typescriptlang.org/docs/handbook/2/mapped-types.html#key-remapping-via-as
101+
{ [K in keyof T & string as `${Prefix}.${K}`]+?: T[K] };
102+
103+
// This takes union type U = T1 | T2 | ... and returns a intersected type (T1 & T2 & ...)
104+
type UnionToIntersection<U> =
105+
// Works because "multiple candidates for the same type variable in contra-variant positions causes an intersection type to be inferred"
106+
// https://www.typescriptlang.org/docs/handbook/advanced-types.html#type-inference-in-conditional-types
107+
(U extends any ? (k: U) => void : never) extends (k: infer I) => void
108+
? I
109+
: never;
77110

78111
/**
79112
* An options object that configures the behavior of {@link @firebase/firestore/lite#(setDoc:1)}, {@link

packages/firestore/src/lite/reference_impl.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
NestedPartialWithFieldValue,
4545
Query,
4646
SetOptions,
47+
TypedUpdateData,
4748
UpdateData,
4849
WithFieldValue
4950
} from './reference';
@@ -266,9 +267,9 @@ export function setDoc<T>(
266267
* @returns A Promise resolved once the data has been successfully written
267268
* to the backend.
268269
*/
269-
export function updateDoc(
270-
reference: DocumentReference<unknown>,
271-
data: UpdateData
270+
export function updateDoc<T>(
271+
reference: DocumentReference<T>,
272+
data: TypedUpdateData<T>
272273
): Promise<void>;
273274
/**
274275
* Updates fields in the document referred to by the specified
@@ -296,9 +297,9 @@ export function updateDoc(
296297
value: unknown,
297298
...moreFieldsAndValues: unknown[]
298299
): Promise<void>;
299-
export function updateDoc(
300+
export function updateDoc<T>(
300301
reference: DocumentReference<unknown>,
301-
fieldOrUpdateData: string | FieldPath | UpdateData,
302+
fieldOrUpdateData: string | FieldPath | TypedUpdateData<T>,
302303
value?: unknown,
303304
...moreFieldsAndValues: unknown[]
304305
): Promise<void> {

packages/firestore/test/lite/integration.test.ts

Lines changed: 148 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,8 @@ import {
5959
UpdateData,
6060
DocumentData,
6161
WithFieldValue,
62-
NestedPartialWithFieldValue
62+
NestedPartialWithFieldValue,
63+
TypedUpdateData
6364
} from '../../src/lite/reference';
6465
import {
6566
addDoc,
@@ -69,7 +70,11 @@ import {
6970
setDoc,
7071
updateDoc
7172
} from '../../src/lite/reference_impl';
72-
import { snapshotEqual, QuerySnapshot } from '../../src/lite/snapshot';
73+
import {
74+
snapshotEqual,
75+
QuerySnapshot,
76+
QueryDocumentSnapshot
77+
} from '../../src/lite/snapshot';
7378
import { Timestamp } from '../../src/lite/timestamp';
7479
import { runTransaction } from '../../src/lite/transaction';
7580
import { writeBatch } from '../../src/lite/write_batch';
@@ -348,9 +353,9 @@ interface MutationTester {
348353
data: NestedPartialWithFieldValue<T>,
349354
options: SetOptions
350355
): Promise<void>;
351-
update(
352-
documentRef: DocumentReference<unknown>,
353-
data: UpdateData
356+
update<T>(
357+
documentRef: DocumentReference<T>,
358+
data: TypedUpdateData<T>
354359
): Promise<void>;
355360
update(
356361
documentRef: DocumentReference<unknown>,
@@ -596,6 +601,144 @@ function genericMutationTests(
596601
});
597602
});
598603

604+
it('temporary sanity check tests', async () => {
605+
class TestObject {
606+
constructor(
607+
readonly outerString: string,
608+
readonly outerNum: number,
609+
readonly outerArr: string[],
610+
readonly nested: {
611+
innerNested: {
612+
innerNestedNum: number;
613+
innerNestedString: string;
614+
};
615+
innerArr: number[];
616+
timestamp: Timestamp;
617+
}
618+
) {}
619+
}
620+
621+
const testConverterMerge = {
622+
toFirestore(testObj: WithFieldValue<TestObject>, options?: SetOptions) {
623+
return { ...testObj };
624+
},
625+
fromFirestore(snapshot: QueryDocumentSnapshot): TestObject {
626+
const data = snapshot.data();
627+
return new TestObject(
628+
data.outerString,
629+
data.outerNum,
630+
data.outerArr,
631+
data.nested
632+
);
633+
}
634+
};
635+
636+
return withTestDb(async db => {
637+
const coll = collection(db, 'posts');
638+
const ref = doc(coll, 'testobj').withConverter(testConverterMerge);
639+
640+
// Allow Field Values and nested partials.
641+
await setDoc(
642+
ref,
643+
{
644+
outerString: deleteField(),
645+
nested: {
646+
innerNested: {
647+
innerNestedNum: increment(1)
648+
},
649+
innerArr: arrayUnion(2),
650+
timestamp: serverTimestamp()
651+
}
652+
},
653+
{ merge: true }
654+
);
655+
656+
// Checks for non-existent properties
657+
await setDoc(
658+
ref,
659+
{
660+
// @ts-expect-error
661+
nonexistent: 'foo'
662+
},
663+
{ merge: true }
664+
);
665+
await setDoc(
666+
ref,
667+
{
668+
nested: {
669+
// @ts-expect-error
670+
nonexistent: 'foo'
671+
}
672+
},
673+
{ merge: true }
674+
);
675+
676+
// Nested Partials are checked
677+
await setDoc(
678+
ref,
679+
{
680+
nested: {
681+
innerNested: {
682+
// @ts-expect-error
683+
innerNestedNum: 'string'
684+
},
685+
// @ts-expect-error
686+
innerArr: 2
687+
}
688+
},
689+
{ merge: true }
690+
);
691+
await setDoc(
692+
ref,
693+
{
694+
// @ts-expect-error
695+
nested: 3
696+
},
697+
{ merge: true }
698+
);
699+
700+
// Can use update to verify fields
701+
await updateDoc(ref, {
702+
// @ts-expect-error
703+
outerString: 3,
704+
// @ts-expect-error
705+
outerNum: [],
706+
outerArr: arrayUnion('foo'),
707+
nested: {
708+
innerNested: {
709+
// @ts-expect-error
710+
innerNestedNum: 'string'
711+
},
712+
// @ts-expect-error
713+
innerArr: 2,
714+
timestamp: serverTimestamp()
715+
}
716+
});
717+
718+
// Cannot update nonexistent fields
719+
await updateDoc(ref, {
720+
// @ts-expect-error
721+
nonexistent: 'foo'
722+
});
723+
await updateDoc(ref, {
724+
nested: {
725+
// @ts-expect-error
726+
nonexistent: 'foo'
727+
}
728+
});
729+
730+
// Can use update to check string separated fields
731+
await updateDoc(ref, {
732+
'nested.innerNested.innerNestedNum': 4,
733+
// @ts-expect-error
734+
'nested.innerNested.innerNestedString': 4,
735+
// @ts-expect-error
736+
'nested.innerArr': 3,
737+
'nested.timestamp': serverTimestamp()
738+
});
739+
});
740+
});
741+
599742
it('supports partials with mergeFields', async () => {
600743
return withTestDb(async db => {
601744
const coll = collection(db, 'posts');

0 commit comments

Comments
 (0)