Repository: dynamoosejs/dynamoose
Pull request #765 (2020-04-01 00:38:52)

Custom methods


Patch:
diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md
index 82af0141..f4f239f3 100644
--- a/BREAKING_CHANGES.md
+++ b/BREAKING_CHANGES.md
@@ -35,3 +35,7 @@
 - DynamoDB set types are now returned as JavaScript Set's instead of Array's
 - Trying to save a Document with a property set to `null` will now throw an error. If you would like to remove the property set it to `dynamoose.undefined` to set it to undefined without taking into account the `default` setting, or `undefined` to set it to undefined while taking into account the `default` setting.
 - Expires TTL value is set to be a default value. In Dynamoose v1 there were cases where expires TTL wouldn't be set, in Dynamoose v2, the behavior of if the Expires TTL is set behaves the same as any default value
+- Custom methods have changed behavior:
+	- `schema.method` is now `model.methods.document`
+	- `schema.statics` is now `model.methods`
+	- Both `model.methods` & `model.methods` have two functions that you call to add & remove methods. `set` & `delete` methods exist on both objects that you can use to add your methods. This is compared to the old system of calling the function for `schema.method` or setting the object for `schema.statics`.
diff --git a/docs/api/Model.md b/docs/api/Model.md
index 03f984a3..aeab8e3f 100644
--- a/docs/api/Model.md
+++ b/docs/api/Model.md
@@ -418,3 +418,112 @@ These methods are only meant to only be called to instantiate the `dynamoose.tra
 This method allows you to run a conditionCheck when running a DynamoDB transaction.
 
 The `additionalParameters` property will be appended to the result to allow you to set custom conditions.
+
+## Model.methods.set(name, function)
+
+This function allows you to add a method to the given model that you can call later. When Dynamoose calls your `function` parameter, `this` will be set to the underlying model. If an existing method exists with the given name, it will be overwritten, except if you are trying to replace an internal method, then this function will fail silently.
+
+```js
+// Setup:
+const User = new dynamoose.model("Model", ModelSchema);
+User.methods.set("scanAll", async function () {
+	let results = await this.scan().exec();
+	lastKey = results.lastKey;
+	do {
+		const newResult = await this.scan().startKey(lastKey).exec();
+		results = [...results, ...newResult];
+		lastKey = newResult.lastKey;
+	} while (lastKey)
+	return results;
+});
+// OR
+User.methods.set("scanAll", function (cb) {
+	let result = [];
+	const main = (lastKey) => {
+		this.scan().exec((err, newResult) => {
+			if (err) {
+				cb(err);
+			} else {
+				result = [...result, ...newResult];
+				if (newResult.lastKey) {
+					main(newResult.lastKey);
+				} else {
+					cb(result);
+				}
+			}
+		});
+	};
+	main();
+});
+
+// Using:
+User.scanAll((err, models) => {
+	models.forEach((model) => {
+		console.log(model);
+	});
+});
+// OR
+const models = await User.scanAll();
+models.forEach((model) => {
+	console.log(model);
+});
+```
+
+## Model.methods.delete(name)
+
+This allows you to delete an existing method from the model. If no existing method is assigned for that name, the function will do nothing and no error will be thrown.
+
+```js
+User.methods.delete("scanAll");
+// The following lines will throw an error
+const models = await User.scanAll();
+// OR
+User.scanAll((err, models) => {});
+```
+
+## Model.methods.document.set(name, function)
+
+This function allows you to add a method to the model documents that you can call later. When Dynamoose calls your `function` parameter, `this` will be set to the underlying document. If an existing method exists with the given name, it will be overwritten, except if you are trying to replace an internal method, then this function will fail silently.
+
+```js
+// Setup:
+const User = new dynamoose.model("Model", ModelSchema);
+User.methods.document.set("setName", async function () {
+	this.name = await getRandomName();
+});
+// OR
+User.methods.set("scanAll", function (cb) {
+	getRandomName((err, name) => {
+		if (err) {
+			cb(err);
+		} else {
+			this.name = name;
+			cb();
+		}
+	});
+});
+
+// Using:
+const user = new User();
+
+user.setName((err) => {
+	console.log("Set name");
+});
+// OR
+await user.setName();
+console.log("Set name");
+```
+
+## Model.methods.document.delete(name)
+
+This allows you to delete an existing method from the document. If no existing method is assigned for that name, the function will do nothing and no error will be thrown.
+
+```js
+User.methods.document.delete("setName");
+
+const user = new User();
+// The following lines will throw an error
+await user.setName();
+// OR
+user.setName((err) => {});
+```
diff --git a/lib/Document.js b/lib/Document.js
index 263b885c..7fdcbf51 100644
--- a/lib/Document.js
+++ b/lib/Document.js
@@ -366,6 +366,19 @@ function DocumentCarrier(model) {
 			const carrier = Document.Model[key].carrier(Document.Model);
 			Document[key] = (...args) => new carrier(...args);
 			Document[key].carrier = carrier;
+		} else if (typeof Document.Model[key] === "object") {
+			const main = (key) => {
+				utils.object.set(Document, key, {});
+				Object.keys(utils.object.get(Document.Model, key)).forEach((subKey) => {
+					const newKey = `${key}.${subKey}`;
+					if (typeof utils.object.get(Document.Model, newKey) === "object") {
+						main(newKey);
+					} else {
+						utils.object.set(Document, newKey, utils.object.get(Document.Model, newKey).bind(Document.Model));
+					}
+				});
+			};
+			main(key);
 		} else {
 			Document[key] = Document.Model[key].bind(Document.Model);
 		}
diff --git a/lib/Model.js b/lib/Model.js
index ab175bac..b99cb44b 100644
--- a/lib/Model.js
+++ b/lib/Model.js
@@ -261,7 +261,7 @@ function convertObjectToKey(key) {
 }
 
 
-Model.prototype.get = function(key, settings = {}, callback) {
+Model.prototype.get = function (key, settings = {}, callback) {
 	if (typeof settings === "function") {
 		callback = settings;
 		settings = {};
@@ -293,7 +293,7 @@ Model.prototype.get = function(key, settings = {}, callback) {
 	}
 };
 
-Model.prototype.create = function(document, settings = {}, callback) {
+Model.prototype.create = function (document, settings = {}, callback) {
 	if (typeof settings === "function" && !callback) {
 		callback = settings;
 		settings = {};
@@ -302,7 +302,7 @@ Model.prototype.create = function(document, settings = {}, callback) {
 	return (new this.Document(document)).save({"overwrite": false, ...settings}, callback);
 };
 
-Model.prototype.update = function(keyObj, updateObj, settings = {}, callback) {
+Model.prototype.update = function (keyObj, updateObj, settings = {}, callback) {
 	if (typeof updateObj === "function") {
 		callback = updateObj;
 		updateObj = null;
@@ -513,6 +513,74 @@ Model.prototype.delete = function (key, settings = {}, callback) {
 Model.prototype.scan = {"carrier": require("./DocumentRetriever")("scan")};
 Model.prototype.query = {"carrier": require("./DocumentRetriever")("query")};
 
+// Methods
+Model.prototype.methods = {
+	"set": function (name, fn) {
+		if (!this.Document[name] || (this.Document[name][Internal.internalProperties] && this.Document[name][Internal.internalProperties].type === "customMethod")) {
+			this.Document[name] = (cb) => {
+				if (cb) {
+					const result = fn.bind(this.Document)(cb);
+					if (result instanceof Promise) {
+						result.then((result) => cb(null, result)).catch((err) => cb(err));
+					}
+				} else {
+					return new Promise((resolve, reject) => {
+						const result = fn.bind(this.Document)((err, result) => {
+							if (err) {
+								reject(err);
+							} else {
+								resolve(result);
+							}
+						});
+						if (result instanceof Promise) {
+							result.then(resolve).catch(reject);
+						}
+					});
+				}
+			};
+			this.Document[name][Internal.internalProperties] = {"type": "customMethod"};
+		}
+	},
+	"delete": function (name) {
+		if (this.Document[name] && this.Document[name][Internal.internalProperties] && this.Document[name][Internal.internalProperties].type === "customMethod") {
+			this.Document[name] = undefined;
+		}
+	},
+	"document": {
+		"set": function (name, fn) {
+			if (!this.Document.prototype[name] || (this.Document.prototype[name][Internal.internalProperties] && this.Document.prototype[name][Internal.internalProperties].type === "customMethod")) {
+				this.Document.prototype[name] = function (cb) {
+					if (cb) {
+						const result = fn.bind(this)(cb);
+						if (result instanceof Promise) {
+							result.then((result) => cb(null, result)).catch((err) => cb(err));
+						}
+					} else {
+						return new Promise((resolve, reject) => {
+							const result = fn.bind(this)((err, result) => {
+								if (err) {
+									reject(err);
+								} else {
+									resolve(result);
+								}
+							});
+							if (result instanceof Promise) {
+								result.then(resolve).catch(reject);
+							}
+						});
+					}
+				};
+				this.Document.prototype[name][Internal.internalProperties] = {"type": "customMethod"};
+			}
+		},
+		"delete": function (name) {
+			if (this.Document.prototype[name] && this.Document.prototype[name][Internal.internalProperties] && this.Document.prototype[name][Internal.internalProperties].type === "customMethod") {
+				this.Document.prototype[name] = undefined;
+			}
+		}
+	}
+};
+
 // Defaults
 const defaults = {
 	"create": true,


Test patch:
diff --git a/test/Model.js b/test/Model.js
index 2c281e58..32c476e1 100644
--- a/test/Model.js
+++ b/test/Model.js
@@ -4,6 +4,7 @@ chai.use(chaiAsPromised);
 const {expect} = chai;
 const dynamoose = require("../lib");
 const Error = require("../lib/Error");
+const Internal = require("../lib/Internal");
 const utils = require("../lib/utils");
 const util = require("util");
 
@@ -2618,6 +2619,275 @@ describe("Model", () => {
 			});
 		});
 	});
+
+	describe("Model.methods", () => {
+		let User, user;
+		beforeEach(() => {
+			User = new dynamoose.Model("User", {"id": Number, "name": String});
+			user = new User();
+		});
+		afterEach(() => {
+			User = null;
+			user = null;
+		});
+
+		it("Should be an object", () => {
+			expect(User.methods).to.be.an("object");
+		});
+
+		describe("Model.methods.set", () => {
+			it("Should be a function", () => {
+				expect(User.methods.set).to.be.a("function");
+			});
+
+			it("Should not throw an error when being called", () => {
+				expect(() => User.methods.set("random", () => {})).to.not.throw();
+			});
+
+			it("Should set correct method on model", () => {
+				User.methods.set("random", () => {});
+				expect(User.random).to.be.a("function");
+			});
+
+			it("Should not overwrite internal methods", () => {
+				const originalMethod = User.get;
+				const newMethod = () => {};
+				User.methods.set("get", newMethod);
+				expect(User.get).to.eql(originalMethod);
+				expect(User.get).to.not.eql(newMethod);
+			});
+
+			it("Should overwrite methods if Internal.internalProperties exists but type doesn't", () => {
+				const originalMethod = User.get;
+				const newMethod = () => {};
+				User.random = originalMethod;
+				User.random[Internal.internalProperties] = {};
+				User.methods.set("random", newMethod);
+				expect(User.random).to.eql(originalMethod);
+				expect(User.random).to.not.eql(newMethod);
+			});
+
+			const methodTypes = [
+				{"name": "Callback", "func": (func) => {
+					return function (...args) {
+						const cb = args[args.length - 1];
+						func.bind(this)(...args).then((result) => cb(null, result)).catch((err) => cb(err));
+					};
+				}},
+				{"name": "Promise", "func": (func) => func}
+			];
+			methodTypes.forEach((methodType) => {
+				describe(`Method Type - ${methodType.name}`, () => {
+					const callerTypes = [
+						{"name": "Callback", "func": util.promisify},
+						{"name": "Promise", "func": (func) => func}
+					];
+					callerTypes.forEach((callerType) => {
+						describe(`Caller Type - ${callerType.name}`, () => {
+							let methodTypeCallDetails = [], methodTypeCallResult = {};
+							beforeEach(() => {
+								User.methods.set("action", methodType.func(async function (...args) {
+									methodTypeCallDetails.push({"arguments": [...args], "this": this});
+									if (methodTypeCallResult.error) {
+										throw methodTypeCallResult.error;
+									} else if (methodTypeCallResult.result) {
+										return methodTypeCallResult.result;
+									}
+								}));
+							});
+							afterEach(() => {
+								methodTypeCallDetails = [];
+								methodTypeCallResult = {};
+							});
+
+							it("Should call custom method with correct arguments", async () => {
+								methodTypeCallResult.result = "Success";
+								await callerType.func(User.action)();
+								expect(methodTypeCallDetails.length).to.eql(1);
+								expect(methodTypeCallDetails[0].arguments.length).to.eql(1);
+								expect(methodTypeCallDetails[0].arguments[0]).to.be.a("function");
+							});
+
+							it("Should call custom method with correct `this`", async () => {
+								methodTypeCallResult.result = "Success";
+								await callerType.func(User.action)();
+								expect(methodTypeCallDetails[0].this).to.eql(User);
+							});
+
+							it("Should return correct response that custom method returns", async () => {
+								methodTypeCallResult.result = "Success";
+								const result = await callerType.func(User.action)();
+								expect(result).to.eql("Success");
+							});
+
+							it("Should throw error if custom method throws error", async () => {
+								methodTypeCallResult.error = "ERROR";
+								let result, error;
+								try {
+									result = await callerType.func(User.action)();
+								} catch (e) {
+									error = e;
+								}
+								expect(result).to.not.exist;
+								expect(error).to.eql("ERROR");
+							});
+						});
+					});
+				});
+			});
+		});
+
+		describe("Model.methods.delete", () => {
+			it("Should be a function", () => {
+				expect(User.methods.delete).to.be.a("function");
+			});
+
+			it("Should not delete internal methods", () => {
+				User.methods.delete("get");
+				expect(User.get).to.be.a("function");
+			});
+
+			it("Should not throw error for deleting unknown method", () => {
+				expect(() => User.methods.delete("randomHere123")).to.not.throw();
+			});
+
+			it("Should delete custom method", () => {
+				User.methods.set("myMethod", () => {});
+				expect(User.myMethod).to.be.a("function");
+				User.methods.delete("myMethod");
+				expect(User.myMethod).to.eql(undefined);
+			});
+		});
+
+		describe("Model.methods.document", () => {
+			describe("Model.methods.document.set", () => {
+				it("Should be a function", () => {
+					expect(User.methods.document.set).to.be.a("function");
+				});
+
+				it("Should not throw an error when being called", () => {
+					expect(() => User.methods.document.set("random", () => {})).to.not.throw();
+				});
+
+				it("Should set correct method on model", () => {
+					User.methods.document.set("random", () => {});
+					expect(user.random).to.be.a("function");
+				});
+
+				it("Should not overwrite internal methods", () => {
+					const originalMethod = user.save;
+					const newMethod = () => {};
+					User.methods.document.set("save", newMethod);
+					expect(user.save).to.eql(originalMethod);
+					expect(user.save).to.not.eql(newMethod);
+				});
+
+				it("Should overwrite methods if Internal.internalProperties exists but type doesn't", () => {
+					const originalMethod = user.save;
+					const newMethod = () => {};
+					User.prototype.random = originalMethod;
+					User.prototype.random[Internal.internalProperties] = {};
+					User.methods.document.set("random", newMethod);
+					expect(user.random).to.eql(originalMethod);
+					expect(user.random).to.not.eql(newMethod);
+				});
+
+				const methodTypes = [
+					{"name": "Callback", "func": (func) => {
+						return function (...args) {
+							const cb = args[args.length - 1];
+							func.bind(this)(...args).then((result) => cb(null, result)).catch((err) => cb(err));
+						};
+					}},
+					{"name": "Promise", "func": (func) => func}
+				];
+				methodTypes.forEach((methodType) => {
+					describe(`Method Type - ${methodType.name}`, () => {
+						const callerTypes = [
+							{"name": "Callback", "func": util.promisify},
+							{"name": "Promise", "func": (func) => func}
+						];
+						callerTypes.forEach((callerType) => {
+							describe(`Caller Type - ${callerType.name}`, () => {
+								let methodTypeCallDetails = [], methodTypeCallResult = {};
+								beforeEach(() => {
+									User.methods.document.set("action", methodType.func(async function (...args) {
+										methodTypeCallDetails.push({"arguments": [...args], "this": this});
+										if (methodTypeCallResult.error) {
+											throw methodTypeCallResult.error;
+										} else if (methodTypeCallResult.result) {
+											return methodTypeCallResult.result;
+										}
+									}));
+								});
+								afterEach(() => {
+									methodTypeCallDetails = [];
+									methodTypeCallResult = {};
+								});
+
+								it("Should call custom method with correct arguments", async () => {
+									methodTypeCallResult.result = "Success";
+									await callerType.func(user.action)();
+									expect(methodTypeCallDetails.length).to.eql(1);
+									expect(methodTypeCallDetails[0].arguments.length).to.eql(1);
+									expect(methodTypeCallDetails[0].arguments[0]).to.be.a("function");
+								});
+
+								it("Should call custom method with correct `this`", async () => {
+									methodTypeCallResult.result = "Success";
+									// Not sure why we have to bind `user` here, when we don't have to bind anything for Model.methods.set
+									// Through manual testing tho, you do not need to bind anything and in production use this works as described in the documentation
+									// Would be nice to figure out why this is only necessary for our test suite and fix it
+									await callerType.func(user.action).bind(user)();
+									expect(methodTypeCallDetails[0].this).to.eql(user);
+								});
+
+								it("Should return correct response that custom method returns", async () => {
+									methodTypeCallResult.result = "Success";
+									const result = await callerType.func(user.action)();
+									expect(result).to.eql("Success");
+								});
+
+								it("Should throw error if custom method throws error", async () => {
+									methodTypeCallResult.error = "ERROR";
+									let result, error;
+									try {
+										result = await callerType.func(user.action)();
+									} catch (e) {
+										error = e;
+									}
+									expect(result).to.not.exist;
+									expect(error).to.eql("ERROR");
+								});
+							});
+						});
+					});
+				});
+			});
+
+			describe("Model.methods.document.delete", () => {
+				it("Should be a function", () => {
+					expect(User.methods.document.delete).to.be.a("function");
+				});
+
+				it("Should not delete internal methods", () => {
+					User.methods.document.delete("save");
+					expect(user.save).to.be.a("function");
+				});
+
+				it("Should not throw error for deleting unknown method", () => {
+					expect(() => User.methods.document.delete("randomHere123")).to.not.throw();
+				});
+
+				it("Should delete custom method", () => {
+					User.methods.document.set("myMethod", () => {});
+					expect(user.myMethod).to.be.a("function");
+					User.methods.document.delete("myMethod");
+					expect(user.myMethod).to.eql(undefined);
+				});
+			});
+		});
+	});
 });
 
 describe("model", () => {