Skip to content

Adding support for "mergeFields" #173

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Apr 6, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions conformance/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@ const firestore = new Firestore({
});

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

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

return Promise.resolve().then(() => {
const isMerge = !!(spec.option && spec.option.all);
const setOption = {};

if (spec.option && spec.option.all) {
setOption.merge = true;
} else if (spec.option && spec.option.fields) {
setOption.mergeFields = [];
for (const fieldPath of spec.option.fields) {
setOption.mergeFields.push(new Firestore.FieldPath(fieldPath.field));
}
}

return docRef(setSpec.docRefPath).set(
convertInput.argument(spec.jsonData),
{
merge: isMerge,
}
setOption
);
});
};
Expand Down
161 changes: 161 additions & 0 deletions src/document.js
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,23 @@ class DocumentMask {
return new DocumentMask(fieldPaths);
}

/**
* Creates a document mask from an array of field paths.
*
* @private
* @param {Array.<string|FieldPath>} fieldMask A list of field paths.
* @returns {DocumentMask}
*/
static fromFieldMask(fieldMask) {
let fieldPaths = [];

for (const fieldPath of fieldMask) {
fieldPaths.push(FieldPath.fromArgument(fieldPath));
}

return new DocumentMask(fieldPaths);
}

/**
* Creates a document mask with the field names of a document.
*
Expand Down Expand Up @@ -1097,6 +1114,122 @@ class DocumentMask {
return this._sortedPaths.length === 0;
}

/**
* Removes the specified values from a sorted field path array.
*
* @private
* @param {Array.<FieldPath>} input - A sorted array of FieldPaths.
* @param {Array.<FieldPath>} values - An array of FieldPaths to remove.
*/
static removeFromSortedArray(input, values) {
for (let i = 0; i < input.length; ) {
let removed = false;

for (const fieldPath of values) {
if (input[i].isEqual(fieldPath)) {
input.splice(i, 1);
removed = true;
break;
}
}

if (!removed) {
++i;
}
}
}

/**
* Removes the field path specified in 'fieldPaths' from this document mask.
*
* @private
* @param {Array.<FieldPath>} fieldPaths An array of FieldPaths.
*/
removeFields(fieldPaths) {
DocumentMask.removeFromSortedArray(this._sortedPaths, fieldPaths);
}

/**
* Returns whether this document mask contains 'fieldPath'.
*
* @private
* @param {FieldPath} fieldPath The field path to test.
* @return {boolean} Whether this document mask contains 'fieldPath'.
*/
contains(fieldPath) {
for (const sortedPath of this._sortedPaths) {
const cmp = sortedPath.compareTo(fieldPath);

if (cmp === 0) {
return true;
} else if (cmp > 0) {
return false;
}
}

return false;
}

/**
* Removes all properties from 'data' that are not contained in this document
* mask.
*
* @private
* @param {Object} data - An object to filter.
* @return {Object} A shallow copy of the object filtered by this document
* mask.
*/
applyTo(data) {
/*!
* Applies this DocumentMask to 'data' and computes the list of field paths
* that were specified in the mask but are not present in 'data'.
*/
const applyDocumentMask = data => {
const remainingPaths = this._sortedPaths.slice(0);

const processObject = (currentData, currentPath) => {
let result = null;

This comment was marked as spam.

This comment was marked as spam.

This comment was marked as spam.


Object.keys(currentData).forEach(key => {
const childPath = currentPath
? currentPath.append(key)
: new FieldPath(key);
if (this.contains(childPath)) {
DocumentMask.removeFromSortedArray(remainingPaths, [childPath]);
result = result || {};
result[key] = currentData[key];
} else if (is.object(currentData[key])) {
const childObject = processObject(currentData[key], childPath);
if (childObject) {
result = result || {};
result[key] = childObject;
}
}
});

return result;
};

// processObject() returns 'null' if the DocumentMask is empty.
const filteredData = processObject(data) || {};

return {
filteredData: filteredData,
remainingPaths: remainingPaths,
};
};

const result = applyDocumentMask(data);

if (result.remainingPaths.length !== 0) {
throw new Error(
`Input data is missing for field '${result.remainingPaths[0].toString()}'.`
);
}

return result.filteredData;
}

/**
* Converts a document mask to the Firestore 'DocumentMask' Proto.
*
Expand Down Expand Up @@ -1219,6 +1352,18 @@ class DocumentTransform {
get isEmpty() {
return this._transforms.size === 0;
}

/**
* Returns the array of fields in this DocumentTransform.
*
* @private
* @type {Array.<FieldPath>} The fields specified in this DocumentTransform.
* @readonly
*/
get fields() {
return Array.from(this._transforms.keys());
}

/**
* Converts a document transform to the Firestore 'DocumentTransform' Proto.
*
Expand Down Expand Up @@ -1464,6 +1609,8 @@ function validatePrecondition(precondition, allowExist) {
*
* @param {boolean=} options.merge - Whether set() should merge the provided
* data into an existing document.
* @param {boolean=} options.mergeFields - Whether set() should only merge the
* specified set of fields.
* @returns {boolean} 'true' if the input is a valid SetOptions object.
*/
function validateSetOptions(options) {
Expand All @@ -1475,6 +1622,20 @@ function validateSetOptions(options) {
throw new Error('"merge" is not a boolean.');
}

if (is.defined(options.mergeFields)) {
if (!is.array(options.mergeFields)) {
throw new Error('"mergeFields" is not an array.');
}

for (let i = 0; i < options.mergeFields.length; ++i) {
validate.isFieldPath(i, options.mergeFields[i]);
}
}

if (is.defined(options.merge) && is.defined(options.mergeFields)) {
throw new Error('You cannot specify both "merge" and "mergeFields".');
}

return true;
}

Expand Down
7 changes: 5 additions & 2 deletions src/reference.js
Original file line number Diff line number Diff line change
Expand Up @@ -400,9 +400,12 @@ class DocumentReference {
* @param {DocumentData} data - A map of the fields and values for the
* document.
* @param {SetOptions=} options - An object to configure the set behavior.
* @param {boolean=} options.merge - If true, set() only replaces the
* values specified in its data argument. Fields omitted from this set() call
* @param {boolean=} options.merge - If true, set() merges the values
* specified in its data argument. Fields omitted from this set() call
* remain untouched.
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
* set() only replaces the specified field paths. All data at the specified
* field paths is fully replaced, while the remaining fields remain untouched.
* @returns {Promise.<WriteResult>} A Promise that resolves with the
* write time of this set.
*
Expand Down
7 changes: 5 additions & 2 deletions src/transaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -183,9 +183,12 @@ class Transaction {
* document to be set.
* @param {DocumentData} data - The object to serialize as the document.
* @param {SetOptions=} options - An object to configure the set behavior.
* @param {boolean=} options.merge - If true, set() only replaces the
* values specified in its data argument. Fields omitted from this set() call
* @param {boolean=} options.merge - If true, set() merges the values
* specified in its data argument. Fields omitted from this set() call
* remain untouched.
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
* set() only replaces the specified field paths. All data at the specified
* field paths is fully replaced, while the remaining fields remain untouched.
* @returns {Transaction} This Transaction instance. Used for
* chaining method calls.
*
Expand Down
30 changes: 23 additions & 7 deletions src/write-batch.js
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,12 @@ class WriteBatch {
* document to be set.
* @param {DocumentData} data - The object to serialize as the document.
* @param {SetOptions=} options - An object to configure the set behavior.
* @param {boolean=} options.merge - If true, set() only replaces the
* values specified in its data argument. Fields omitted from this set() call
* @param {boolean=} options.merge - If true, set() merges the values
* specified in its data argument. Fields omitted from this set() call
* remain untouched.
* @param {Array.<string|FieldPath>=} options.mergeFields - If provided,
* set() only replaces the specified field paths. All data at the specified
* field paths is fully replaced, while the remaining fields remain untouched.
* @returns {WriteBatch} This WriteBatch instance. Used for chaining
* method calls.
*
Expand All @@ -276,27 +279,40 @@ class WriteBatch {
* });
*/
set(documentRef, data, options) {
const merge = (options && options.merge) === true;
validate.isOptionalSetOptions('options', options);
const mergeLeaves = options && options.merge === true;
const mergePaths = options && options.mergeFields;

validate.isDocumentReference('documentRef', documentRef);
validate.isDocument('data', data, {
allowEmpty: true,
allowDeletes: merge ? 'all' : 'none',
allowDeletes: mergePaths || mergeLeaves ? 'all' : 'none',
allowServerTimestamps: true,
});
validate.isOptionalSetOptions('options', options);

this.verifyNotCommitted();

let documentMask;

if (mergePaths) {
documentMask = DocumentMask.fromFieldMask(options.mergeFields);
data = documentMask.applyTo(data);
}

const document = DocumentSnapshot.fromObject(documentRef, data);
const transform = DocumentTransform.fromObject(documentRef, data);
const documentMask = DocumentMask.fromObject(data);

if (mergePaths) {
documentMask.removeFields(transform.fields);
} else {
documentMask = DocumentMask.fromObject(data);
}

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

let write;

if (!merge) {
if (!mergePaths && !mergeLeaves) {
write = document.toProto();
} else if (hasDocumentData || transform.isEmpty) {
write = document.toProto();
Expand Down
Loading