Repository: dynamoosejs/dynamoose
Pull request #736 (2020-03-19 23:53:50)

set & get Schema Attribute Settings


Patch:
diff --git a/docs/api/Schema.md b/docs/api/Schema.md
index fe7313f8..1030884c 100644
--- a/docs/api/Schema.md
+++ b/docs/api/Schema.md
@@ -266,6 +266,32 @@ You can set an attribute to have an enum array, which means it must match one of
 }
 ```
 
+### get: function | async function
+
+You can use a get function on an attribute to be run whenever retrieving a document from DynamoDB. This function will only be run if the item exists in the document. Dynamoose will pass the DynamoDB value into this function and you must return the new value that you want Dynamoose to return to the application.
+
+```js
+{
+	"id": {
+		"type": String,
+		"get": (value) => `applicationid-${value}` // This will prepend `applicationid-` to all values for this attribute when returning from the database
+	}
+}
+```
+
+### set: function | async function
+
+You can use a set function on an attribute to be run whenever saving a document to DynamoDB. This function will only be run if the item exists in the document. Dynamoose will pass the value you provide into this function and you must return the new value that you want Dynamoose to save to DynamoDB.
+
+```js
+{
+	"name": {
+		"type": String,
+		"set": (value) => `${value.charAt(0).toUpperCase()}${value.slice(1)}` // Capitalize first letter of name when saving to database
+	}
+}
+```
+
 ### index: boolean | object | array
 
 You can define indexes on properties to be created or updated upon model initialization. If you pass in an array for the value of this setting it must be an array of index objects. By default no indexes are specified on the attribute.
diff --git a/lib/Document.js b/lib/Document.js
index b2b42067..3850fd27 100644
--- a/lib/Document.js
+++ b/lib/Document.js
@@ -201,6 +201,17 @@ function DocumentCarrier(model) {
 				}
 			}));
 		}
+		if (settings.modifiers) {
+			await Promise.all(settings.modifiers.map((modifier) => {
+				return Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
+					const value = utils.object.get(returnObject, key);
+					const modifierFunction = await model.schema.getAttributeSettingValue(modifier, key, {"returnFunction": true});
+					if (modifierFunction && value) {
+						utils.object.set(returnObject, key, await modifierFunction(value));
+					}
+				}));
+			}));
+		}
 
 		return returnObject;
 	};
@@ -216,7 +227,7 @@ function DocumentCarrier(model) {
 			settings = {};
 		}
 
-		const paramsPromise = this.toDynamo({"defaults": true, "validate": true, "required": true, "enum": true, "forceDefault": true, "saveUnknown": true, "customTypesDynamo": true, "updateTimestamps": true}).then((item) => {
+		const paramsPromise = this.toDynamo({"defaults": true, "validate": true, "required": true, "enum": true, "forceDefault": true, "saveUnknown": true, "customTypesDynamo": true, "updateTimestamps": true, "modifiers": ["set"]}).then((item) => {
 			const putItemObj = {
 				"Item": item,
 				"TableName": Document.Model.name
diff --git a/lib/DocumentRetriever.js b/lib/DocumentRetriever.js
index 56fe353f..e6950022 100644
--- a/lib/DocumentRetriever.js
+++ b/lib/DocumentRetriever.js
@@ -210,7 +210,7 @@ function main(documentRetrieverTypeString) {
 						[`${documentRetrieverType.pastTense}Count`]: result[`${utils.capitalize_first_letter(documentRetrieverType.pastTense)}Count`]
 					};
 				}
-				const array = (await Promise.all(result.Items.map(async (item) => await ((new model.Document(item, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "saveUnknown": true, "type": "fromDynamo"}))))).filter((a) => Boolean(a));
+				const array = (await Promise.all(result.Items.map(async (item) => await ((new model.Document(item, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "saveUnknown": true, "modifiers": ["get"], "type": "fromDynamo"}))))).filter((a) => Boolean(a));
 				array.lastKey = result.LastEvaluatedKey ? (Array.isArray(result.LastEvaluatedKey) ? result.LastEvaluatedKey.map((key) => model.Document.fromDynamo(key)) : model.Document.fromDynamo(result.LastEvaluatedKey)) : undefined;
 				array.count = result.Count;
 				array[`${documentRetrieverType.pastTense}Count`] = result[`${utils.capitalize_first_letter(documentRetrieverType.pastTense)}Count`];
diff --git a/lib/Model.js b/lib/Model.js
index 71ffb3c8..cdbf16ef 100644
--- a/lib/Model.js
+++ b/lib/Model.js
@@ -215,7 +215,7 @@ Model.prototype.get = function(key, settings = {}, callback) {
 		settings = {};
 	}
 
-	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "saveUnknown": true, "type": "fromDynamo"});
+	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "saveUnknown": true, "modifiers": ["get"], "type": "fromDynamo"});
 
 	const getItemParams = {
 		"Key": convertObjectToKey.bind(this)(key),


Test patch:
diff --git a/test/Document.js b/test/Document.js
index e2363097..fb5fe023 100644
--- a/test/Document.js
+++ b/test/Document.js
@@ -900,6 +900,28 @@ describe("Document", () => {
 					}]);
 				});
 
+				it("Should save with correct object with set function for attribute", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", {"id": Number, "name": {"type": String, "set": (val) => `${val}-set`}});
+					user = new User({"id": 1, "name": "Charlie"});
+					await callType.func(user).bind(user)();
+					expect(putParams).to.eql([{
+						"Item": {"id": {"N": "1"}, "name": {"S": "Charlie-set"}},
+						"TableName": "User"
+					}]);
+				});
+
+				it("Should save with correct object with async set function for attribute", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", {"id": Number, "name": {"type": String, "set": async (val) => `${val}-set`}});
+					user = new User({"id": 1, "name": "Charlie"});
+					await callType.func(user).bind(user)();
+					expect(putParams).to.eql([{
+						"Item": {"id": {"N": "1"}, "name": {"S": "Charlie-set"}},
+						"TableName": "User"
+					}]);
+				});
+
 				it("Should throw error if object contains properties that have type mismatch with schema", () => {
 					putItemFunction = () => Promise.resolve();
 					User = new Model("User", {"id": Number, "name": String, "age": Number}, {"create": false, "waitForActive": false});
diff --git a/test/Model.js b/test/Model.js
index 4c44201e..4a969c74 100644
--- a/test/Model.js
+++ b/test/Model.js
@@ -921,6 +921,26 @@ describe("Model", () => {
 					expect(user.friends).to.eql([{"name": "Tim", "id": 1}, {"name": "Bob", "id": 2}]);
 				});
 
+				it("Should return correct object if attribute has a get function", async () => {
+					User = new dynamoose.Model("User", {"id": Number, "name": {"type": String, "get": (val) => `${val}-get`}});
+					getItemFunction = () => Promise.resolve({"Item": {"id": {"N": "1"}, "name": {"S": "Charlie"}}});
+					const user = await callType.func(User).bind(User)(1);
+					expect(user).to.be.an("object");
+					expect(Object.keys(user)).to.eql(["id", "name"]);
+					expect(user.id).to.eql(1);
+					expect(user.name).to.eql("Charlie-get");
+				});
+
+				it("Should return correct object if attribute has an async get function", async () => {
+					User = new dynamoose.Model("User", {"id": Number, "name": {"type": String, "get": async (val) => `${val}-get`}});
+					getItemFunction = () => Promise.resolve({"Item": {"id": {"N": "1"}, "name": {"S": "Charlie"}}});
+					const user = await callType.func(User).bind(User)(1);
+					expect(user).to.be.an("object");
+					expect(Object.keys(user)).to.eql(["id", "name"]);
+					expect(user.id).to.eql(1);
+					expect(user.name).to.eql("Charlie-get");
+				});
+
 				it("Should throw error if DynamoDB responds with error", () => {
 					getItemFunction = () => Promise.reject({"error": "Error"});
 
@@ -1061,6 +1081,50 @@ describe("Model", () => {
 						"TableName": "User"
 					});
 				});
+
+				it("Should send correct params to putItem with set function", async () => {
+					createItemFunction = () => Promise.resolve();
+					User = new dynamoose.Model("User", {"id": Number, "name": {"type": String, "set": (val) => `${val}-set`}});
+					await callType.func(User).bind(User)({"id": 1, "name": "Charlie"});
+					expect(createItemParams).to.be.an("object");
+					expect(createItemParams).to.eql({
+						"ConditionExpression": "attribute_not_exists(#__hash_key)",
+						"ExpressionAttributeNames": {
+							"#__hash_key": "id"
+						},
+						"Item": {
+							"id": {
+								"N": "1"
+							},
+							"name": {
+								"S": "Charlie-set"
+							}
+						},
+						"TableName": "User"
+					});
+				});
+
+				it("Should send correct params to putItem with async set function", async () => {
+					createItemFunction = () => Promise.resolve();
+					User = new dynamoose.Model("User", {"id": Number, "name": {"type": String, "set": async (val) => `${val}-set`}});
+					await callType.func(User).bind(User)({"id": 1, "name": "Charlie"});
+					expect(createItemParams).to.be.an("object");
+					expect(createItemParams).to.eql({
+						"ConditionExpression": "attribute_not_exists(#__hash_key)",
+						"ExpressionAttributeNames": {
+							"#__hash_key": "id"
+						},
+						"Item": {
+							"id": {
+								"N": "1"
+							},
+							"name": {
+								"S": "Charlie-set"
+							}
+						},
+						"TableName": "User"
+					});
+				});
 			});
 		});
 	});
diff --git a/test/Query.js b/test/Query.js
index fe80f494..77a845df 100644
--- a/test/Query.js
+++ b/test/Query.js
@@ -235,6 +235,18 @@ describe("Query", () => {
 					});
 				});
 
+				it("Should return correct result with get function for attribute", async () => {
+					Model = new dynamoose.Model("Cat", new dynamoose.Schema({"id": Number, "name": {"type": String, "index": {"global": true}, "get": (val) => `${val}-get`}}));
+					queryPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}}]});
+					expect((await callType.func(Model.query("name").eq("Charlie").exec).bind(Model.query("name").eq("Charlie"))()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie-get"}]);
+				});
+
+				it("Should return correct result with async get function for attribute", async () => {
+					Model = new dynamoose.Model("Cat", new dynamoose.Schema({"id": Number, "name": {"type": String, "index": {"global": true}, "get": async (val) => `${val}-get`}}));
+					queryPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}}]});
+					expect((await callType.func(Model.query("name").eq("Charlie").exec).bind(Model.query("name").eq("Charlie"))()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie-get"}]);
+				});
+
 				it("Should throw error from AWS", () => {
 					queryPromiseResolver = () => {
 						throw {"error": "Error"};
diff --git a/test/Scan.js b/test/Scan.js
index 19db1b5a..d8b989b9 100644
--- a/test/Scan.js
+++ b/test/Scan.js
@@ -166,6 +166,18 @@ describe("Scan", () => {
 					});
 				});
 
+				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"}}]});
+					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie-get"}]);
+				});
+
+				it("Should return correct result for async get function on attribute", async () => {
+					Model = new dynamoose.Model("Cat", new dynamoose.Schema({"id": Number, "name": {"type": String, "get": async (val) => `${val}-get`}}));
+					scanPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}}]});
+					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie-get"}]);
+				});
+
 				it("Should throw error from AWS", () => {
 					scanPromiseResolver = () => {
 						throw {"error": "Error"};