Repository: dynamoosejs/dynamoose
Pull request #769 (2020-04-04 02:59:13)

Conditionals
This PR adds basic support for Conditionals in Dynamoose.


Patch:
diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md
index 30212bfb..c2f687e3 100644
--- a/BREAKING_CHANGES.md
+++ b/BREAKING_CHANGES.md
@@ -7,7 +7,8 @@
 - Schema `default` value does not pass the model instance into `default` functions any more.
 - `Model.update`
 	- `$LISTAPPEND` has been removed, and `$ADD` now includes the behavior of `$LISTAPPEND`
-	- `$DELETE` now maps to the correct underlying DynamoDB method instead of the previous behavior of mapping to `$REMOVE`
+	- `$DELETE` has been renamed to `$REMOVE`
+	- `$REMOVE` (previously `$DELETE`) now maps to the correct underlying DynamoDB method instead of the previous behavior of mapping to `$REMOVE`
 	- `$PUT` has been replaced with `$SET`
 - `dynamoose.setDefaults` has been renamed to `dynamoose.Model.defaults`, and is now a property that you set, vs a method that you pass the object into
 - `dynamoose.model` has been renamed to `dynamoose.Model`
diff --git a/docs/api/Condition.md b/docs/api/Condition.md
new file mode 100644
index 00000000..0392bd0f
--- /dev/null
+++ b/docs/api/Condition.md
@@ -0,0 +1,169 @@
+# Condition
+
+The Condition object represents a conditional that you can attach to various settings in other Dynamoose methods.
+
+## new dynamoose.Condition([filter])
+
+This is the basic entry point to construct a conditional. The filter property is optional and can either be an object or a string representing the key you which to first filter on.
+
+```js
+new dynamoose.Condition("breed").contains("Terrier") // will condition for where the key `breed` contains `Terrier`
+new dynamoose.Condition({"breed": {"contains": "Terrier"}}) // will condition for where the key `breed` contains `Terrier`
+```
+
+If you pass an object into `new dynamoose.Condition()` the object for each key should contain the comparison type. For example, in the last example above, `contains` was our comparison type. This comparison type must match one of the comparison type functions listed on this page.
+
+## condition.and()
+
+This function has no behavior and is only used to increase readability of your conditional. This function can be omitted with no behavior change to your code.
+
+```js
+// The two condition objects below are identical
+new dynamoose.Condition().where("id").eq(1).and().where("name").eq("Bob");
+new dynamoose.Condition().where("id").eq(1).where("name").eq("Bob");
+```
+
+## condition.and()
+
+This function specifies an `OR` join between two conditions, as opposed to the default `AND`. The condition will return `true` if either condition is met.
+
+```js
+new dynamoose.Condition().where("id").eq(1).or().where("name").eq("Bob"); // id = 1 OR name = Bob
+```
+
+## condition.not()
+
+This function sets the condition to use the opposite comparison type for the given condition. You can find the list opposite comparison types below.
+
+- equals (EQ) - not equals (NE)
+- less than or equals (LE) - greater than (GT)
+- less than (LT) - greater than or equals (GE)
+- null (NULL) - not null (NOT_NULL)
+- contains (CONTAINS) - not contains (NOT_CONTAINS)
+- exists (EXISTS) - not exists (NOT_EXISTS)
+
+The following comparisons do not have an opposite comparison type, and will throw an error if you try to use condition.not() with them.
+
+- in (IN)
+- between (BETWEEN)
+- begins with (BEGINS_WITH)
+
+```js
+new dynamoose.Condition().where("id").not().eq(1); // Retrieve all objects where id does NOT equal 1
+new dynamoose.Condition().where("id").not().between(1, 2); // Will throw error since between does not have an opposite comparison type
+```
+
+## condition.parenthesis(condition)
+
+This function takes in a `Condition` instance as a parameter and uses that as a group. This lets you specify the priority of the conditional. You can also pass a function into the `condition` parameter and Dynamoose will call your function with one argument which is a condition instance that you can return to specify the group.
+
+```js
+// The two condition objects below are identical
+new dynamoose.Condition().where("id").eq(1).and().parenthesis(new dynamoose.Condition().where("name").eq("Bob")); // id = 1 AND (name = Bob)
+new dynamoose.Condition().where("id").eq(1).and().parenthesis((condition) => condition.where("name").eq("Bob")); // id = 1 AND (name = Bob)
+```
+
+## condition.group(condition)
+
+This function is identical to [`condition.parenthesis(condition)`](#conditionparenthesiscondition) and just used as an alias.
+
+## condition.filter(key)
+
+This function prepares a new conditional to be used with the request. If you have not finished your previous filter conditional before using this function again it will wipe out the previous pending conditional filter. The `key` parameter is a string that you pass in representing which attribute you would like to filter on.
+
+You will use this function with a comparison function which will complete the filter conditional and allow you to start another filter conditional if you wish.
+
+```js
+new dynamoose.Condition().filter("id"); // Currently this condition has no filter behavior and will represent an empty conditional
+new dynamoose.Condition().filter("id").eq(1); // Since this condition has a comparison function (eq) after the filter it will complete the filter conditional and only represent items where `id` = 1
+```
+
+## condition.where(key)
+
+This function is identical to [`condition.filter(key)`](#conditionfilterkey) and just used as an alias.
+
+## condition.attribute(key)
+
+This function is identical to [`condition.filter(key)`](#conditionfilterkey) and just used as an alias.
+
+## condition.eq(value)
+
+This comparison function will check to see if the given filter key is equal to the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("name").eq("Tom"); // Condition all items where `name` equals `Tom`
+```
+
+## condition.exists()
+
+This comparison function will check to see if the given filter key exists in the document.
+
+```js
+new dynamoose.Condition().filter("phoneNumber").exists(); // Represents all items where `phoneNumber` exists in the document
+
+new dynamoose.Condition().filter("phoneNumber").not().exists(); // Represents all items where `phoneNumber` does not exist in the document
+```
+
+## condition.lt(value)
+
+This comparison function will check to see if the given filter key is less than the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("age").lt(5); // Represents all items where `age` is less than 5
+```
+
+## condition.le(value)
+
+This comparison function will check to see if the given filter key is less than or equal to the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("age").le(5); // Represents all items where `age` is less than or equal to 5
+```
+
+## condition.gt(value)
+
+This comparison function will check to see if the given filter key is greater than the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("age").gt(5); // Represents all items where `age` is greater than 5
+```
+
+## condition.ge(value)
+
+This comparison function will check to see if the given filter key is greater than or equal to the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("age").ge(5); // Represents all items where `age` is greater than or equal to 5
+```
+
+## condition.beginsWith(value)
+
+This comparison function will check to see if the given filter key begins with the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("name").beginsWith("T"); // Represents all items where `name` begins with `T`
+```
+
+## condition.contains(value)
+
+This comparison function will check to see if the given filter key contains the value you pass in as a parameter.
+
+```js
+new dynamoose.Condition().filter("name").contains("om"); // Represents all items where `name` contains `om`
+```
+
+## condition.in(values)
+
+This comparison function will check to see if the given filter key equals any of the items you pass in in the values array you pass in. The `values` parameter must be an array and will only represent items where the value for the given key exists in the array you pass in.
+
+```js
+new dynamoose.Condition("name").in(["Charlie", "Bob"]) // Represents all items where `name` = `Charlie` OR `Bob`
+```
+
+## condition.between(a, b)
+
+This comparison function will check to see if the given filter key is between the two values you pass in as parameters.
+
+```js
+new dynamoose.Condition().filter("age").between(5, 9); // Represents all items where `age` is between 5 and 9
+```
diff --git a/docs/api/Model.md b/docs/api/Model.md
index b8a291df..f9b5d7f1 100644
--- a/docs/api/Model.md
+++ b/docs/api/Model.md
@@ -412,6 +412,7 @@ You can also pass in a `settings` object parameter to define extra settings for
 | Name | Description | Type | Default |
 |------|-------------|------|---------|
 | return | What the function should return. Can be `document`, or `request`. In the event this is set to `request` the request Dynamoose will make to DynamoDB will be returned, and no request to DynamoDB will be made. | String | `document` |
+| condition | This is an optional instance of a Condition for the update. | [dynamoose.Condition](Condition.md) | `null`
 
 There are two different methods for specifying what you'd like to edit in the document. The first is you can just pass in the attribute name as the key, and the new value as the value. This will set the given attribute to the new value.
 
@@ -431,6 +432,25 @@ User.update({"id": 1}, {"name": "Bob"}, (error, user) => {
 });
 ```
 
+```js
+// The following code below will only update the item if the `active` property on the existing document is set to true
+
+const condition = new dynamoose.Condition().where("active").eq(true);
+
+
+await User.update({"id": 1}, {"name": "Bob"}, {"condition": condition});
+
+// OR
+
+User.update({"id": 1}, {"name": "Bob"}, {"condition": condition}, (error, user) => {
+	if (error) {
+		console.error(error);
+	} else {
+		console.log(user);
+	}
+});
+```
+
 The other method you can use is by using specific update types. These update types are as follows.
 
 - `$SET` - This method will set the attribute to the new value (as shown above)
diff --git a/lib/Condition.js b/lib/Condition.js
new file mode 100644
index 00000000..dcaaaadc
--- /dev/null
+++ b/lib/Condition.js
@@ -0,0 +1,186 @@
+const Document = require("./Document");
+const Error = require("./Error");
+const utils = require("./utils");
+const OR = Symbol("OR");
+
+class Condition {
+	constructor(object) {
+		this.settings = {};
+		this.settings.conditions = [];
+		this.settings.pending = {}; // represents the pending chain of filter data waiting to be attached to the `conditions` parameter. For example, storing the key before we know what the comparison operator is.
+
+		if (typeof object === "object") {
+			Object.keys(object).forEach((key) => {
+				const value = object[key];
+				const valueType = typeof value === "object" && Object.keys(value).length > 0 ? Object.keys(value)[0] : "eq";
+				const comparisonType = types.find((item) => item.name === valueType);
+
+				if (!comparisonType) {
+					throw new Error.InvalidFilterComparison(`The type: ${valueType} is invalid.`);
+				}
+
+				this.settings.conditions.push([key, {"type": comparisonType.typeName, "value": typeof value[valueType] !== "undefined" && value[valueType] !== null ? value[valueType] : value}]);
+			});
+		} else if (object) {
+			this.settings.pending.key = object;
+		}
+
+		return this;
+	}
+}
+
+
+const notComparisonTypes = (() => {
+	const obj = {
+		"EQ": "NE",
+		"IN": null,
+		"LE": "GT",
+		"LT": "GE",
+		"BETWEEN": null,
+		"CONTAINS": "NOT_CONTAINS",
+		"EXISTS": "NOT_EXISTS",
+		"BEGINS_WITH": null
+	};
+	Object.keys(obj).forEach((key) => {
+		const val = obj[key];
+		if (val) {
+			obj[val] = key;
+		} else {
+			obj[val] = null;
+		}
+	});
+	return obj;
+})();
+
+function finalizePending(instance) {
+	const pending = instance.settings.pending;
+
+	if (pending.not === true) {
+		if (notComparisonTypes[pending.type] === null) {
+			throw new Error.InvalidFilterComparison(`${pending.type} can not follow not()`);
+		}
+		pending.type = notComparisonTypes[pending.type];
+	}
+
+	instance.settings.conditions.push([pending.key, {
+		"type": pending.type,
+		"value": pending.value
+	}]);
+
+	instance.settings.pending = {};
+}
+
+Condition.prototype.parenthesis = Condition.prototype.group = function (value) {
+	value = typeof value === "function" ? value(new Condition()) : value;
+	this.settings.conditions.push(value.settings.conditions);
+	return this;
+};
+Condition.prototype.or = function() {
+	this.settings.conditions.push(OR);
+	return this;
+};
+Condition.prototype.and = function() { return this; };
+Condition.prototype.not = function() {
+	this.settings.pending.not = !this.settings.pending.not;
+	return this;
+};
+Condition.prototype.where = Condition.prototype.filter = Condition.prototype.attribute = function(key) {
+	this.settings.pending = {key};
+	return this;
+};
+const types = [
+	{"name": "eq", "typeName": "EQ"},
+	{"name": "lt", "typeName": "LT"},
+	{"name": "le", "typeName": "LE"},
+	{"name": "gt", "typeName": "GT"},
+	{"name": "ge", "typeName": "GE"},
+	{"name": "beginsWith", "typeName": "BEGINS_WITH"},
+	{"name": "contains", "typeName": "CONTAINS"},
+	{"name": "exists", "typeName": "EXISTS"},
+	{"name": "in", "typeName": "IN"},
+	{"name": "between", "typeName": "BETWEEN", "multipleArguments": true}
+];
+types.forEach((type) => {
+	Condition.prototype[type.name] = function(value) {
+		this.settings.pending.value = type.value || (type.multipleArguments ? [...arguments] : value);
+		this.settings.pending.type = type.typeName;
+		finalizePending(this);
+		return this;
+	};
+});
+
+Condition.prototype.requestObject = function() {
+	if (this.settings.conditions.length === 0) {
+		return {};
+	}
+
+	let index = 0;
+	function main(input) {
+		return input.reduce((object, entry, i, arr) => {
+			let expression = "";
+			if (Array.isArray(entry[0])) {
+				const result = main(entry);
+				const newData = utils.merge_objects.main({"combineMethod": "object_combine"})({...result}, {...object});
+				const returnObject = utils.object.pick(newData, ["ExpressionAttributeNames", "ExpressionAttributeValues"]);
+
+				expression = `(${result.ConditionExpression})`;
+				object = {...object, ...returnObject};
+			} else if (entry !== OR) {
+				const [key, condition] = entry;
+				const {value} = condition;
+				const keys = {"name": `#ca${index}`, "value": `:cv${index}`};
+				index++;
+
+				object.ExpressionAttributeNames[keys.name] = key;
+				object.ExpressionAttributeValues[keys.value] = Document.toDynamo(value, {"type": "value"});
+
+				switch (condition.type) {
+				case "EQ":
+				case "NE":
+					expression = `${keys.name} ${condition.type === "EQ" ? "=" : "<>"} ${keys.value}`;
+					break;
+				case "IN":
+					delete object.ExpressionAttributeValues[keys.value];
+					expression = `${keys.name} IN (${value.map((v, i) => `${keys.value}-${i + 1}`).join(", ")})`;
+					value.forEach((valueItem, i) => {
+						object.ExpressionAttributeValues[`${keys.value}-${i + 1}`] = Document.toDynamo(valueItem, {"type": "value"});
+					});
+					break;
+				case "GT":
+				case "GE":
+				case "LT":
+				case "LE":
+					expression = `${keys.name} ${condition.type.startsWith("G") ? ">" : "<"}${condition.type.endsWith("E") ? "=" : ""} ${keys.value}`;
+					break;
+				case "BETWEEN":
+					expression = `${keys.name} BETWEEN ${keys.value}-1 AND ${keys.value}-2`;
+					object.ExpressionAttributeValues[`${keys.value}-1`] = Document.toDynamo(value[0], {"type": "value"});
+					object.ExpressionAttributeValues[`${keys.value}-2`] = Document.toDynamo(value[1], {"type": "value"});
+					delete object.ExpressionAttributeValues[keys.value];
+					break;
+				case "CONTAINS":
+				case "NOT_CONTAINS":
+					expression = `${condition.type === "NOT_CONTAINS" ? "NOT " : ""}contains (${keys.name}, ${keys.value})`;
+					break;
+				case "EXISTS":
+				case "NOT_EXISTS":
+					expression = `attribute_${condition.type === "NOT_EXISTS" ? "not_" : ""}exists (${keys.name})`;
+					delete object.ExpressionAttributeValues[keys.value];
+					break;
+				case "BEGINS_WITH":
+					expression = `begins_with (${keys.name}, ${keys.value})`;
+					break;
+				}
+			} else {
+				return object;
+			}
+
+			object.ConditionExpression = `${object.ConditionExpression}${object.ConditionExpression !== "" ? ` ${arr[i - 1] === OR ? "OR" : "AND"} ` : ""}${expression}`;
+
+			return object;
+		}, {"ConditionExpression": "", "ExpressionAttributeNames": {}, "ExpressionAttributeValues": {}});
+	}
+	return main(this.settings.conditions);
+};
+
+module.exports = Condition;
diff --git a/lib/Model.js b/lib/Model.js
index b8b2e4d8..d88f89e1 100644
--- a/lib/Model.js
+++ b/lib/Model.js
@@ -561,7 +561,7 @@ Model.prototype.update = function (keyObj, updateObj, settings = {}, callback) {
 	const updateItemParamsPromise = this.pendingTaskPromise().then(async () => ({
 		"Key": this.Document.toDynamo(keyObj),
 		"ReturnValues": "ALL_NEW",
-		...await getUpdateExpressionObject(),
+		...utils.merge_objects.main({"combineMethod": "object_combine"})((settings.condition ? settings.condition.requestObject() : {}), await getUpdateExpressionObject()),
 		"TableName": this.name
 	}));
 	if (settings.return === "request") {
diff --git a/lib/index.js b/lib/index.js
index 75106428..e940febf 100644
--- a/lib/index.js
+++ b/lib/index.js
@@ -1,6 +1,7 @@
 module.exports = {
 	"Model": require("./Model"),
 	"Schema": require("./Schema"),
+	"Condition": require("./Condition"),
 	"transaction": require("./Transaction"),
 	"aws": require("./aws"),
 	"undefined": Symbol("dynamoose.undefined")
diff --git a/lib/utils/merge_objects.js b/lib/utils/merge_objects.js
index 289fae57..17b64bcd 100644
--- a/lib/utils/merge_objects.js
+++ b/lib/utils/merge_objects.js
@@ -1,5 +1,5 @@
 // This function is used to merge objects for combining multiple responses.
-module.exports = (...args) => {
+const main = (settings = {}) => (...args) => {
 	let returnObject;
 
 	args.forEach((arg, index) => {
@@ -16,13 +16,19 @@ module.exports = (...args) => {
 
 			Object.keys(arg).forEach((key) => {
 				if (typeof returnObject[key] === "object" && typeof arg[key] === "object" && !Array.isArray(returnObject[key]) && !Array.isArray(arg[key]) && returnObject[key] !== null) {
-					returnObject[key] = [returnObject[key], arg[key]];
+					if (settings.combineMethod === "object_combine") {
+						returnObject[key] = {...returnObject[key], ...arg[key]};
+					} else {
+						returnObject[key] = [returnObject[key], arg[key]];
+					}
 				} else if (Array.isArray(returnObject[key]) && Array.isArray(arg[key])) {
 					returnObject[key] = [...returnObject[key], ...arg[key]];
 				} else if (Array.isArray(returnObject[key])) {
 					returnObject[key] = [...returnObject[key], arg[key]];
-				} else {
+				} else if (returnObject[key]) {
 					returnObject[key] += arg[key];
+				} else {
+					returnObject[key] = arg[key];
 				}
 			});
 		}
@@ -30,3 +36,6 @@ module.exports = (...args) => {
 
 	return returnObject;
 };
+
+module.exports = main();
+module.exports.main = main;
diff --git a/lib/utils/object/entries.js b/lib/utils/object/entries.js
index 4312bfa7..0a8541f7 100644
--- a/lib/utils/object/entries.js
+++ b/lib/utils/object/entries.js
@@ -4,7 +4,7 @@ const main = module.exports = (object, existingKey = "") => {
 		const keyWithExisting = `${existingKey ? `${existingKey}.` : ""}${key}`;
 		accumulator.push([keyWithExisting, value]);
 
-		if (typeof value === "object") {
+		if (typeof value === "object" && value !== null) {
 			accumulator.push(...main(value, keyWithExisting));
 		}
 
diff --git a/lib/utils/object/index.js b/lib/utils/object/index.js
index cc7797d9..39e125b4 100644
--- a/lib/utils/object/index.js
+++ b/lib/utils/object/index.js
@@ -2,6 +2,7 @@ module.exports = {
 	"get": require("./get"),
 	"set": require("./set"),
 	"delete": require("./delete"),
+	"pick": require("./pick"),
 	"keys": require("./keys"),
 	"entries": require("./entries")
 };
diff --git a/lib/utils/object/pick.js b/lib/utils/object/pick.js
new file mode 100644
index 00000000..a5da24d6
--- /dev/null
+++ b/lib/utils/object/pick.js
@@ -0,0 +1,13 @@
+const get = require("./get");
+const set = require("./set");
+
+module.exports = (object, keys) => {
+	return keys.reduce((obj, key) => {
+		const value = get(object, key);
+		const isValueUndefined = typeof value === "undefined" || value === null;
+		if (!isValueUndefined) {
+			set(obj, key, value);
+		}
+		return obj;
+	}, {});
+};


Test patch:
diff --git a/test/Condition.js b/test/Condition.js
new file mode 100644
index 00000000..0cf50739
--- /dev/null
+++ b/test/Condition.js
@@ -0,0 +1,202 @@
+const chai = require("chai");
+const {expect} = chai;
+const dynamoose = require("../lib");
+const {Condition} = dynamoose;
+
+describe("Condition", () => {
+	it("Should be a function", () => {
+		expect(Condition).to.be.a("function");
+	});
+
+	it("Should return an object", () => {
+		expect(new Condition()).to.be.an("object");
+	});
+
+	describe("requestObject", () => {
+		it("Should be a function", () => {
+			expect(new Condition().requestObject).to.be.a("function");
+		});
+
+		const tests = [
+			{
+				"input": () => new Condition(),
+				"output": {}
+			},
+			{
+				"input": () => new Condition("id").eq("5"),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition({"id": "5"}),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition({"id": {"eq": "5"}}),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition({"id": {"lt": "5"}}),
+				"output": {"ConditionExpression": "#ca0 < :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			// TODO: fix the test below
+			// {
+			// 	"input": () => new Condition({"id": {"ne": "5"}}),
+			// 	"output": {"ConditionExpression": "#ca0 <> :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			// },
+			{
+				"input": () => new Condition({"id": {"random": "5"}}),
+				"error": "The type: random is invalid."
+			},
+			{
+				"input": () => new Condition().group(new Condition("id").eq("5")),
+				"output": {"ConditionExpression": "(#ca0 = :cv0)", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().parenthesis(new Condition("id").eq("5")),
+				"output": {"ConditionExpression": "(#ca0 = :cv0)", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().where("id").eq("5")),
+				"output": {"ConditionExpression": "(#ca0 = :cv0)", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().group(new Condition().group(new Condition().where("id").eq("5")))),
+				"output": {"ConditionExpression": "(((#ca0 = :cv0)))", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().group((condition) => condition.where("id").eq("5")),
+				"output": {"ConditionExpression": "(#ca0 = :cv0)", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().where("id").eq("5")).and().group(new Condition().where("name").eq("Charlie")),
+				"output": {"ConditionExpression": "(#ca0 = :cv0) AND (#ca1 = :cv1)", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().where("id").eq("5").group(new Condition().where("name").eq("Charlie"))),
+				"output": {"ConditionExpression": "(#ca0 = :cv0 AND (#ca1 = :cv1))", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().where("id").eq("5").group(new Condition().where("name").eq("Charlie").and().where("power").eq(10))),
+				"output": {"ConditionExpression": "(#ca0 = :cv0 AND (#ca1 = :cv1 AND #ca2 = :cv2))", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name", "#ca2": "power"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}, ":cv2": {"N": "10"}}}
+			},
+			{
+				"input": () => new Condition().group(new Condition().where("id").eq("5").or().group(new Condition().where("name").eq("Charlie").and().where("power").eq(10))),
+				"output": {"ConditionExpression": "(#ca0 = :cv0 OR (#ca1 = :cv1 AND #ca2 = :cv2))", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name", "#ca2": "power"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}, ":cv2": {"N": "10"}}}
+			},
+			{
+				"input": () => new Condition().where("id").eq("5").or().where("name").eq("Charlie"),
+				"output": {"ConditionExpression": "#ca0 = :cv0 OR #ca1 = :cv1", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}}}
+			},
+			{
+				"input": () => new Condition().where("id").eq("5"),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().filter("id").eq("5"),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().attribute("id").eq("5"),
+				"output": {"ConditionExpression": "#ca0 = :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("id").not().eq("5"),
+				"output": {"ConditionExpression": "#ca0 <> :cv0", "ExpressionAttributeNames": {"#ca0": "id"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("id").not().eq("5").and().where("name").eq("Charlie"),
+				"output": {"ConditionExpression": "#ca0 <> :cv0 AND #ca1 = :cv1", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}}}
+			},
+			{
+				"input": () => new Condition().where("id").eq("5").and().where("name").eq("Charlie"),
+				"output": {"ConditionExpression": "#ca0 = :cv0 AND #ca1 = :cv1", "ExpressionAttributeNames": {"#ca0": "id", "#ca1": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "5"}, ":cv1": {"S": "Charlie"}}}
+			},
+			{
+				"input": () => new Condition().where("age").lt(5),
+				"output": {"ConditionExpression": "#ca0 < :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").not().lt(5),
+				"output": {"ConditionExpression": "#ca0 >= :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").le(5),
+				"output": {"ConditionExpression": "#ca0 <= :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").not().le(5),
+				"output": {"ConditionExpression": "#ca0 > :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").gt(5),
+				"output": {"ConditionExpression": "#ca0 > :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").not().gt(5),
+				"output": {"ConditionExpression": "#ca0 <= :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").ge(5),
+				"output": {"ConditionExpression": "#ca0 >= :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("age").not().ge(5),
+				"output": {"ConditionExpression": "#ca0 < :cv0", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0": {"N": "5"}}}
+			},
+			{
+				"input": () => new Condition().where("name").beginsWith("C"),
+				"output": {"ConditionExpression": "begins_with (#ca0, :cv0)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "C"}}}
+			},
+			{
+				"input": () => new Condition().where("name").not().beginsWith("C"),
+				"error": "BEGINS_WITH can not follow not()"
+			},
+			{
+				"input": () => new Condition().where("name").contains("C"),
+				"output": {"ConditionExpression": "contains (#ca0, :cv0)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "C"}}}
+			},
+			{
+				"input": () => new Condition().where("name").not().contains("C"),
+				"output": {"ConditionExpression": "NOT contains (#ca0, :cv0)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {":cv0": {"S": "C"}}}
+			},
+			{
+				"input": () => new Condition().where("name").exists(),
+				"output": {"ConditionExpression": "attribute_exists (#ca0)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {}}
+			},
+			{
+				"input": () => new Condition().where("name").not().exists(),
+				"output": {"ConditionExpression": "attribute_not_exists (#ca0)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {}}
+			},
+			{
+				"input": () => new Condition().where("name").in(["Charlie", "Bob"]),
+				"output": {"ConditionExpression": "#ca0 IN (:cv0-1, :cv0-2)", "ExpressionAttributeNames": {"#ca0": "name"}, "ExpressionAttributeValues": {":cv0-1": {"S": "Charlie"}, ":cv0-2": {"S": "Bob"}}}
+			},
+			{
+				"input": () => new Condition().where("name").not().in(["Charlie", "Bob"]),
+				"error": "IN can not follow not()"
+			},
+			{
+				"input": () => new Condition().where("age").between(13, 18),
+				"output": {"ConditionExpression": "#ca0 BETWEEN :cv0-1 AND :cv0-2", "ExpressionAttributeNames": {"#ca0": "age"}, "ExpressionAttributeValues": {":cv0-1": {"N": "13"}, ":cv0-2": {"N": "18"}}}
+			},
+			{
+				"input": () => new Condition().where("age").not().between(13, 18),
+				"error": "BETWEEN can not follow not()"
+			},
+			{
+				"input": () => new Condition().where("userID").eq("1").and().where("userID").exists(),
+				"output": {"ConditionExpression": "#ca0 = :cv0 AND attribute_exists (#ca1)", "ExpressionAttributeNames": {"#ca0": "userID", "#ca1": "userID"}, "ExpressionAttributeValues": {":cv0": {"S": "1"}}}
+			},
+		];
+
+		tests.forEach((test) => {
+			it(`Should ${test.error ? "throw" : "return"} ${JSON.stringify(test.error || test.output)} for ${JSON.stringify(test.input)}`, () => {
+				if (test.error) {
+					expect(() => test.input().requestObject()).to.throw(test.error);
+				} else {
+					expect(test.input().requestObject()).to.eql(test.output);
+				}
+			});
+		});
+	});
+});
diff --git a/test/Document.js b/test/Document.js
index 78e3393f..1e90cb5a 100644
--- a/test/Document.js
+++ b/test/Document.js
@@ -12,7 +12,7 @@ const Internal = require("../lib/Internal");
 
 describe("Document", () => {
 	it("Should be a function", () => {
-		expect(Document).to.be.an("function");
+		expect(Document).to.be.a("function");
 	});
 
 	it("Should not have internalProperties if use spread operator on object", () => {
diff --git a/test/Model.js b/test/Model.js
index 3ea66232..2104318a 100644
--- a/test/Model.js
+++ b/test/Model.js
@@ -2281,6 +2281,37 @@ describe("Model", () => {
 					expect(parseInt(updateItemParams.ExpressionAttributeValues[":v0"].N)).to.be.within(date - 10, date + 10);
 				});
 
+				it("Should send correct params to updateItem with conditional", async () => {
+					updateItemFunction = () => Promise.resolve({});
+					User = new dynamoose.Model("User", new dynamoose.Schema({"id": Number, "name": String, "active": Boolean}));
+					const condition = new dynamoose.Condition("active").eq(true);
+					await callType.func(User).bind(User)({"id": 1}, {"name": "Charlie"}, {condition});
+					expect(updateItemParams).to.be.an("object");
+					expect(updateItemParams).to.eql({
+						"ExpressionAttributeNames": {
+							"#a0": "name",
+							"#ca0": "active"
+						},
+						"ExpressionAttributeValues": {
+							":v0": {
+								"S": "Charlie"
+							},
+							":cv0": {
+								"BOOL": true
+							}
+						},
+						"UpdateExpression": "SET #a0 = :v0",
+						"ConditionExpression": "#ca0 = :cv0",
+						"Key": {
+							"id": {
+								"N": "1"
+							}
+						},
+						"TableName": "User",
+						"ReturnValues": "ALL_NEW"
+					});
+				});
+
 				it("Should return updated document upon success", async () => {
 					updateItemFunction = () => Promise.resolve({"Attributes": {"id": {"N": "1"}, "name": {"S": "Charlie"}}});
 					const result = await callType.func(User).bind(User)({"id": 1, "name": "Charlie"});
@@ -2917,9 +2948,22 @@ describe("Model", () => {
 				});
 			});
 
+			it("Should return correct result with overwrite set to true", async () => {
+				expect(await User.transaction.create({"id": 1}, {"overwrite": true})).to.eql({
+					"Put": {
+						"Item": {"id": {"N": "1"}},
+						"TableName": "User"
+					}
+				});
+			});
+
 			it("Should return correct result with overwrite set to false", async () => {
 				expect(await User.transaction.create({"id": 1}, {"overwrite": false})).to.eql({
 					"Put": {
+						"ConditionExpression": "attribute_not_exists(#__hash_key)",
+						"ExpressionAttributeNames": {
+							"#__hash_key": "id"
+						},
 						"Item": {"id": {"N": "1"}},
 						"TableName": "User"
 					}
diff --git a/test/utils/merge_objects.js b/test/utils/merge_objects.js
index ea68551c..643f325a 100644
--- a/test/utils/merge_objects.js
+++ b/test/utils/merge_objects.js
@@ -23,6 +23,21 @@ describe("utils.merge_objects", () => {
 			"input": [{"a": [{"b": "c"}, {"d": "e"}]}, {"a": [{"f": "g"}, {"h": "i"}]}, {"a": {"j": "k"}}],
 			"output": {"a": [{"b": "c"}, {"d": "e"}, {"f": "g"}, {"h": "i"}, {"j": "k"}]}
 		},
+		{
+			"input": [{"a": {"b": "c"}}, {"a": {"d": "e"}}],
+			"output": {"a": {"b": "c", "d": "e"}},
+			"main": {"combineMethod": "object_combine"}
+		},
+		{
+			"input": [{"a": {"d": "c"}}, {"a": {"d": "e"}}],
+			"output": {"a": {"d": "e"}},
+			"main": {"combineMethod": "object_combine"}
+		},
+		{
+			"input": [{"a": {"d": "c"}}, {"a": {"z": "e", "d": "b"}}, {"a": {"z": "y"}}],
+			"output": {"a": {"d": "b", "z": "y"}},
+			"main": {"combineMethod": "object_combine"}
+		},
 		{
 			"input": [[1, 2], {"a": "b"}],
 			"error": "You can't mix value types for the merge_objects method."
@@ -38,13 +53,14 @@ describe("utils.merge_objects", () => {
 	];
 
 	tests.forEach((test) => {
+		const func = test.main ? utils.merge_objects.main(test.main) : utils.merge_objects;
 		if (test.error) {
 			it(`Should throw error for ${JSON.stringify(test.input)}`, () => {
-				expect(() => utils.merge_objects(...test.input)).to.throw(test.error);
+				expect(() => func(...test.input)).to.throw(test.error);
 			});
 		} else {
 			it(`Should return ${JSON.stringify(test.output)} for ${JSON.stringify(test.input)}`, () => {
-				expect(utils.merge_objects(...test.input)).to.eql(test.output);
+				expect(func(...test.input)).to.eql(test.output);
 			});
 		}
 	});
diff --git a/test/utils/object/entries.js b/test/utils/object/entries.js
index aa875ffb..0fb39006 100644
--- a/test/utils/object/entries.js
+++ b/test/utils/object/entries.js
@@ -11,6 +11,14 @@ describe("utils.object.entries", () => {
 			"input": {"hello": "world"},
 			"output": [["hello", "world"]]
 		},
+		{
+			"input": {"hello": undefined},
+			"output": [["hello", undefined]]
+		},
+		{
+			"input": {"hello": null},
+			"output": [["hello", null]]
+		},
 		{
 			"input": {"hello": "world", "test": "data"},
 			"output": [["hello", "world"], ["test", "data"]]
diff --git a/test/utils/object/pick.js b/test/utils/object/pick.js
new file mode 100644
index 00000000..c9fcda2c
--- /dev/null
+++ b/test/utils/object/pick.js
@@ -0,0 +1,29 @@
+const {expect} = require("chai");
+const utils = require("../../../lib/utils");
+
+describe("utils.object.pick", () => {
+	it("Should be a function", () => {
+		expect(utils.object.pick).to.be.a("function");
+	});
+
+	const tests = [
+		{
+			"input": [{"hello": "world"}, ["hello"]],
+			"output": {"hello": "world"}
+		},
+		{
+			"input": [{"hello": "world"}, ["hello", "world"]],
+			"output": {"hello": "world"}
+		},
+		{
+			"input": [{"hello": "world", "obj": 1}, ["hello"]],
+			"output": {"hello": "world"}
+		},
+	];
+
+	tests.forEach((test) => {
+		it(`Should return ${test.output} for ${test.input}`, () => {
+			expect(utils.object.pick(...test.input)).to.eql(test.output);
+		});
+	});
+});