Skip to content

Commit 4a70e17

Browse files
Add ignoreUndefinedProperties (#3077)
1 parent 7672638 commit 4a70e17

File tree

11 files changed

+172
-27
lines changed

11 files changed

+172
-27
lines changed

packages/firebase/index.d.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -6920,15 +6920,15 @@ declare namespace firebase.database.ServerValue {
69206920
* ```
69216921
*/
69226922
var TIMESTAMP: Object;
6923-
6923+
69246924
/**
6925-
* Returns a placeholder value that can be used to atomically increment the
6925+
* Returns a placeholder value that can be used to atomically increment the
69266926
* current database value by the provided delta.
69276927
*
69286928
* @param delta the amount to modify the current value atomically.
69296929
* @return a placeholder value for modifying data atomically server-side.
69306930
*/
6931-
function increment(delta: number) : Object;
6931+
function increment(delta: number): Object;
69326932
}
69336933

69346934
/**
@@ -7743,6 +7743,14 @@ declare namespace firebase.firestore {
77437743
* @webonly
77447744
*/
77457745
experimentalForceLongPolling?: boolean;
7746+
7747+
/**
7748+
* Whether to skip nested properties that are set to `undefined` during
7749+
* object serialization. If set to `true`, these properties are skipped
7750+
* and not written to Firestore. If set `false` or omitted, the SDK throws
7751+
* an exception when it encounters properties of type `undefined`.
7752+
*/
7753+
ignoreUndefinedProperties?: boolean;
77467754
}
77477755

77487756
/**

packages/firestore-types/index.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface Settings {
2929
timestampsInSnapshots?: boolean;
3030
cacheSizeBytes?: number;
3131
experimentalForceLongPolling?: boolean;
32+
ignoreUndefinedProperties?: boolean;
3233
}
3334

3435
export interface PersistenceSettings {

packages/firestore/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
# Unreleased
2+
- [feature] Added support for calling `FirebaseFiresore.settings` with
3+
`{ ignoreUndefinedProperties: true }`. When set, Firestore ignores
4+
undefined properties inside objects rather than rejecting the API call.
5+
6+
# Released
27
- [fixed] Fixed a regression introduced in v7.14.2 that incorrectly applied
38
a `FieldValue.increment` in combination with `set({...}, {merge: true})`.
49
- [fixed] Firestore now rejects `onSnapshot()` listeners if they cannot be

packages/firestore/src/api/database.ts

+33-7
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ const DEFAULT_HOST = 'firestore.googleapis.com';
9898
const DEFAULT_SSL = true;
9999
const DEFAULT_TIMESTAMPS_IN_SNAPSHOTS = true;
100100
const DEFAULT_FORCE_LONG_POLLING = false;
101+
const DEFAULT_IGNORE_UNDEFINED_PROPERTIES = false;
101102

102103
/**
103104
* Constant used to indicate the LRU garbage collection should be disabled.
@@ -142,6 +143,8 @@ class FirestoreSettings {
142143

143144
readonly forceLongPolling: boolean;
144145

146+
readonly ignoreUndefinedProperties: boolean;
147+
145148
// Can be a google-auth-library or gapi client.
146149
// eslint-disable-next-line @typescript-eslint/no-explicit-any
147150
credentials?: any;
@@ -169,7 +172,8 @@ class FirestoreSettings {
169172
'credentials',
170173
'timestampsInSnapshots',
171174
'cacheSizeBytes',
172-
'experimentalForceLongPolling'
175+
'experimentalForceLongPolling',
176+
'ignoreUndefinedProperties'
173177
]);
174178

175179
validateNamedOptionalType(
@@ -187,6 +191,13 @@ class FirestoreSettings {
187191
settings.timestampsInSnapshots
188192
);
189193

194+
validateNamedOptionalType(
195+
'settings',
196+
'boolean',
197+
'ignoreUndefinedProperties',
198+
settings.ignoreUndefinedProperties
199+
);
200+
190201
// Nobody should set timestampsInSnapshots anymore, but the error depends on
191202
// whether they set it to true or false...
192203
if (settings.timestampsInSnapshots === true) {
@@ -202,6 +213,8 @@ class FirestoreSettings {
202213
}
203214
this.timestampsInSnapshots =
204215
settings.timestampsInSnapshots ?? DEFAULT_TIMESTAMPS_IN_SNAPSHOTS;
216+
this.ignoreUndefinedProperties =
217+
settings.ignoreUndefinedProperties ?? DEFAULT_IGNORE_UNDEFINED_PROPERTIES;
205218

206219
validateNamedOptionalType(
207220
'settings',
@@ -232,9 +245,7 @@ class FirestoreSettings {
232245
settings.experimentalForceLongPolling
233246
);
234247
this.forceLongPolling =
235-
settings.experimentalForceLongPolling === undefined
236-
? DEFAULT_FORCE_LONG_POLLING
237-
: settings.experimentalForceLongPolling;
248+
settings.experimentalForceLongPolling ?? DEFAULT_FORCE_LONG_POLLING;
238249
}
239250

240251
isEqual(other: FirestoreSettings): boolean {
@@ -244,7 +255,8 @@ class FirestoreSettings {
244255
this.timestampsInSnapshots === other.timestampsInSnapshots &&
245256
this.credentials === other.credentials &&
246257
this.cacheSizeBytes === other.cacheSizeBytes &&
247-
this.forceLongPolling === other.forceLongPolling
258+
this.forceLongPolling === other.forceLongPolling &&
259+
this.ignoreUndefinedProperties === other.ignoreUndefinedProperties
248260
);
249261
}
250262
}
@@ -275,7 +287,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService {
275287
// TODO(mikelehen): Use modularized initialization instead.
276288
readonly _queue = new AsyncQueue();
277289

278-
readonly _dataReader: UserDataReader;
290+
_userDataReader: UserDataReader | undefined;
279291

280292
// Note: We are using `MemoryComponentProvider` as a default
281293
// ComponentProvider to ensure backwards compatibility with the format
@@ -310,7 +322,21 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService {
310322

311323
this._componentProvider = componentProvider;
312324
this._settings = new FirestoreSettings({});
313-
this._dataReader = new UserDataReader(this._databaseId);
325+
}
326+
327+
get _dataReader(): UserDataReader {
328+
debugAssert(
329+
!!this._firestoreClient,
330+
'Cannot obtain UserDataReader before instance is intitialized'
331+
);
332+
if (!this._userDataReader) {
333+
// Lazy initialize UserDataReader once the settings are frozen
334+
this._userDataReader = new UserDataReader(
335+
this._databaseId,
336+
this._settings.ignoreUndefinedProperties
337+
);
338+
}
339+
return this._userDataReader;
314340
}
315341

316342
settings(settingsLiteral: firestore.Settings): void {

packages/firestore/src/api/field_value.ts

+6-3
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,8 @@ export class ArrayUnionFieldValueImpl extends FieldValueImpl {
109109
arrayElement: true
110110
},
111111
context.databaseId,
112-
context.serializer
112+
context.serializer,
113+
context.ignoreUndefinedProperties
113114
);
114115
const parsedElements = this._elements.map(
115116
element => parseData(element, parseContext)!
@@ -140,7 +141,8 @@ export class ArrayRemoveFieldValueImpl extends FieldValueImpl {
140141
arrayElement: true
141142
},
142143
context.databaseId,
143-
context.serializer
144+
context.serializer,
145+
context.ignoreUndefinedProperties
144146
);
145147
const parsedElements = this._elements.map(
146148
element => parseData(element, parseContext)!
@@ -167,7 +169,8 @@ export class NumericIncrementFieldValueImpl extends FieldValueImpl {
167169
methodName: this._methodName
168170
},
169171
context.databaseId,
170-
context.serializer
172+
context.serializer,
173+
context.ignoreUndefinedProperties
171174
);
172175
const operand = parseData(this._operand, parseContext)!;
173176
const numericIncrement = new NumericIncrementTransformOperation(

packages/firestore/src/api/user_data_reader.ts

+13-2
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,8 @@ export class ParseContext {
158158
* @param settings The settings for the parser.
159159
* @param databaseId The database ID of the Firestore instance.
160160
* @param serializer The serializer to use to generate the Value proto.
161+
* @param ignoreUndefinedProperties Whether to ignore undefined properties
162+
* rather than throw.
161163
* @param fieldTransforms A mutable list of field transforms encountered while
162164
* parsing the data.
163165
* @param fieldMask A mutable list of field paths encountered while parsing
@@ -172,6 +174,7 @@ export class ParseContext {
172174
readonly settings: ContextSettings,
173175
readonly databaseId: DatabaseId,
174176
readonly serializer: JsonProtoSerializer,
177+
readonly ignoreUndefinedProperties: boolean,
175178
fieldTransforms?: FieldTransform[],
176179
fieldMask?: FieldPath[]
177180
) {
@@ -198,6 +201,7 @@ export class ParseContext {
198201
{ ...this.settings, ...configuration },
199202
this.databaseId,
200203
this.serializer,
204+
this.ignoreUndefinedProperties,
201205
this.fieldTransforms,
202206
this.fieldMask
203207
);
@@ -276,6 +280,7 @@ export class UserDataReader {
276280

277281
constructor(
278282
private readonly databaseId: DatabaseId,
283+
private readonly ignoreUndefinedProperties: boolean,
279284
serializer?: JsonProtoSerializer
280285
) {
281286
this.serializer =
@@ -458,7 +463,8 @@ export class UserDataReader {
458463
arrayElement: false
459464
},
460465
this.databaseId,
461-
this.serializer
466+
this.serializer,
467+
this.ignoreUndefinedProperties
462468
);
463469
}
464470

@@ -613,7 +619,10 @@ function parseSentinelFieldValue(
613619
*
614620
* @return The parsed value
615621
*/
616-
function parseScalarValue(value: unknown, context: ParseContext): api.Value {
622+
function parseScalarValue(
623+
value: unknown,
624+
context: ParseContext
625+
): api.Value | null {
617626
if (value === null) {
618627
return { nullValue: 'NULL_VALUE' };
619628
} else if (typeof value === 'number') {
@@ -659,6 +668,8 @@ function parseScalarValue(value: unknown, context: ParseContext): api.Value {
659668
value.firestore._databaseId
660669
)
661670
};
671+
} else if (value === undefined && context.ignoreUndefinedProperties) {
672+
return null;
662673
} else {
663674
throw context.createError(
664675
`Unsupported field value: ${valueDescription(value)}`

packages/firestore/src/util/input_validation.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -461,7 +461,7 @@ export function validatePositiveNumber(
461461
if (n <= 0) {
462462
throw new FirestoreError(
463463
Code.INVALID_ARGUMENT,
464-
`Function "${functionName}()" requires its ${ordinal(
464+
`Function ${functionName}() requires its ${ordinal(
465465
position
466466
)} argument to be a positive number, but it was: ${n}.`
467467
);

packages/firestore/test/integration/api/fields.test.ts

+59-1
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ import {
2323
toDataArray,
2424
withTestCollection,
2525
withTestCollectionSettings,
26-
withTestDoc
26+
withTestDoc,
27+
withTestDocAndSettings
2728
} from '../util/helpers';
2829

2930
const FieldPath = firebase.firestore!.FieldPath;
@@ -433,3 +434,60 @@ apiDescribe('Timestamp Fields in snapshots', (persistence: boolean) => {
433434
});
434435
});
435436
});
437+
438+
apiDescribe('`undefined` properties', (persistence: boolean) => {
439+
const settings = { ...DEFAULT_SETTINGS };
440+
settings.ignoreUndefinedProperties = true;
441+
442+
it('are ignored in set()', () => {
443+
return withTestDocAndSettings(persistence, settings, async doc => {
444+
await doc.set({ foo: 'foo', 'bar': undefined });
445+
const docSnap = await doc.get();
446+
expect(docSnap.data()).to.deep.equal({ foo: 'foo' });
447+
});
448+
});
449+
450+
it('are ignored in update()', () => {
451+
return withTestDocAndSettings(persistence, settings, async doc => {
452+
await doc.set({});
453+
await doc.update({ a: { foo: 'foo', 'bar': undefined } });
454+
await doc.update('b', { foo: 'foo', 'bar': undefined });
455+
const docSnap = await doc.get();
456+
expect(docSnap.data()).to.deep.equal({
457+
a: { foo: 'foo' },
458+
b: { foo: 'foo' }
459+
});
460+
});
461+
});
462+
463+
it('are ignored in Query.where()', () => {
464+
return withTestCollectionSettings(
465+
persistence,
466+
settings,
467+
{ 'doc1': { nested: { foo: 'foo' } } },
468+
async coll => {
469+
const query = coll.where('nested', '==', {
470+
foo: 'foo',
471+
'bar': undefined
472+
});
473+
const querySnap = await query.get();
474+
expect(querySnap.size).to.equal(1);
475+
}
476+
);
477+
});
478+
479+
it('are ignored in Query.startAt()', () => {
480+
return withTestCollectionSettings(
481+
persistence,
482+
settings,
483+
{ 'doc1': { nested: { foo: 'foo' } } },
484+
async coll => {
485+
const query = coll
486+
.orderBy('nested')
487+
.startAt({ foo: 'foo', 'bar': undefined });
488+
const querySnap = await query.get();
489+
expect(querySnap.size).to.equal(1);
490+
}
491+
);
492+
});
493+
});

0 commit comments

Comments
 (0)