Skip to content

Commit 18f02e8

Browse files
Adding support for "mergeFields" (#173)
1 parent f06de88 commit 18f02e8

File tree

8 files changed

+396
-22
lines changed

8 files changed

+396
-22
lines changed

conformance/runner.js

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,7 @@ const firestore = new Firestore({
3737
});
3838

3939
/** List of test cases that are ignored. */
40-
const ignoredRe = [
41-
// Node doesn't support field masks for set().
42-
/^set-merge: .*$/,
43-
];
40+
const ignoredRe = [];
4441

4542
/** If non-empty, list the test cases to run exclusively. */
4643
const exclusiveRe = [];
@@ -333,13 +330,20 @@ function runTest(spec) {
333330
firestore.api.Firestore._commit = commitHandler(spec);
334331

335332
return Promise.resolve().then(() => {
336-
const isMerge = !!(spec.option && spec.option.all);
333+
const setOption = {};
334+
335+
if (spec.option && spec.option.all) {
336+
setOption.merge = true;
337+
} else if (spec.option && spec.option.fields) {
338+
setOption.mergeFields = [];
339+
for (const fieldPath of spec.option.fields) {
340+
setOption.mergeFields.push(new Firestore.FieldPath(fieldPath.field));
341+
}
342+
}
337343

338344
return docRef(setSpec.docRefPath).set(
339345
convertInput.argument(spec.jsonData),
340-
{
341-
merge: isMerge,
342-
}
346+
setOption
343347
);
344348
});
345349
};

src/document.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,23 @@ class DocumentMask {
10391039
return new DocumentMask(fieldPaths);
10401040
}
10411041

1042+
/**
1043+
* Creates a document mask from an array of field paths.
1044+
*
1045+
* @private
1046+
* @param {Array.<string|FieldPath>} fieldMask A list of field paths.
1047+
* @returns {DocumentMask}
1048+
*/
1049+
static fromFieldMask(fieldMask) {
1050+
let fieldPaths = [];
1051+
1052+
for (const fieldPath of fieldMask) {
1053+
fieldPaths.push(FieldPath.fromArgument(fieldPath));
1054+
}
1055+
1056+
return new DocumentMask(fieldPaths);
1057+
}
1058+
10421059
/**
10431060
* Creates a document mask with the field names of a document.
10441061
*
@@ -1097,6 +1114,122 @@ class DocumentMask {
10971114
return this._sortedPaths.length === 0;
10981115
}
10991116

1117+
/**
1118+
* Removes the specified values from a sorted field path array.
1119+
*
1120+
* @private
1121+
* @param {Array.<FieldPath>} input - A sorted array of FieldPaths.
1122+
* @param {Array.<FieldPath>} values - An array of FieldPaths to remove.
1123+
*/
1124+
static removeFromSortedArray(input, values) {
1125+
for (let i = 0; i < input.length; ) {
1126+
let removed = false;
1127+
1128+
for (const fieldPath of values) {
1129+
if (input[i].isEqual(fieldPath)) {
1130+
input.splice(i, 1);
1131+
removed = true;
1132+
break;
1133+
}
1134+
}
1135+
1136+
if (!removed) {
1137+
++i;
1138+
}
1139+
}
1140+
}
1141+
1142+
/**
1143+
* Removes the field path specified in 'fieldPaths' from this document mask.
1144+
*
1145+
* @private
1146+
* @param {Array.<FieldPath>} fieldPaths An array of FieldPaths.
1147+
*/
1148+
removeFields(fieldPaths) {
1149+
DocumentMask.removeFromSortedArray(this._sortedPaths, fieldPaths);
1150+
}
1151+
1152+
/**
1153+
* Returns whether this document mask contains 'fieldPath'.
1154+
*
1155+
* @private
1156+
* @param {FieldPath} fieldPath The field path to test.
1157+
* @return {boolean} Whether this document mask contains 'fieldPath'.
1158+
*/
1159+
contains(fieldPath) {
1160+
for (const sortedPath of this._sortedPaths) {
1161+
const cmp = sortedPath.compareTo(fieldPath);
1162+
1163+
if (cmp === 0) {
1164+
return true;
1165+
} else if (cmp > 0) {
1166+
return false;
1167+
}
1168+
}
1169+
1170+
return false;
1171+
}
1172+
1173+
/**
1174+
* Removes all properties from 'data' that are not contained in this document
1175+
* mask.
1176+
*
1177+
* @private
1178+
* @param {Object} data - An object to filter.
1179+
* @return {Object} A shallow copy of the object filtered by this document
1180+
* mask.
1181+
*/
1182+
applyTo(data) {
1183+
/*!
1184+
* Applies this DocumentMask to 'data' and computes the list of field paths
1185+
* that were specified in the mask but are not present in 'data'.
1186+
*/
1187+
const applyDocumentMask = data => {
1188+
const remainingPaths = this._sortedPaths.slice(0);
1189+
1190+
const processObject = (currentData, currentPath) => {
1191+
let result = null;
1192+
1193+
Object.keys(currentData).forEach(key => {
1194+
const childPath = currentPath
1195+
? currentPath.append(key)
1196+
: new FieldPath(key);
1197+
if (this.contains(childPath)) {
1198+
DocumentMask.removeFromSortedArray(remainingPaths, [childPath]);
1199+
result = result || {};
1200+
result[key] = currentData[key];
1201+
} else if (is.object(currentData[key])) {
1202+
const childObject = processObject(currentData[key], childPath);
1203+
if (childObject) {
1204+
result = result || {};
1205+
result[key] = childObject;
1206+
}
1207+
}
1208+
});
1209+
1210+
return result;
1211+
};
1212+
1213+
// processObject() returns 'null' if the DocumentMask is empty.
1214+
const filteredData = processObject(data) || {};
1215+
1216+
return {
1217+
filteredData: filteredData,
1218+
remainingPaths: remainingPaths,
1219+
};
1220+
};
1221+
1222+
const result = applyDocumentMask(data);
1223+
1224+
if (result.remainingPaths.length !== 0) {
1225+
throw new Error(
1226+
`Input data is missing for field '${result.remainingPaths[0].toString()}'.`
1227+
);
1228+
}
1229+
1230+
return result.filteredData;
1231+
}
1232+
11001233
/**
11011234
* Converts a document mask to the Firestore 'DocumentMask' Proto.
11021235
*
@@ -1219,6 +1352,18 @@ class DocumentTransform {
12191352
get isEmpty() {
12201353
return this._transforms.size === 0;
12211354
}
1355+
1356+
/**
1357+
* Returns the array of fields in this DocumentTransform.
1358+
*
1359+
* @private
1360+
* @type {Array.<FieldPath>} The fields specified in this DocumentTransform.
1361+
* @readonly
1362+
*/
1363+
get fields() {
1364+
return Array.from(this._transforms.keys());
1365+
}
1366+
12221367
/**
12231368
* Converts a document transform to the Firestore 'DocumentTransform' Proto.
12241369
*
@@ -1464,6 +1609,8 @@ function validatePrecondition(precondition, allowExist) {
14641609
*
14651610
* @param {boolean=} options.merge - Whether set() should merge the provided
14661611
* data into an existing document.
1612+
* @param {boolean=} options.mergeFields - Whether set() should only merge the
1613+
* specified set of fields.
14671614
* @returns {boolean} 'true' if the input is a valid SetOptions object.
14681615
*/
14691616
function validateSetOptions(options) {
@@ -1475,6 +1622,20 @@ function validateSetOptions(options) {
14751622
throw new Error('"merge" is not a boolean.');
14761623
}
14771624

1625+
if (is.defined(options.mergeFields)) {
1626+
if (!is.array(options.mergeFields)) {
1627+
throw new Error('"mergeFields" is not an array.');
1628+
}
1629+
1630+
for (let i = 0; i < options.mergeFields.length; ++i) {
1631+
validate.isFieldPath(i, options.mergeFields[i]);
1632+
}
1633+
}
1634+
1635+
if (is.defined(options.merge) && is.defined(options.mergeFields)) {
1636+
throw new Error('You cannot specify both "merge" and "mergeFields".');
1637+
}
1638+
14781639
return true;
14791640
}
14801641

src/reference.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -400,9 +400,12 @@ class DocumentReference {
400400
* @param {DocumentData} data - A map of the fields and values for the
401401
* document.
402402
* @param {SetOptions=} options - An object to configure the set behavior.
403-
* @param {boolean=} options.merge - If true, set() only replaces the
404-
* values specified in its data argument. Fields omitted from this set() call
403+
* @param {boolean=} options.merge - If true, set() merges the values
404+
* specified in its data argument. Fields omitted from this set() call
405405
* remain untouched.
406+
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
407+
* set() only replaces the specified field paths. All data at the specified
408+
* field paths is fully replaced, while the remaining fields remain untouched.
406409
* @returns {Promise.<WriteResult>} A Promise that resolves with the
407410
* write time of this set.
408411
*

src/transaction.js

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,12 @@ class Transaction {
183183
* document to be set.
184184
* @param {DocumentData} data - The object to serialize as the document.
185185
* @param {SetOptions=} options - An object to configure the set behavior.
186-
* @param {boolean=} options.merge - If true, set() only replaces the
187-
* values specified in its data argument. Fields omitted from this set() call
186+
* @param {boolean=} options.merge - If true, set() merges the values
187+
* specified in its data argument. Fields omitted from this set() call
188188
* remain untouched.
189+
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
190+
* set() only replaces the specified field paths. All data at the specified
191+
* field paths is fully replaced, while the remaining fields remain untouched.
189192
* @returns {Transaction} This Transaction instance. Used for
190193
* chaining method calls.
191194
*

src/write-batch.js

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -259,9 +259,12 @@ class WriteBatch {
259259
* document to be set.
260260
* @param {DocumentData} data - The object to serialize as the document.
261261
* @param {SetOptions=} options - An object to configure the set behavior.
262-
* @param {boolean=} options.merge - If true, set() only replaces the
263-
* values specified in its data argument. Fields omitted from this set() call
262+
* @param {boolean=} options.merge - If true, set() merges the values
263+
* specified in its data argument. Fields omitted from this set() call
264264
* remain untouched.
265+
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
266+
* set() only replaces the specified field paths. All data at the specified
267+
* field paths is fully replaced, while the remaining fields remain untouched.
265268
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
266269
* method calls.
267270
*
@@ -276,27 +279,40 @@ class WriteBatch {
276279
* });
277280
*/
278281
set(documentRef, data, options) {
279-
const merge = (options && options.merge) === true;
282+
validate.isOptionalSetOptions('options', options);
283+
const mergeLeaves = options && options.merge === true;
284+
const mergePaths = options && options.mergeFields;
280285

281286
validate.isDocumentReference('documentRef', documentRef);
282287
validate.isDocument('data', data, {
283288
allowEmpty: true,
284-
allowDeletes: merge ? 'all' : 'none',
289+
allowDeletes: mergePaths || mergeLeaves ? 'all' : 'none',
285290
allowServerTimestamps: true,
286291
});
287-
validate.isOptionalSetOptions('options', options);
288292

289293
this.verifyNotCommitted();
290294

295+
let documentMask;
296+
297+
if (mergePaths) {
298+
documentMask = DocumentMask.fromFieldMask(options.mergeFields);
299+
data = documentMask.applyTo(data);
300+
}
301+
291302
const document = DocumentSnapshot.fromObject(documentRef, data);
292303
const transform = DocumentTransform.fromObject(documentRef, data);
293-
const documentMask = DocumentMask.fromObject(data);
304+
305+
if (mergePaths) {
306+
documentMask.removeFields(transform.fields);
307+
} else {
308+
documentMask = DocumentMask.fromObject(data);
309+
}
294310

295311
const hasDocumentData = !document.isEmpty || !documentMask.isEmpty;
296312

297313
let write;
298314

299-
if (!merge) {
315+
if (!mergePaths && !mergeLeaves) {
300316
write = document.toProto();
301317
} else if (hasDocumentData || transform.isEmpty) {
302318
write = document.toProto();

0 commit comments

Comments
 (0)