Skip to content

Commit 463fbfc

Browse files
Rejecting custom types from the API surface (#137)
1 parent 843a5d1 commit 463fbfc

11 files changed

+258
-119
lines changed

src/document.js

Lines changed: 65 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -709,19 +709,22 @@ class DocumentSnapshot {
709709
}
710710

711711
if (is.array(val)) {
712-
let encodedElements = [];
713-
for (let i = 0; i < val.length; ++i) {
714-
let enc = DocumentSnapshot.encodeValue(val[i]);
715-
if (enc) {
716-
encodedElements.push(enc);
717-
}
718-
}
719-
return {
712+
const array = {
720713
valueType: 'arrayValue',
721-
arrayValue: {
722-
values: encodedElements,
723-
},
714+
arrayValue: {},
724715
};
716+
717+
if (val.length > 0) {
718+
array.arrayValue.values = [];
719+
for (let i = 0; i < val.length; ++i) {
720+
let enc = DocumentSnapshot.encodeValue(val[i]);
721+
if (enc) {
722+
array.arrayValue.values.push(enc);
723+
}
724+
}
725+
}
726+
727+
return array;
725728
}
726729

727730
if (is.nil(val)) {
@@ -755,9 +758,7 @@ class DocumentSnapshot {
755758
if (isPlainObject(val)) {
756759
const map = {
757760
valueType: 'mapValue',
758-
mapValue: {
759-
fields: {},
760-
},
761+
mapValue: {},
761762
};
762763

763764
// If we encounter an empty object, we always need to send it to make sure
@@ -772,11 +773,7 @@ class DocumentSnapshot {
772773
return map;
773774
}
774775

775-
throw new Error(
776-
'Cannot encode type (' +
777-
Object.prototype.toString.call(val) +
778-
') to a Firestore Value'
779-
);
776+
throw validate.customObjectError(val);
780777
}
781778
}
782779

@@ -1184,10 +1181,7 @@ class DocumentTransform {
11841181
* @return {boolean} Whether we encountered a transform sentinel.
11851182
*/
11861183
static isTransformSentinel(val) {
1187-
return (
1188-
val === FieldValue.SERVER_TIMESTAMP_SENTINEL ||
1189-
val === FieldValue.DELETE_SENTINEL
1190-
);
1184+
return is.instanceof(val, FieldValue);
11911185
}
11921186

11931187
/**
@@ -1277,16 +1271,20 @@ class Precondition {
12771271
* Validates a JavaScript object for usage as a Firestore document.
12781272
*
12791273
* @param {Object} obj JavaScript object to validate.
1280-
* @param {boolean=} options.allowDeletes Whether field deletes are supported
1281-
* at the top level (e.g. for document updates).
1282-
* @param {boolean=} options.allowNestedDeletes Whether field deletes are supported
1283-
* at any level (e.g. for document merges).
1284-
* @param {boolean=} options.allowEmpty Whether empty documents are support.
1285-
* Defaults to true.
1274+
*@param {string} options.allowDeletes At what level field deletes are
1275+
* supported (acceptable values are 'none', 'root' or 'all').
1276+
* @param {boolean} options.allowServerTimestamps Whether server timestamps
1277+
* are supported.
1278+
* @param {boolean} options.allowEmpty Whether empty documents are supported.
12861279
* @returns {boolean} 'true' when the object is valid.
12871280
* @throws {Error} when the object is invalid.
12881281
*/
12891282
function validateDocumentData(obj, options) {
1283+
assert(
1284+
typeof options.allowEmpty === 'boolean',
1285+
"Expected boolean for 'options.allowEmpty'"
1286+
);
1287+
12901288
if (!isPlainObject(obj)) {
12911289
throw new Error('Input is not a plain JavaScript object.');
12921290
}
@@ -1313,16 +1311,23 @@ function validateDocumentData(obj, options) {
13131311
* Validates a JavaScript value for usage as a Firestore value.
13141312
*
13151313
* @param {Object} obj JavaScript value to validate.
1316-
* @param {boolean=} options.allowDeletes Whether field deletes are supported
1317-
* at the top level (e.g. for document updates).
1318-
* @param {boolean=} options.allowNestedDeletes Whether field deletes are supported
1319-
* at any level (e.g. for document merges).
1314+
* @param {string} options.allowDeletes At what level field deletes are
1315+
* supported (acceptable values are 'none', 'root' or 'all').
1316+
* @param {boolean} options.allowServerTimestamps Whether server timestamps
1317+
* are supported.
13201318
* @param {number=} depth The current depth of the traversal.
13211319
* @returns {boolean} 'true' when the object is valid.
13221320
* @throws {Error} when the object is invalid.
13231321
*/
13241322
function validateFieldValue(obj, options, depth) {
1325-
options = options || {};
1323+
assert(
1324+
['none', 'root', 'all'].indexOf(options.allowDeletes) !== -1,
1325+
"Expected 'none', 'root', or 'all' for 'options.allowDeletes'"
1326+
);
1327+
assert(
1328+
typeof options.allowServerTimestamps === 'boolean',
1329+
"Expected boolean for 'options.allowServerTimestamps'"
1330+
);
13261331

13271332
if (!depth) {
13281333
depth = 1;
@@ -1332,25 +1337,39 @@ function validateFieldValue(obj, options, depth) {
13321337
);
13331338
}
13341339

1335-
if (obj === FieldValue.DELETE_SENTINEL) {
1336-
if (!options.allowNestedDeletes && (!options.allowDeletes || depth > 1)) {
1337-
throw new Error(
1338-
'Deletes must appear at the top-level and can only be used in update() or set() with {merge:true}.'
1339-
);
1340+
if (is.array(obj)) {
1341+
for (let prop of obj) {
1342+
validateFieldValue(obj[prop], options, depth + 1);
13401343
}
1341-
}
1342-
1343-
if (isPlainObject(obj)) {
1344+
} else if (isPlainObject(obj)) {
13441345
for (let prop in obj) {
13451346
if (obj.hasOwnProperty(prop)) {
13461347
validateFieldValue(obj[prop], options, depth + 1);
13471348
}
13481349
}
1349-
}
1350-
if (is.array(obj)) {
1351-
for (let prop of obj) {
1352-
validateFieldValue(obj[prop], options, depth + 1);
1350+
} else if (obj === FieldValue.DELETE_SENTINEL) {
1351+
if (
1352+
(options.allowDeletes === 'root' && depth > 1) ||
1353+
options.allowDeletes === 'none'
1354+
) {
1355+
throw new Error(
1356+
'FieldValue.delete() must appear at the top-level and can only be used in update() or set() with {merge:true}.'
1357+
);
1358+
}
1359+
} else if (obj === FieldValue.SERVER_TIMESTAMP_SENTINEL) {
1360+
if (!options.allowServerTimestamps) {
1361+
throw new Error(
1362+
'FieldValue.serverTimestamp() can only be used in update(), set() and create().'
1363+
);
13531364
}
1365+
} else if (is.instanceof(obj, DocumentReference)) {
1366+
return true;
1367+
} else if (is.instanceof(obj, GeoPoint)) {
1368+
return true;
1369+
} else if (is.instanceof(obj, FieldPath)) {
1370+
throw new Error('Cannot use "FieldPath" as a Firestore type.');
1371+
} else if (is.object(obj)) {
1372+
throw validate.customObjectError(obj);
13541373
}
13551374

13561375
return true;

src/field-value.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@
2020
* Sentinel value for a field delete.
2121
*
2222
*/
23-
const DELETE_SENTINEL = {};
23+
let DELETE_SENTINEL;
2424

2525
/*!
2626
* Sentinel value for a server timestamp.
2727
*
2828
*/
29-
const SERVER_TIMESTAMP_SENTINEL = {};
29+
let SERVER_TIMESTAMP_SENTINEL;
3030

3131
/**
3232
* Sentinel values that can be used when writing documents with set() or
@@ -35,6 +35,12 @@ const SERVER_TIMESTAMP_SENTINEL = {};
3535
* @class
3636
*/
3737
class FieldValue {
38+
/**
39+
* @private
40+
* @hideconstructor
41+
*/
42+
constructor() {}
43+
3844
/**
3945
* Returns a sentinel used with update() to mark a field for deletion.
4046
*
@@ -76,6 +82,9 @@ class FieldValue {
7682
}
7783
}
7884

85+
DELETE_SENTINEL = new FieldValue();
86+
SERVER_TIMESTAMP_SENTINEL = new FieldValue();
87+
7988
module.exports = FieldValue;
8089
module.exports.DELETE_SENTINEL = DELETE_SENTINEL;
8190
module.exports.SERVER_TIMESTAMP_SENTINEL = SERVER_TIMESTAMP_SENTINEL;

src/order.js

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
'use strict';
1818

1919
const is = require('is');
20+
const validate = require('./validate')();
2021

2122
/*!
2223
* @see ResourcePath
@@ -78,13 +79,7 @@ function typeOrder(val) {
7879
return types.OBJECT;
7980
}
8081
default: {
81-
throw new Error(
82-
'Cannot use type (' +
83-
val +
84-
': ' +
85-
JSON.stringify(val) +
86-
') as a Firestore value.'
87-
);
82+
throw validate.customObjectError(val);
8883
}
8984
}
9085
}
@@ -260,10 +255,16 @@ function compare(left, right) {
260255
return compareGeoPoints(left.geoPointValue, right.geoPointValue);
261256
}
262257
case types.ARRAY: {
263-
return compareArrays(left.arrayValue.values, right.arrayValue.values);
258+
return compareArrays(
259+
left.arrayValue.values || [],
260+
right.arrayValue.values || []
261+
);
264262
}
265263
case types.OBJECT: {
266-
return compareObjects(left.mapValue.fields, right.mapValue.fields);
264+
return compareObjects(
265+
left.mapValue.fields || {},
266+
right.mapValue.fields || {}
267+
);
267268
}
268269
}
269270
}

src/path.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,7 @@ class FieldPath extends Path {
484484
static validateFieldPath(fieldPath) {
485485
if (!is.instanceof(fieldPath, FieldPath)) {
486486
if (!is.string(fieldPath)) {
487-
throw new Error(`Paths must be strings or FieldPath objects.`);
487+
throw validate.customObjectError(fieldPath);
488488
}
489489

490490
if (fieldPath.indexOf('..') >= 0) {

src/reference.js

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,6 @@ let Firestore;
3636
*/
3737
let DocumentSnapshot;
3838

39-
/*!
40-
* Injected.
41-
*
42-
* @see DocumentTransform
43-
*/
44-
let DocumentTransform;
45-
4639
/*!
4740
* Injected.
4841
*/
@@ -1118,6 +1111,10 @@ class Query {
11181111
where(fieldPath, opStr, value) {
11191112
validate.isFieldPath('fieldPath', fieldPath);
11201113
validate.isFieldComparison('opStr', opStr, value);
1114+
validate.isFieldValue('value', value, {
1115+
allowDeletes: 'none',
1116+
allowServerTimestamps: false,
1117+
});
11211118

11221119
if (this._queryOptions.startAt || this._queryOptions.endAt) {
11231120
throw new Error(
@@ -1402,12 +1399,10 @@ class Query {
14021399
fieldValue = this._convertReference(fieldValue);
14031400
}
14041401

1405-
if (DocumentTransform.isTransformSentinel(fieldValue)) {
1406-
throw new Error(
1407-
`Cannot use FieldValue.delete() or FieldValue.serverTimestamp() in ` +
1408-
`a query boundary. Found at index ${i}.`
1409-
);
1410-
}
1402+
validate.isFieldValue(i, fieldValue, {
1403+
allowDeletes: 'none',
1404+
allowServerTimestamps: false,
1405+
});
14111406

14121407
options.values.push(DocumentSnapshot.encodeValue(fieldValue));
14131408
}
@@ -2040,7 +2035,11 @@ class CollectionReference extends Query {
20402035
* });
20412036
*/
20422037
add(data) {
2043-
validate.isDocument('data', data);
2038+
validate.isDocument('data', data, {
2039+
allowEmpty: true,
2040+
allowDeletes: 'none',
2041+
allowServerTimestamps: true,
2042+
});
20442043

20452044
let documentRef = this.doc();
20462045
return documentRef.create(data).then(() => {
@@ -2110,14 +2109,16 @@ function validateComparisonOperator(str, val) {
21102109
* @returns 'true' is value is an instance of DocumentReference.
21112110
*/
21122111
function validateDocumentReference(value) {
2113-
return is.instanceof(value, DocumentReference);
2112+
if (is.instanceof(value, DocumentReference)) {
2113+
return true;
2114+
}
2115+
throw validate.customObjectError(value);
21142116
}
21152117

21162118
module.exports = FirestoreType => {
21172119
Firestore = FirestoreType;
21182120
let document = require('./document')(DocumentReference);
21192121
DocumentSnapshot = document.DocumentSnapshot;
2120-
DocumentTransform = document.DocumentTransform;
21212122
Watch = require('./watch')(
21222123
FirestoreType,
21232124
DocumentChange,
@@ -2134,6 +2135,7 @@ module.exports = FirestoreType => {
21342135
FieldPath: FieldPath.validateFieldPath,
21352136
FieldComparison: validateComparisonOperator,
21362137
FieldOrder: validateFieldOrder,
2138+
FieldValue: document.validateFieldValue,
21372139
Precondition: document.validatePrecondition,
21382140
ResourcePath: ResourcePath.validateResourcePath,
21392141
});

src/validate.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,5 +140,24 @@ module.exports = validators => {
140140
return true;
141141
};
142142

143+
exports.customObjectError = val => {
144+
let typeName = is.object(val) ? val.constructor.name : typeof val;
145+
146+
switch (typeName) {
147+
case 'DocumentReference':
148+
case 'FieldPath':
149+
case 'FieldValue':
150+
case 'GeoPoint':
151+
return new Error(
152+
`Detected an object of type "${typeName}" that doesn't match the expected instance. Please ensure that ` +
153+
'the Firestore types you are using are from the same NPM package.'
154+
);
155+
default:
156+
return new Error(
157+
`Cannot use custom type "${typeName}" as a Firestore type.`
158+
);
159+
}
160+
};
161+
143162
return exports;
144163
};

0 commit comments

Comments
 (0)