Skip to content

Commit 383f772

Browse files
wilhuffjshcrowthe
authored andcommitted
Fix validation of nested arrays to allow indirect nesting (#228)
* Fix validation of nested arrays to allow indirect nesting With this change indirectly nested arrays are allowed. We still disallow directly nested arrays. Fixes internal bug b/66253451. Port of firebase/firebase-ios-sdk#377 * Add IntelliJ .iml files to .gitignore * Use comments to name literal arguments
1 parent 4b55572 commit 383f772

File tree

3 files changed

+108
-42
lines changed

3 files changed

+108
-42
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ dist
1212

1313
# Editor Configs
1414
.idea
15-
.vscode
15+
.vscode
16+
*.iml

packages/firestore/src/api/user_data_converter.ts

Lines changed: 63 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -128,24 +128,29 @@ class ParseContext {
128128
* Initializes a ParseContext with the given source and path.
129129
*
130130
* @param dataSource Indicates what kind of API method this data came from.
131+
* @param methodName The name of the method the user called to create this
132+
* ParseContext.
131133
* @param path A path within the object being parsed. This could be an empty
132-
* path (in which case the context represents the root of the data being
133-
* parsed), or a nonempty path (indicating the context represents a nested
134-
* location within the data).
134+
* path (in which case the context represents the root of the data being
135+
* parsed), or a nonempty path (indicating the context represents a nested
136+
* location within the data).
137+
* @param arrayElement Whether or not this context corresponds to an element
138+
* of an array.
139+
* @param fieldTransforms A mutable list of field transforms encountered while
140+
* parsing the data.
141+
* @param fieldMask A mutable list of field paths encountered while parsing
142+
* the data.
135143
*
136144
* TODO(b/34871131): We don't support array paths right now, so path can be
137145
* null to indicate the context represents any location within an array (in
138146
* which case certain features will not work and errors will be somewhat
139147
* compromised).
140-
* @param fieldTransforms A mutable list of field transforms encountered while
141-
* parsing the data.
142-
* @param fieldMask A mutable list of field paths encountered while parsing
143-
* the data.
144148
*/
145149
constructor(
146150
readonly dataSource: UserDataSource,
147151
readonly methodName: string,
148152
readonly path: FieldPath | null,
153+
readonly arrayElement?: boolean,
149154
fieldTransforms?: FieldTransform[],
150155
fieldMask?: FieldPath[]
151156
) {
@@ -154,36 +159,52 @@ class ParseContext {
154159
if (fieldTransforms === undefined) {
155160
this.validatePath();
156161
}
162+
this.arrayElement = arrayElement !== undefined ? arrayElement : false;
157163
this.fieldTransforms = fieldTransforms || [];
158164
this.fieldMask = fieldMask || [];
159165
}
160166

161-
childContext(field: string | FieldPath | number): ParseContext {
162-
let childPath: FieldPath | null;
163-
if (typeof field === 'number') {
164-
// TODO(b/34871131): We don't support array paths right now; so make path
165-
// null.
166-
childPath = null;
167-
} else {
168-
childPath = this.path == null ? null : this.path.child(field);
169-
}
167+
childContextForField(field: string): ParseContext {
168+
const childPath = this.path == null ? null : this.path.child(field);
170169
const context = new ParseContext(
171170
this.dataSource,
172171
this.methodName,
173172
childPath,
173+
/*arrayElement=*/ false,
174174
this.fieldTransforms,
175175
this.fieldMask
176176
);
177-
if (typeof field === 'string') {
178-
// We only need to validate the new segment.
179-
context.validatePathSegment(field);
180-
} else if (typeof field === 'object' && field instanceof FieldPath) {
181-
// Validate the whole path.
182-
context.validatePath();
183-
}
177+
context.validatePathSegment(field);
184178
return context;
185179
}
186180

181+
childContextForFieldPath(field: FieldPath): ParseContext {
182+
const childPath = this.path == null ? null : this.path.child(field);
183+
const context = new ParseContext(
184+
this.dataSource,
185+
this.methodName,
186+
childPath,
187+
/*arrayElement=*/ false,
188+
this.fieldTransforms,
189+
this.fieldMask
190+
);
191+
context.validatePath();
192+
return context;
193+
}
194+
195+
childContextForArray(index: number): ParseContext {
196+
// TODO(b/34871131): We don't support array paths right now; so make path
197+
// null.
198+
return new ParseContext(
199+
this.dataSource,
200+
this.methodName,
201+
/*path=*/ null,
202+
/*arrayElement=*/ true,
203+
this.fieldTransforms,
204+
this.fieldMask
205+
);
206+
}
207+
187208
createError(reason: string): Error {
188209
const fieldDescription =
189210
this.path === null || this.path.isEmpty()
@@ -272,7 +293,7 @@ export class UserDataConverter {
272293
objUtils.forEach(input as Dict<AnyJs>, (key, value) => {
273294
const path = new ExternalFieldPath(key)._internalPath;
274295

275-
const childContext = context.childContext(path);
296+
const childContext = context.childContextForFieldPath(path);
276297
value = this.runPreConverter(value, childContext);
277298

278299
const parsedValue = this.parseData(value, childContext);
@@ -299,7 +320,7 @@ export class UserDataConverter {
299320
objUtils.forEach(input as Dict<AnyJs>, (key, value) => {
300321
const path = fieldPathFromDotSeparatedString(methodName, key);
301322

302-
const childContext = context.childContext(path);
323+
const childContext = context.childContextForFieldPath(path);
303324
value = this.runPreConverter(value, childContext);
304325
if (value instanceof DeleteFieldValueImpl) {
305326
// Add it to the field mask, but don't add anything to updateData.
@@ -354,7 +375,7 @@ export class UserDataConverter {
354375

355376
for (let i = 0; i < keys.length; ++i) {
356377
const path = keys[i];
357-
const childContext = context.childContext(path);
378+
const childContext = context.childContextForFieldPath(path);
358379
const value = this.runPreConverter(values[i], childContext);
359380
if (value instanceof DeleteFieldValueImpl) {
360381
// Add it to the field mask, but don't add anything to updateData.
@@ -413,15 +434,16 @@ export class UserDataConverter {
413434
private parseData(input: AnyJs, context: ParseContext): FieldValue | null {
414435
input = this.runPreConverter(input, context);
415436
if (input instanceof Array) {
416-
// TODO(b/34871131): We may need a different way to detect nested arrays
417-
// once we support array paths (at which point we should include the path
418-
// containing the array in the error message).
419-
if (!context.path) {
437+
// TODO(b/34871131): Include the path containing the array in the error
438+
// message.
439+
if (context.arrayElement) {
420440
throw context.createError('Nested arrays are not supported');
421441
}
422-
// We don't support field mask paths more granular than the top-level
423-
// array.
424-
context.fieldMask.push(context.path);
442+
// If context.path is null we are already inside an array and we don't
443+
// support field mask paths more granular than the top-level array.
444+
if (context.path) {
445+
context.fieldMask.push(context.path);
446+
}
425447
return this.parseArray(input as AnyJs[], context);
426448
} else if (looksLikeJsonObject(input)) {
427449
validatePlainObject('Unsupported field value:', context, input);
@@ -440,7 +462,10 @@ export class UserDataConverter {
440462
const result = [] as FieldValue[];
441463
let entryIndex = 0;
442464
for (const entry of array) {
443-
let parsedEntry = this.parseData(entry, context.childContext(entryIndex));
465+
let parsedEntry = this.parseData(
466+
entry,
467+
context.childContextForArray(entryIndex)
468+
);
444469
if (parsedEntry == null) {
445470
// Just include nulls in the array for fields being replaced with a
446471
// sentinel.
@@ -455,7 +480,10 @@ export class UserDataConverter {
455480
private parseObject(obj: Dict<AnyJs>, context: ParseContext): FieldValue {
456481
let result = new SortedMap<string, FieldValue>(primitiveComparator);
457482
objUtils.forEach(obj, (key: string, val: AnyJs) => {
458-
const parsedValue = this.parseData(val, context.childContext(key));
483+
const parsedValue = this.parseData(
484+
val,
485+
context.childContextForField(key)
486+
);
459487
if (parsedValue != null) {
460488
result = result.insert(key, parsedValue);
461489
}

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

Lines changed: 43 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,12 +304,49 @@ apiDescribe('Validation:', persistence => {
304304
}
305305
);
306306

307-
validationIt(persistence, 'must not contain nested arrays.', db => {
308-
return expectWriteToFail(
309-
db,
310-
{ 'nested-array': [1, [2]] },
311-
'Nested arrays are not supported'
312-
);
307+
validationIt(
308+
persistence,
309+
'must not contain directly nested arrays.',
310+
db => {
311+
return expectWriteToFail(
312+
db,
313+
{ 'nested-array': [1, [2]] },
314+
'Nested arrays are not supported'
315+
);
316+
}
317+
);
318+
319+
validationIt(persistence, 'may contain indirectly nested arrays.', db => {
320+
const data = { 'nested-array': [1, { foo: [2] }] };
321+
322+
const ref = db.doc('foo/bar');
323+
const ref2 = db.doc('foo/quux');
324+
325+
ref
326+
.set(data)
327+
.then(() => {
328+
return ref.firestore
329+
.batch()
330+
.set(ref, data)
331+
.commit();
332+
})
333+
.then(() => {
334+
return ref.update(data);
335+
})
336+
.then(() => {
337+
return ref.firestore
338+
.batch()
339+
.update(ref, data)
340+
.commit();
341+
})
342+
.then(() => {
343+
return ref.firestore.runTransaction(txn => {
344+
// Note ref2 does not exist at this point so set that and update ref.
345+
txn.update(ref, data);
346+
txn.set(ref2, data);
347+
return Promise.resolve();
348+
});
349+
});
313350
});
314351

315352
validationIt(persistence, 'must not contain undefined.', db => {

0 commit comments

Comments
 (0)