Repository: dynamoosejs/dynamoose
Pull request #799 (2020-04-12 01:52:06)

Condition raw item
This PR adds functionality for passing a raw object into a Condition initializer.


Patch:
diff --git a/docs/api/Condition.md b/docs/api/Condition.md
index b4f42955..6b5ec13b 100644
--- a/docs/api/Condition.md
+++ b/docs/api/Condition.md
@@ -4,7 +4,7 @@ The Condition object represents a conditional that you can attach to various set
 
 ## 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.
+This is the basic entry point to construct a conditional. The filter property is optional and can either be an object, existing conditional instance 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`
@@ -12,7 +12,30 @@ new dynamoose.Condition({"breed": {"contains": "Terrier"}}) // will condition fo
 new dynamoose.Condition(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.
+For a more advanced use case you pass an object into `new dynamoose.Condition()` the object for each key should contain the comparison type. For example, in the second to last example above, `contains` was our comparison type. This comparison type must match one of the comparison type functions listed on this page.
+
+You can also pass in a raw DynamoDB condition object. Which has properties for `ExpressionAttributeNames`, `ExpressionAttributeValues` & either `FilterExpression` or `ComparisonExpression`. In the event you do this, all future condition methods called on this condition instance will be ignored. In the event you don't pass in DynamoDB objects for the `ExpressionAttributeValues` values, Dynamoose will automatically convert them to DynamoDB compatible objects to make the request.
+
+```js
+new dynamoose.Condition({
+	"FilterExpression": "#id = :id",
+	"ExpressionAttributeValues": {
+		":id": 100
+	},
+	"ExpressionAttributeNames": {
+		"#id": "id"
+	}
+})
+new dynamoose.Condition({
+	"FilterExpression": "#id = :id",
+	"ExpressionAttributeValues": {
+		":id": {"N": 100}
+	},
+	"ExpressionAttributeNames": {
+		"#id": "id"
+	}
+})
+```
 
 ## condition.and()
 
diff --git a/lib/Condition.js b/lib/Condition.js
index 23b42960..f61d2e06 100644
--- a/lib/Condition.js
+++ b/lib/Condition.js
@@ -3,6 +3,8 @@ const Error = require("./Error");
 const utils = require("./utils");
 const OR = Symbol("OR");
 
+const isRawConditionObject = (object) => Object.keys(object).length === 3 && ["ExpressionAttributeValues", "ExpressionAttributeNames"].every((item) => Boolean(object[item]) && typeof object[item] === "object");
+
 class Condition {
 	constructor(object) {
 		if (object instanceof Condition) {
@@ -16,21 +18,24 @@ class Condition {
 			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 (!isRawConditionObject(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.`);
-					}
+						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}]);
-				});
+						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;
 			}
 		}
+		this.settings.raw = object;
 
 		return this;
 	}
@@ -97,7 +102,17 @@ types.forEach((type) => {
 });
 
 Condition.prototype.requestObject = function(settings = {"conditionString": "ConditionExpression"}) {
-	if (this.settings.conditions.length === 0) {
+	if (this.settings.raw && utils.object.equals(Object.keys(this.settings.raw).sort(), [settings.conditionString, "ExpressionAttributeValues", "ExpressionAttributeNames"].sort())) {
+		return Object.entries(this.settings.raw.ExpressionAttributeValues).reduce((obj, entry) => {
+			const [key, value] = entry;
+			// TODO: we should fix this so that we can do `isDynamoItem(value)`
+			if (!Document.isDynamoObject({"key": value})) {
+				console.log(value, Object.keys(value));
+				obj.ExpressionAttributeValues[key] = Document.toDynamo(value, {"type": "value"});
+			}
+			return obj;
+		}, this.settings.raw);
+	} else if (this.settings.conditions.length === 0) {
 		return {};
 	}
 
@@ -129,9 +144,9 @@ Condition.prototype.requestObject = function(settings = {"conditionString": "Con
 					break;
 				case "IN":
 					delete object.ExpressionAttributeValues[keys.value];
-					expression = `${keys.name} IN (${value.map((v, i) => `${keys.value}-${i + 1}`).join(", ")})`;
+					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"});
+						object.ExpressionAttributeValues[`${keys.value}_${i + 1}`] = Document.toDynamo(valueItem, {"type": "value"});
 					});
 					break;
 				case "GT":
@@ -141,9 +156,9 @@ Condition.prototype.requestObject = function(settings = {"conditionString": "Con
 					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"});
+					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":
diff --git a/lib/Document.js b/lib/Document.js
index 8559ec45..04493f0d 100644
--- a/lib/Document.js
+++ b/lib/Document.js
@@ -5,7 +5,27 @@ const {internalProperties} = require("./Internal").General;
 
 const staticMethods = {
 	"toDynamo": (object, settings = {"type": "object"}) => (settings.type === "value" ? aws.converter().input : aws.converter().marshall)(object),
-	"fromDynamo": (object) => aws.converter().unmarshall(object)
+	"fromDynamo": (object) => aws.converter().unmarshall(object),
+	// This function will return null if it's unknown if it is a Dynamo object (ex. empty object). It will return true if it is a Dynamo object and false if it's not.
+	"isDynamoObject": function (object, recurrsive = false) {
+		// This function will check to see if a nested object is valid by calling Document.isDynamoObject recursively
+		const isValid = (value) => {
+			const keys = Object.keys(value);
+			const key = keys[0];
+			const nestedResult = (typeof value[key] === "object" && !(value[key] instanceof Buffer) ? (Array.isArray(value[key]) ? value[key].every((value) => this.isDynamoObject(value, true)) : this.isDynamoObject(value[key])) : true);
+			const Schema = require("./Schema");
+			const attributeType = Schema.attributeTypes.findDynamoDBType(key);
+			return typeof value === "object" && keys.length === 1 && attributeType && (nestedResult || Object.keys(value[key]).length === 0 || attributeType.isSet);
+		};
+
+		const keys = Object.keys(object);
+		const values = Object.values(object);
+		if (keys.length === 0) {
+			return null;
+		} else {
+			return recurrsive ? isValid(object) : values.every((value) => isValid(value));
+		}
+	}
 };
 function applyStaticMethods(item) {
 	Object.entries(staticMethods).forEach((entry) => {
@@ -37,26 +57,6 @@ function DocumentCarrier(model) {
 	}
 
 	applyStaticMethods(Document);
-	// This function will return null if it's unknown if it is a Dynamo object (ex. empty object). It will return true if it is a Dynamo object and false if it's not.
-	Document.isDynamoObject = (object, recurrsive = false) => {
-		// This function will check to see if a nested object is valid by calling Document.isDynamoObject recursively
-		function isValid(value) {
-			const keys = Object.keys(value);
-			const key = keys[0];
-			const nestedResult = (typeof value[key] === "object" && !(value[key] instanceof Buffer) ? (Array.isArray(value[key]) ? value[key].every((value) => Document.isDynamoObject(value, true)) : Document.isDynamoObject(value[key])) : true);
-			const Schema = require("./Schema");
-			const attributeType = Schema.attributeTypes.findDynamoDBType(key);
-			return typeof value === "object" && keys.length === 1 && attributeType && (nestedResult || Object.keys(value[key]).length === 0 || attributeType.isSet);
-		}
-
-		const keys = Object.keys(object);
-		const values = Object.values(object);
-		if (keys.length === 0) {
-			return null;
-		} else {
-			return recurrsive ? isValid(object) : values.every((value) => isValid(value));
-		}
-	};
 	// This function will mutate the object passed in to run any actions to conform to the schema that cannot be achieved through non mutating methods in Document.objectFromSchema (setting timestamps, etc.)
 	Document.prepareForObjectFromSchema = function(object, settings) {
 		if (settings.updateTimestamps) {
diff --git a/lib/DocumentRetriever.js b/lib/DocumentRetriever.js
index d4e9c1a9..5a0fb547 100644
--- a/lib/DocumentRetriever.js
+++ b/lib/DocumentRetriever.js
@@ -2,6 +2,7 @@ const aws = require("./aws");
 const Error = require("./Error");
 const utils = require("./utils");
 const Condition = require("./Condition");
+const Document = require("./Document");
 
 // DocumentRetriever is used for both Scan and Query since a lot of the code is shared between the two
 
@@ -52,7 +53,7 @@ function main(documentRetrieverTypeString) {
 				object.Limit = this.settings.limit;
 			}
 			if (this.settings.startAt) {
-				object.ExclusiveStartKey = model.Document.isDynamoObject(this.settings.startAt) ? this.settings.startAt : model.Document.toDynamo(this.settings.startAt);
+				object.ExclusiveStartKey = Document.isDynamoObject(this.settings.startAt) ? this.settings.startAt : model.Document.toDynamo(this.settings.startAt);
 			}
 			if (this.settings.attributes) {
 				object.AttributesToGet = this.settings.attributes;
diff --git a/lib/utils/object/equals.js b/lib/utils/object/equals.js
new file mode 100644
index 00000000..f1c5517c
--- /dev/null
+++ b/lib/utils/object/equals.js
@@ -0,0 +1,15 @@
+const entries = require("./entries");
+
+module.exports = (a, b) => {
+	const aEntries = entries(a);
+	const bEntries = entries(b);
+	const bEntriesMap = bEntries.reduce((res, value) => {
+		const [key, val] = value;
+		res[key] = val;
+		return res;
+	}, {});
+
+	return aEntries.length === bEntries.length && aEntries.every((entry) => {
+		return typeof entry[1] === "object" || bEntriesMap[entry[0]] === entry[1];
+	});
+};
diff --git a/lib/utils/object/index.js b/lib/utils/object/index.js
index 39e125b4..42579b02 100644
--- a/lib/utils/object/index.js
+++ b/lib/utils/object/index.js
@@ -4,5 +4,6 @@ module.exports = {
 	"delete": require("./delete"),
 	"pick": require("./pick"),
 	"keys": require("./keys"),
-	"entries": require("./entries")
+	"entries": require("./entries"),
+	"equals": require("./equals")
 };


Test patch:
diff --git a/test/Condition.js b/test/Condition.js
index 28d86033..89fad13e 100644
--- a/test/Condition.js
+++ b/test/Condition.js
@@ -173,7 +173,7 @@ describe("Condition", () => {
 			},
 			{
 				"input": () => new Condition().where("name").in(["Charlie", "Bob"]),
-				"output": {"ConditionExpression": "#a0 IN (:v0-1, :v0-2)", "ExpressionAttributeNames": {"#a0": "name"}, "ExpressionAttributeValues": {":v0-1": {"S": "Charlie"}, ":v0-2": {"S": "Bob"}}}
+				"output": {"ConditionExpression": "#a0 IN (:v0_1, :v0_2)", "ExpressionAttributeNames": {"#a0": "name"}, "ExpressionAttributeValues": {":v0_1": {"S": "Charlie"}, ":v0_2": {"S": "Bob"}}}
 			},
 			{
 				"input": () => new Condition().where("name").not().in(["Charlie", "Bob"]),
@@ -181,7 +181,7 @@ describe("Condition", () => {
 			},
 			{
 				"input": () => new Condition().where("age").between(13, 18),
-				"output": {"ConditionExpression": "#a0 BETWEEN :v0-1 AND :v0-2", "ExpressionAttributeNames": {"#a0": "age"}, "ExpressionAttributeValues": {":v0-1": {"N": "13"}, ":v0-2": {"N": "18"}}}
+				"output": {"ConditionExpression": "#a0 BETWEEN :v0_1 AND :v0_2", "ExpressionAttributeNames": {"#a0": "age"}, "ExpressionAttributeValues": {":v0_1": {"N": "13"}, ":v0_2": {"N": "18"}}}
 			},
 			{
 				"input": () => new Condition().where("age").not().between(13, 18),
@@ -191,14 +191,41 @@ describe("Condition", () => {
 				"input": () => new Condition().where("userID").eq("1").and().where("userID").exists(),
 				"output": {"ConditionExpression": "#a0 = :v0 AND attribute_exists (#a1)", "ExpressionAttributeNames": {"#a0": "userID", "#a1": "userID"}, "ExpressionAttributeValues": {":v0": {"S": "1"}}}
 			},
+			{
+				"input": () => new Condition({"FilterExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"settings": {"conditionString": "FilterExpression"},
+				"output": {"FilterExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}
+			},
+			{
+				"input": () => new Condition({"FilterExpression": "#id = :id", "ExpressionAttributeValues": {":id": "5"}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"settings": {"conditionString": "FilterExpression"},
+				"output": {"FilterExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}
+			},
+			{
+				"input": () => new Condition({"FilterExpression": "#id = :id", "ExpressionAttributeValues": {":id": "5"}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"output": {}
+			},
+			{
+				"input": () => new Condition({"ConditionExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"output": {"ConditionExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}
+			},
+			{
+				"input": () => new Condition({"ConditionExpression": "#id = :id", "ExpressionAttributeValues": {":id": "5"}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"output": {"ConditionExpression": "#id = :id", "ExpressionAttributeValues": {":id": {"S": "5"}}, "ExpressionAttributeNames": {"#id": "id"}}
+			},
+			{
+				"input": () => new Condition({"ConditionExpression": "#id = :id", "ExpressionAttributeValues": {":id": "5"}, "ExpressionAttributeNames": {"#id": "id"}}),
+				"settings": {"conditionString": "FilterExpression"},
+				"output": {}
+			},
 		];
 
 		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);
+					expect(() => test.input().requestObject(test.settings)).to.throw(test.error);
 				} else {
-					expect(test.input().requestObject()).to.eql(test.output);
+					expect(test.input().requestObject(test.settings)).to.eql(test.output);
 				}
 			});
 		});
diff --git a/test/Query.js b/test/Query.js
index 0550fdce..89de8bb9 100644
--- a/test/Query.js
+++ b/test/Query.js
@@ -274,14 +274,21 @@ describe("Query", () => {
 						},
 						"ExpressionAttributeValues": {
 							":qhv": {"S": "Charlie"},
-							":v1-1": {"N": "1"},
-							":v1-2": {"N": "3"}
+							":v1_1": {"N": "1"},
+							":v1_2": {"N": "3"}
 						},
-						"FilterExpression": "#a1 BETWEEN :v1-1 AND :v1-2",
+						"FilterExpression": "#a1 BETWEEN :v1_1 AND :v1_2",
 						"KeyConditionExpression": "#qha = :qhv"
 					});
 				});
 
+				it("Should not include - in filter expression", async () => {
+					queryPromiseResolver = () => ({"Items": []});
+					const query = Model.query("name").eq("Charlie").filter("id").between(1, 3);
+					await callType.func(query.exec).bind(query)();
+					expect(queryParams.FilterExpression).to.not.include("-");
+				});
+
 				it("Should send correct request on query.exec with query condition", async () => {
 					queryPromiseResolver = () => ({"Items": []});
 					Model = new dynamoose.Model("Cat", {"id": Number, "name": {"type": String, "index": {"global": true, "rangeKey": "age"}}, "age": Number});
@@ -835,10 +842,10 @@ describe("Query", () => {
 				},
 				"ExpressionAttributeValues": {
 					":qhv": {"S": "Charlie"},
-					":v1-1": {"N": "10"},
-					":v1-2": {"N": "20"}
+					":v1_1": {"N": "10"},
+					":v1_2": {"N": "20"}
 				},
-				"FilterExpression": "#a1 IN (:v1-1, :v1-2)",
+				"FilterExpression": "#a1 IN (:v1_1, :v1_2)",
 				"KeyConditionExpression": "#qha = :qhv"
 			});
 		});
@@ -855,12 +862,12 @@ describe("Query", () => {
 				},
 				"ExpressionAttributeValues": {
 					":qhv": {"S": "Charlie"},
-					":v1-1": {"N": "10"},
-					":v1-2": {"N": "20"},
-					":v1-3": {"N": "30"},
-					":v1-4": {"N": "40"}
+					":v1_1": {"N": "10"},
+					":v1_2": {"N": "20"},
+					":v1_3": {"N": "30"},
+					":v1_4": {"N": "40"}
 				},
-				"FilterExpression": "#a1 IN (:v1-1, :v1-2, :v1-3, :v1-4)",
+				"FilterExpression": "#a1 IN (:v1_1, :v1_2, :v1_3, :v1_4)",
 				"KeyConditionExpression": "#qha = :qhv"
 			});
 		});
diff --git a/test/Scan.js b/test/Scan.js
index 441138b0..4295336b 100644
--- a/test/Scan.js
+++ b/test/Scan.js
@@ -190,14 +190,21 @@ describe("Scan", () => {
 							"#a0": "id"
 						},
 						"ExpressionAttributeValues": {
-							":v0-1": {"N": "1"},
-							":v0-2": {"N": "3"}
+							":v0_1": {"N": "1"},
+							":v0_2": {"N": "3"}
 						},
-						"FilterExpression": "#a0 BETWEEN :v0-1 AND :v0-2",
+						"FilterExpression": "#a0 BETWEEN :v0_1 AND :v0_2",
 						"TableName": "Cat"
 					});
 				});
 
+				it("Should not include - in filter expression", async () => {
+					scanPromiseResolver = () => ({"Items": []});
+					const scan = Model.scan().filter("id").between(1, 3);
+					await callType.func(scan.exec).bind(scan)();
+					expect(scanParams.FilterExpression).to.not.include("-");
+				});
+
 				it("Should return correct result for get function on attribute", async () => {
 					Model = new dynamoose.Model("Cat", new dynamoose.Schema({"id": Number, "name": {"type": String, "get": (val) => `${val}-get`}}));
 					scanPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}}]});
diff --git a/test/utils/object/equals.js b/test/utils/object/equals.js
new file mode 100644
index 00000000..00cf40ea
--- /dev/null
+++ b/test/utils/object/equals.js
@@ -0,0 +1,57 @@
+const {expect} = require("chai");
+const utils = require("../../../lib/utils");
+
+describe("utils.object.equals", () => {
+	it("Should be a function", () => {
+		expect(utils.object.equals).to.be.a("function");
+	});
+
+	const tests = [
+		{
+			"input": [{"hello": "world"}, {"hello": "world"}],
+			"output": true
+		},
+		{
+			"input": [{"hello": "world"}, {"hello": "universe"}],
+			"output": false
+		},
+		{
+			"input": [{"hello": "universe"}, {"hello": "world"}],
+			"output": false
+		},
+		{
+			"input": [{"hello": {"item": "world"}}, {"hello": "world"}],
+			"output": false
+		},
+		{
+			"input": [{"hello": {"item": "world"}}, {"hello": {"item": "universe"}}],
+			"output": false
+		},
+		{
+			"input": [{"hello": {"item": "world"}}, {"hello": {"item": "world"}}],
+			"output": true
+		},
+		{
+			"input": [[1, 2, 3], [1, 2, 3]],
+			"output": true
+		},
+		{
+			"input": [[2, 1, 3], [1, 2, 3]],
+			"output": false
+		},
+		{
+			"input": [[2, 1], [2, 1, 3]],
+			"output": false
+		},
+		{
+			"input": [[2, 1, 3], [2, 1]],
+			"output": false
+		}
+	];
+
+	tests.forEach((test) => {
+		it(`Should return ${test.output} for ${JSON.stringify(test.input)}`, () => {
+			expect(utils.object.equals(...test.input)).to.eql(test.output);
+		});
+	});
+});