Repository: dynamoosejs/dynamoose
Pull request #721 (2020-02-21 01:21:46)

Time to Live


Patch:
diff --git a/BREAKING_CHANGES.md b/BREAKING_CHANGES.md
index c7b9c634..4887774c 100644
--- a/BREAKING_CHANGES.md
+++ b/BREAKING_CHANGES.md
@@ -13,3 +13,7 @@
 - `Model.table.create.request` (formerly `Model.getTableReq`) is now an async function
 - `model.originalItem` has been renamed to `model.original` (or `Document.original`)
 - `Document.original` formerly (`model.originalItem`) no longer returns the last item saved, but the item first retrieved from DynamoDB
+- `expires` has been moved from the Schema settings to the Model settings
+- `expires.ttl` is now milliseconds as opposed to seconds
+- `expires.defaultExpires` is no longer an option
+- `expires.returnExpiredItems` has been renamed to `expires.items.returnExpired`
diff --git a/docs/FAQ.md b/docs/FAQ.md
index 7faefdd7..2418f4c6 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -6,7 +6,7 @@ The following is a chart of IAM permissions you need in order to run Dynamoose f
 
 | Dynamoose Action | IAM Permission | Notes |
 |------------------|----------------|-------|
-| new Model() | `createTable`, `describeTable`, `updateTable` | `createTable` is only used if `create` is set to true. `describeTable` is only used if `waitForActive` OR `create` is set to true. `updateTable` is only used if `update` is set to true. |
+| new Model() | `createTable`, `describeTable`, `updateTable`, `updateTimeToLive` | `createTable` is only used if `create` is set to true. `describeTable` is only used if `waitForActive` OR `create` is set to true. `updateTable` is only used if `update` is set to true. `updateTimeToLive` is only used if `create` or `update` is set to true and there is an `expires` setting on the model. |
 | Model.get | `getItem` |  |
 | Model.scan | `scan` | This permission is only required on `scan.exec` |
 | Model.query | `query` | This permission is only required on `query.exec` |
diff --git a/docs/api/Model.md b/docs/api/Model.md
index 1f14c71b..045aef0c 100644
--- a/docs/api/Model.md
+++ b/docs/api/Model.md
@@ -36,6 +36,11 @@ The config parameter is an object used to customize settings for the model.
 | waitForActive.check.timeout | How many milliseconds before Dynamoose should timeout and stop checking if the table is active. | Number | 180000 |
 | waitForActive.check.frequency | How many milliseconds Dynamoose should delay between checks to see if the table is active. If this number is set to 0 it will use `setImmediate()` to run the check again. | Number | 1000 |
 | update | If Dynamoose should update the capacity of the existing table to match the model throughput. | Boolean | false |
+| expires | The setting to describe the time to live for documents created. If you pass in a number it will be used for the `expires.ttl` setting, with default values for everything else. If this is `null`, no time to live will be active on the model. | Number \| Object | null |
+| expires.ttl | The default amount of time the document should stay alive from creation time in milliseconds. | Number | null |
+| expires.attribute | The attribute name for where the document time to live attribute. | String | `ttl` |
+| expires.items | The options for items with ttl. | Object | {} |
+| expires.items.returnExpired | If Dynamoose should include expired items when returning retrieved items. | Boolean | true |
 
 The default object is listed below.
 
@@ -55,7 +60,8 @@ The default object is listed below.
 			"frequency": 1000
 		}
 	},
-	"update": false
+	"update": false,
+	"expires": null
 }
 ```
 
diff --git a/docs/api/Schema.md b/docs/api/Schema.md
index 3762ef00..0e14be86 100644
--- a/docs/api/Schema.md
+++ b/docs/api/Schema.md
@@ -43,15 +43,15 @@ const schema = new dynamoose.Schema({
 
 ## Attribute Types
 
-| Type    | Set Allowed | DynamoDB Type | Custom Dynamoose Type | Nested Type | Notes                                                                                               |
-|---------|-------------|---------------|-----------------------|-------------|-----------------------------------------------------------------------------------------------------|
-| String  | True        | S             | False                 | False       |                                                                                                     |
-| Boolean | False       | BOOL          | False                 | False       |                                                                                                     |
-| Number  | True        | N             | False                 | False       |                                                                                                     |
-| Buffer  | True        | B             | False                 | False       |                                                                                                     |
-| Date    | True        | N             | True                  | False       | Will be stored in DynamoDB as milliseconds since Jan 1 1970, and converted to/from a Date instance. |
-| Object  | False       | M             | False                 | True        |                                                                                                     |
-| Array   | False       | L             | False                 | True        |                                                                                                     |
+| Type | Set Allowed | DynamoDB Type | Custom Dynamoose Type | Nested Type | Settings | Notes |
+|---|---|---|---|---|---|---|
+| String | True | S | False | False |   |   |
+| Boolean | False | BOOL | False | False |   |   |
+| Number | True | N | False | False |   |   |
+| Buffer | True | B | False | False |   |   |
+| Date | True | N | True | False | **storage** - miliseconds \| seconds (default: miliseconds) | Will be stored in DynamoDB as milliseconds since Jan 1 1970, and converted to/from a Date instance. |
+| Object | False | M | False | True |   |   |
+| Array | False | L | False | True |   |   |
 
 If you use a set you will define the type surrounded by brackets. For example a String Set would be defined as a type of `[String]`. Set's are different from Array's since they require each item in the Set be unique. If you use a Set, it will use the underlying JavaScript Set instance as opposed to an Array.
 
@@ -63,6 +63,31 @@ For types that are `Nested Types`, you must define a `schema` setting that inclu
 
 ## Attribute Settings
 
+### type: type | object
+
+The type attribute can either be a type (ex. `Object`, `Number`, etc.) or an object that has additional information for the type. In the event you set it as an object you must pass in a `value` for the type, and can optionally pass in a `settings` object.
+
+```js
+{
+	"address": {
+		"type": Object
+	}
+}
+```
+
+```js
+{
+	"deletedAt": {
+		"type": {
+			"value": Date,
+			"settings": {
+				"storage": "seconds" // Default: miliseconds (as shown above)
+			}
+		}
+	}
+}
+```
+
 ### schema: object | array
 
 This property is only used for the `Object` or `Array` attribute types. It is used to define the schema for the underlying nested type. For `Array` attribute types, this value must be an `Array` with one element defining the schema. This element for `Array` attribute types can either be another raw Dynamoose type (ex. `String`), or an object defining a more detailed schema for the `Array` elements. For `Object` attribute types this value must be an object defining the schema. Some examples of this property in action can be found below.
diff --git a/lib/Document.js b/lib/Document.js
index 10ea6c53..11a80fe3 100644
--- a/lib/Document.js
+++ b/lib/Document.js
@@ -104,60 +104,62 @@ function DocumentCarrier(model) {
 	};
 	// This function will return an object that conforms to the schema (removing any properties that don't exist, using default values, etc.) & throws an error if there is a typemismatch.
 	Document.objectFromSchema = async function(object, settings = {"type": "toDynamo"}) {
-		// const attributeTypeMap = {};
-		const returnObject = Object.keys(object).reduce((finalObject, key) => {
-			function main(key) {
-				const value = utils.object.get(object, key);
-				const genericKey = key.replace(/\d+/gu, "0"); // This is a key replacing all list numbers with 0 to standardize things like checking if it exists in the schema
-				const keyParts = key.split(".");
-				const parentKey = [...keyParts].splice(0, keyParts.length - 1).join(".");
-				const parentKeyIsArray = parentKey && model.schema.getAttributeType(parentKey) === "L";
-				const existsInSchema = model.schema.attributes().includes(genericKey) || parentKeyIsArray;
-				const isCustomType = existsInSchema && model.schema.getAttributeTypeDetails(key).customType;
-				const valueType = typeof value;
-				const customValue = isCustomType ? model.schema.getAttributeTypeDetails(key).customType.functions[settings.type](value) : null;
-				const customValueType = isCustomType ? typeof customValue : null;
-				let attributeType = existsInSchema || parentKeyIsArray ? utils.attribute_types.find((type) => type.dynamodbType === model.schema.getAttributeType(key)) : null;
-				const expectedType = existsInSchema ? attributeType.javascriptType : null;
-				let typeMatches = existsInSchema ? valueType === expectedType || (attributeType.isOfType && attributeType.isOfType(value, settings.type)) : null;
-				if (existsInSchema && attributeType.isOfType && attributeType.isOfType(isCustomType && settings.type === "toDynamo" ? customValue : value, settings.type)) {
-					typeMatches = true;
-				}
-				const saveUnknownSetting = model.schema.getSettingValue("saveUnknown");
-				const saveUnknownAllowed = Array.isArray(saveUnknownSetting) ? saveUnknownSetting.includes(key) : saveUnknownSetting;
-				if ((existsInSchema && (typeMatches || (settings.customTypesDynamo && customValueType === expectedType))) || (settings.saveUnknown && saveUnknownAllowed)) {
-					if (!existsInSchema && settings.saveUnknown && saveUnknownAllowed) {
-						const foundAttributeType = utils.attribute_types.find((type) => type.isOfType && type.isOfType(customValue || value, settings.type, {"saveUnknown": true}));
-						if (foundAttributeType) {
-							attributeType = foundAttributeType;
-						}
+		if (settings.checkExpiredItem && model.options.expires && (model.options.expires.items || {}).returnExpired === false && object[model.options.expires.attribute] && (object[model.options.expires.attribute] * 1000) < Date.now()) {
+			return null;
+		}
+
+		function mainCheck(finalObjectPre, key, value) {
+			const finalObject = {...finalObjectPre};
+			const genericKey = key.replace(/\d+/gu, "0"); // This is a key replacing all list numbers with 0 to standardize things like checking if it exists in the schema
+			const keyParts = key.split(".");
+			const parentKey = [...keyParts].splice(0, keyParts.length - 1).join(".");
+			const parentKeyIsArray = parentKey && model.schema.getAttributeType(parentKey) === "L";
+			const existsInSchema = model.schema.attributes().includes(genericKey) || parentKeyIsArray;
+			const isCustomType = existsInSchema && model.schema.getAttributeTypeDetails(key).customType;
+			const valueType = typeof value;
+			const customValue = isCustomType ? model.schema.getAttributeTypeDetails(key).customType.functions[settings.type](value) : null;
+			const customValueType = isCustomType ? typeof customValue : null;
+			let attributeType = existsInSchema || parentKeyIsArray ? utils.attribute_types.find((type) => type.dynamodbType === model.schema.getAttributeType(key)) : null;
+			const expectedType = existsInSchema ? attributeType.javascriptType : null;
+			let typeMatches = existsInSchema ? valueType === expectedType || (attributeType.isOfType && attributeType.isOfType(value, settings.type)) : null;
+			if (existsInSchema && attributeType.isOfType && attributeType.isOfType(isCustomType && settings.type === "toDynamo" ? customValue : value, settings.type)) {
+				typeMatches = true;
+			}
+			const saveUnknownSetting = model.schema.getSettingValue("saveUnknown");
+			const saveUnknownAllowed = Array.isArray(saveUnknownSetting) ? saveUnknownSetting.includes(key) : saveUnknownSetting;
+			if ((existsInSchema && (typeMatches || (settings.customTypesDynamo && customValueType === expectedType))) || (settings.saveUnknown && saveUnknownAllowed)) {
+				if (!existsInSchema && settings.saveUnknown && saveUnknownAllowed) {
+					const foundAttributeType = utils.attribute_types.find((type) => type.isOfType && type.isOfType(customValue || value, settings.type, {"saveUnknown": true}));
+					if (foundAttributeType) {
+						attributeType = foundAttributeType;
 					}
+				}
 
-					const setValue = attributeType && attributeType[settings.type] ? attributeType[settings.type](customValue || value) : (customValue || value);
+				const setValue = attributeType && attributeType[settings.type] ? attributeType[settings.type](customValue || value) : (customValue || value);
 
-					if (attributeType && attributeType.nestedType) {
-						utils.object.set(finalObject, key, attributeType.defaultNestedType());
-						Object.keys(setValue).forEach((nestedKey) => {
-							main(`${key}.${nestedKey}`);
-						});
-					} else {
-						utils.object.set(finalObject, key, setValue);
-					}
-				} else if (existsInSchema && !typeMatches && !(settings.customTypesDynamo && customValueType === expectedType)) {
-					throw new Error.TypeMismatch(`Expected ${key} to be of type ${attributeType.javascriptType}, instead found type ${valueType}.`);
+				if (attributeType && attributeType.nestedType) {
+					utils.object.set(finalObject, key, attributeType.defaultNestedType());
+					Object.keys(setValue).forEach((nestedKey) => {
+						const subKey = `${key}.${nestedKey}`;
+						mainCheck(finalObject, subKey, utils.object.get(object, subKey));
+					});
+				} else {
+					utils.object.set(finalObject, key, setValue);
 				}
+			} else if (existsInSchema && !typeMatches && !(settings.customTypesDynamo && customValueType === expectedType)) {
+				throw new Error.TypeMismatch(`Expected ${key} to be of type ${attributeType.javascriptType}, instead found type ${valueType}.`);
 			}
-			main(key);
-
 			return finalObject;
-		}, {});
+		}
+		const returnObject = Object.keys(object).reduce((finalObject, key) => mainCheck(finalObject, key, utils.object.get(object, key)), {});
 
 		if (settings.defaults || settings.forceDefault) {
 			await Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
 				const value = utils.object.get(returnObject, key);
 				const defaultValue = await model.schema.defaultCheck(key, value, settings);
 				if (defaultValue) {
-					utils.object.set(returnObject, key, defaultValue);
+					const newObject = mainCheck({}, key, defaultValue);
+					utils.object.set(returnObject, key, utils.object.get(newObject, key));
 				}
 			}));
 		}
@@ -253,6 +255,9 @@ function DocumentCarrier(model) {
 	Document.prototype.conformToSchema = async function(settings = {"type": "fromDynamo"}) {
 		Document.prepareForObjectFromSchema(this, settings);
 		const expectedObject = await Document.objectFromSchema(this, settings);
+		if (!expectedObject) {
+			return expectedObject;
+		}
 		const expectedKeys = Object.keys(expectedObject);
 		Object.keys(this).forEach((key) => {
 			if (!expectedKeys.includes(key)) {
diff --git a/lib/DocumentRetriever.js b/lib/DocumentRetriever.js
index 1d9b53d1..15a36b75 100644
--- a/lib/DocumentRetriever.js
+++ b/lib/DocumentRetriever.js
@@ -144,7 +144,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, "type": "fromDynamo"}))));
+				const array = (await Promise.all(result.Items.map(async (item) => await ((new model.Document(item, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "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 33633c91..9e39bf30 100644
--- a/lib/Model.js
+++ b/lib/Model.js
@@ -14,6 +14,25 @@ function Model(name, schema, options = {}) {
 	} else if (!(schema instanceof Schema)) {
 		schema = new Schema(schema);
 	}
+	if (options.expires) {
+		if (typeof options.expires === "number") {
+			options.expires = {
+				"attribute": "ttl",
+				"ttl": options.expires
+			};
+		}
+		options.expires = utils.combine_objects(options.expires, {"attribute": "ttl"});
+
+		schema.schemaObject[options.expires.attribute] = {
+			"type": {
+				"value": Date,
+				"settings": {
+					"storage": "seconds"
+				}
+			},
+			"default": () => new Date(Date.now() + options.expires.ttl)
+		};
+	}
 	this.schema = schema;
 
 	// Setup flow
@@ -34,6 +53,10 @@ function Model(name, schema, options = {}) {
 	if ((this.options.waitForActive || {}).enabled) {
 		setupFlow.push(waitForActive(this));
 	}
+	// Update Time To Live
+	if ((this.options.create || this.options.update) && options.expires) {
+		setupFlow.push(updateTimeToLive(this));
+	}
 	// Update
 	if (this.options.update) {
 		setupFlow.push(updateTable(this));
@@ -73,6 +96,15 @@ async function createTableRequest(model) {
 		...await model.schema.getCreateTableAttributeParams()
 	};
 }
+function updateTimeToLive(model) {
+	return aws.ddb().updateTimeToLive({
+		"TableName": model.name,
+		"TimeToLiveSpecification": {
+			"AttributeName": model.options.expires.attribute,
+			"Enabled": true
+		}
+	});
+}
 function getProvisionedThroughput(model) {
 	if (model.options.throughput === "ON_DEMAND") {
 		return {
@@ -136,7 +168,7 @@ async function updateTable(model) {
 
 
 Model.prototype.get = function(key, callback) {
-	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "saveUnknown": true, "type": "fromDynamo"});
+	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "saveUnknown": true, "type": "fromDynamo"});
 
 	const promise = this.pendingTaskPromise().then(() => aws.ddb().getItem({
 		"Key": this.Document.toDynamo(typeof key === "object" ? key : {
@@ -299,7 +331,7 @@ Model.prototype.update = function(keyObj, updateObj, callback) {
 		return returnObject;
 	};
 
-	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "type": "fromDynamo"});
+	const documentify = (document) => (new this.Document(document, {"fromDynamo": true})).conformToSchema({"customTypesDynamo": true, "checkExpiredItem": true, "type": "fromDynamo"});
 	const promise = this.pendingTaskPromise().then(async () => aws.ddb().updateItem({
 		"Key": this.Document.toDynamo(keyObj),
 		"ReturnValues": "ALL_NEW",
diff --git a/lib/Schema.js b/lib/Schema.js
index f789bc3c..2f488954 100644
--- a/lib/Schema.js
+++ b/lib/Schema.js
@@ -163,6 +163,11 @@ Schema.prototype.getAttributeTypeDetails = function(key) {
 		throw new Error.UnknownAttribute(`Invalid Attribute: ${key}`);
 	}
 	let typeVal = typeof val === "object" && !Array.isArray(val) ? val.type : val;
+	let typeSettings = {};
+	if (typeof typeVal === "object" && !Array.isArray(typeVal)) {
+		typeSettings = typeVal.settings || {};
+		typeVal = typeVal.value;
+	}
 
 	const isSet = Array.isArray(typeVal);
 	if (isSet) {
@@ -197,13 +202,31 @@ Schema.prototype.getAttributeTypeDetails = function(key) {
 		customType = {
 			"functions": {
 				"toDynamo": (val) => {
+					const action = (val) => {
+						if (typeSettings.storage === "seconds") {
+							return val.getTime() / 1000;
+						} else {
+							return val.getTime();
+						}
+					};
+
 					try {
-						return isSet ? val.map((subVal) => subVal.getTime()) : val.getTime();
+						return isSet ? val.map(action) : action(val);
 					} catch (e) {
 						return null;
 					}
 				},
-				"fromDynamo": (val) => isSet ? {"values": val.values.map((subVal) => new Date(subVal))} : new Date(val)
+				"fromDynamo": (val) => {
+					const action = (val) => {
+						if (typeSettings.storage === "seconds") {
+							return new Date(val * 1000);
+						} else {
+							return new Date(val);
+						}
+					};
+
+					return isSet ? {"values": val.values.map(action)} : action(val);
+				}
 			}
 		};
 		break;


Test patch:
diff --git a/test/Document.js b/test/Document.js
index 5a991a59..707bd214 100644
--- a/test/Document.js
+++ b/test/Document.js
@@ -489,6 +489,45 @@ describe("Document", () => {
 					expect(error).to.eql(new Error.ValidationError("friends.1.addresses.1.country is a required property but has no value when trying to save document"));
 				});
 
+				it("Should save with correct object with expires set to a number", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", new Schema({"id": Number, "name": String}), {"create": false, "waitForActive": false, "expires": 10000});
+					user = new User({"id": 1, "name": "Charlie"});
+					await callType.func(user).bind(user)();
+					expect(putParams[0].TableName).to.eql("User");
+					expect(putParams[0].Item).to.be.a("object");
+					expect(putParams[0].Item.id).to.eql({"N": "1"});
+					expect(putParams[0].Item.name).to.eql({"S": "Charlie"});
+					const expectedTTL = (Date.now() + 10000) / 1000;
+					expect(parseInt(putParams[0].Item.ttl.N)).to.be.within(expectedTTL - 1000, expectedTTL + 1000);
+				});
+
+				it("Should save with correct object with expires set to object", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", new Schema({"id": Number, "name": String}), {"create": false, "waitForActive": false, "expires": {"attribute": "expires", "ttl": 10000}});
+					user = new User({"id": 1, "name": "Charlie"});
+					await callType.func(user).bind(user)();
+					expect(putParams[0].TableName).to.eql("User");
+					expect(putParams[0].Item).to.be.a("object");
+					expect(putParams[0].Item.id).to.eql({"N": "1"});
+					expect(putParams[0].Item.name).to.eql({"S": "Charlie"});
+					const expectedTTL = (Date.now() + 10000) / 1000;
+					expect(parseInt(putParams[0].Item.expires.N)).to.be.within(expectedTTL - 1000, expectedTTL + 1000);
+				});
+
+				it("Should save with correct object with expires set to object with no attribute", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", new Schema({"id": Number, "name": String}), {"create": false, "waitForActive": false, "expires": {"ttl": 10000}});
+					user = new User({"id": 1, "name": "Charlie"});
+					await callType.func(user).bind(user)();
+					expect(putParams[0].TableName).to.eql("User");
+					expect(putParams[0].Item).to.be.a("object");
+					expect(putParams[0].Item.id).to.eql({"N": "1"});
+					expect(putParams[0].Item.name).to.eql({"S": "Charlie"});
+					const expectedTTL = (Date.now() + 10000) / 1000;
+					expect(parseInt(putParams[0].Item.ttl.N)).to.be.within(expectedTTL - 1000, expectedTTL + 1000);
+				});
+
 				it("Should save with correct object with timestamps set to true", async () => {
 					putItemFunction = () => Promise.resolve();
 					User = new Model("User", new Schema({"id": Number, "name": String}, {"timestamps": true}), {"create": false, "waitForActive": false});
@@ -669,6 +708,33 @@ describe("Document", () => {
 					}]);
 				});
 
+				it("Should throw type mismatch error if passing in wrong type for default value", async () => {
+					putItemFunction = () => Promise.resolve();
+					User = new Model("User", {"id": Number, "age": {"type": Number, "default": () => true}}, {"create": false, "waitForActive": false});
+					user = new User({"id": 1});
+
+					let result, error;
+					try {
+						result = await callType.func(user).bind(user)();
+					} catch (e) {
+						error = e;
+					}
+					expect(result).to.not.exist;
+					expect(error).to.eql(new Error.TypeMismatch("Expected age to be of type number, instead found type boolean."));
+				});
+
+				it("Should save with correct object with default value as custom type", async () => {
+					putItemFunction = () => Promise.resolve();
+					const date = new Date();
+					User = new Model("User", {"id": Number, "timestamp": {"type": Date, "default": () => date}}, {"create": false, "waitForActive": false});
+					user = new User({"id": 1});
+					await callType.func(user).bind(user)();
+					expect(putParams).to.eql([{
+						"Item": {"id": {"N": "1"}, "timestamp": {"N": `${date.getTime()}`}},
+						"TableName": "User"
+					}]);
+				});
+
 				it("Should save with correct object with validation value", async () => {
 					putItemFunction = () => Promise.resolve();
 					User = new Model("User", {"id": Number, "age": {"type": Number, "validate": 5}}, {"create": false, "waitForActive": false});
@@ -1357,10 +1423,25 @@ describe("Document", () => {
 				"output": {"id": "ID_test"},
 				"schema": {"id": {"type": String, "validate": "ID_test"}}
 			},
+			{
+				"input": [{"id": 1, "ttl": 1}, {"type": "fromDynamo", "checkExpiredItem": true}],
+				"model": ["User", {"id": Number}, {"create": false, "waitForActive": false, "expires": 1000}],
+				"output": {"id": 1, "ttl": new Date(1000)}
+			},
+			{
+				"input": [{"id": 1, "ttl": 1}, {"type": "fromDynamo", "checkExpiredItem": true}],
+				"model": ["User", {"id": Number}, {"create": false, "waitForActive": false, "expires": {"ttl": 1000, "attribute": "ttl", "items": {"returnExpired": false}}}],
+				"output": null
+			}
 		];
 
 		tests.forEach((test) => {
-			const model = new Model("User", test.schema, {"create": false, "waitForActive": false});
+			let model;
+			if (test.model) {
+				model = new Model(...test.model);
+			} else {
+				model = new Model("User", test.schema, {"create": false, "waitForActive": false});
+			}
 
 			if (test.error) {
 				it(`Should throw error ${JSON.stringify(test.error)} for input of ${JSON.stringify(test.input)}`, async () => {
diff --git a/test/Model.js b/test/Model.js
index 9d021021..bb1c6725 100644
--- a/test/Model.js
+++ b/test/Model.js
@@ -508,6 +508,77 @@ describe("Model", () => {
 						}]);
 					});
 				});
+
+				describe("Time To Live", () => {
+					let updateTTLParams = [];
+					beforeEach(() => {
+						dynamoose.Model.defaults = {
+							"create": false,
+							"update": true
+						};
+					});
+					beforeEach(() => {
+						updateTTLParams = [];
+						dynamoose.aws.ddb.set({
+							"describeTable": () => {
+								return {
+									"promise": () => Promise.resolve({
+										"Table": {
+											"ProvisionedThroughput": {
+												"ReadCapacityUnits": 5,
+												"WriteCapacityUnits": 5
+											},
+											"TableStatus": "ACTIVE"
+										}
+									})
+								};
+							},
+							"updateTimeToLive": (params) => {
+								updateTTLParams.push(params);
+								return {
+									"promise": Promise.resolve()
+								};
+							}
+						});
+					});
+					afterEach(() => {
+						updateTTLParams = [];
+						dynamoose.aws.ddb.revert();
+					});
+
+					it("Should call updateTimeToLive with correct parameters", async () => {
+						const tableName = "Cat";
+						option.func(tableName, {"id": String}, {"expires": 1000});
+						await utils.set_immediate_promise();
+						expect(updateTTLParams).to.eql([{
+							"TableName": tableName,
+							"TimeToLiveSpecification": {
+								"Enabled": true,
+								"AttributeName": "ttl"
+							}
+						}]);
+					});
+
+					it("Should call updateTimeToLive with correct parameters for custom attribute", async () => {
+						const tableName = "Cat";
+						option.func(tableName, {"id": String}, {"expires": {"ttl": 1000, "attribute": "expires"}});
+						await utils.set_immediate_promise();
+						expect(updateTTLParams).to.eql([{
+							"TableName": tableName,
+							"TimeToLiveSpecification": {
+								"Enabled": true,
+								"AttributeName": "expires"
+							}
+						}]);
+					});
+
+					it("Should not call updateTimeToLive if no expires", async () => {
+						const tableName = "Cat";
+						option.func(tableName, {"id": String});
+						await utils.set_immediate_promise();
+						expect(updateTTLParams).to.eql([]);
+					});
+				});
 			});
 		});
 	});
@@ -585,6 +656,23 @@ describe("Model", () => {
 					expect(user).to.be.an.instanceof(User);
 				});
 
+				it("Should return null for expired object", async () => {
+					User = new dynamoose.Model("User", {"id": Number}, {"expires": {"ttl": 1000, "items": {"returnExpired": false}}});
+					getItemFunction = () => Promise.resolve({"Item": {"id": {"N": "1"}, "ttl": {"N": "1"}}});
+					const user = await callType.func(User).bind(User)(1);
+					expect(user).to.eql(null);
+				});
+
+				it("Should return expired object if returnExpired is not set", async () => {
+					User = new dynamoose.Model("User", {"id": Number}, {"expires": 1000});
+					getItemFunction = () => Promise.resolve({"Item": {"id": {"N": "1"}, "ttl": {"N": "1"}}});
+					const user = await callType.func(User).bind(User)(1);
+					expect(user).to.be.an("object");
+					expect(Object.keys(user)).to.eql(["id", "ttl"]);
+					expect(user.id).to.eql(1);
+					expect(user.ttl).to.eql(new Date(1000));
+				});
+
 				it("Should return object with correct values with saveUnknown", async () => {
 					User = new dynamoose.Model("User", new dynamoose.Schema({"id": Number}, {"saveUnknown": true}));
 					getItemFunction = () => Promise.resolve({"Item": {"id": {"N": "1"}, "hello": {"S": "world"}}});
diff --git a/test/Query.js b/test/Query.js
index e9db8082..1b658533 100644
--- a/test/Query.js
+++ b/test/Query.js
@@ -80,6 +80,18 @@ describe("Query", () => {
 					expect((await callType.func(Model.query().exec).bind(Model.query())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie"}]);
 				});
 
+				it("Should return null for expired object", async () => {
+					queryPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "ttl": {"N": "1"}}]});
+					Model = new dynamoose.Model("Cat", {"id": Number}, {"expires": {"ttl": 1000, "items": {"returnExpired": false}}});
+					expect((await callType.func(Model.query().exec).bind(Model.query())()).map((item) => ({...item}))).to.eql([]);
+				});
+
+				it("Should return expired object if returnExpired is not set", async () => {
+					queryPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "ttl": {"N": "1"}}]});
+					Model = new dynamoose.Model("Cat", {"id": Number}, {"expires": {"ttl": 1000}});
+					expect((await callType.func(Model.query().exec).bind(Model.query())()).map((item) => ({...item}))).to.eql([{"id": 1, "ttl": new Date(1000)}]);
+				});
+
 				it("Should return correct result if unknown properties are in DynamoDB", async () => {
 					queryPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}, "age": {"N": "1"}}]});
 					expect((await callType.func(Model.query().exec).bind(Model.query())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie"}]);
diff --git a/test/Scan.js b/test/Scan.js
index 6a7a22de..dcd18bfd 100644
--- a/test/Scan.js
+++ b/test/Scan.js
@@ -80,6 +80,18 @@ describe("Scan", () => {
 					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie"}]);
 				});
 
+				it("Should return null for expired object", async () => {
+					scanPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "ttl": {"N": "1"}}]});
+					Model = new dynamoose.Model("Cat", {"id": Number}, {"expires": {"ttl": 1000, "items": {"returnExpired": false}}});
+					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([]);
+				});
+
+				it("Should return expired object if returnExpired is not set", async () => {
+					scanPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "ttl": {"N": "1"}}]});
+					Model = new dynamoose.Model("Cat", {"id": Number}, {"expires": {"ttl": 1000}});
+					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([{"id": 1, "ttl": new Date(1000)}]);
+				});
+
 				it("Should return correct result if unknown properties are in DynamoDB", async () => {
 					scanPromiseResolver = () => ({"Items": [{"id": {"N": "1"}, "name": {"S": "Charlie"}, "age": {"N": "1"}}]});
 					expect((await callType.func(Model.scan().exec).bind(Model.scan())()).map((item) => ({...item}))).to.eql([{"id": 1, "name": "Charlie"}]);
diff --git a/test/Schema.js b/test/Schema.js
index bbdb61c7..1c9d50de 100644
--- a/test/Schema.js
+++ b/test/Schema.js
@@ -1,5 +1,6 @@
 const {expect} = require("chai");
 const Schema = require("../lib/Schema");
+const Error = require("../lib/Error");
 
 describe("Schema", () => {
 	it("Should be a function", () => {
@@ -711,6 +712,46 @@ describe("Schema", () => {
 		});
 	});
 
+	describe("getAttributeTypeDetails", () => {
+		it("Should throw for invalid attribute", () => {
+			const func = () => new Schema({"id": String}).getAttributeTypeDetails("random");
+			expect(func).to.throw(Error.UnknownAttribute);
+			expect(func).to.throw("Invalid Attribute: random");
+		});
+
+		it("Should have correct custom type for date", () => {
+			const functions = new Schema({"id": Date}).getAttributeTypeDetails("id").customType.functions;
+			expect(functions.toDynamo).to.exist;
+			expect(functions.fromDynamo).to.exist;
+			expect(functions.toDynamo(new Date(1582246653000))).to.eql(1582246653000);
+			expect(functions.fromDynamo(1582246653000)).to.eql(new Date(1582246653000));
+		});
+
+		it("Should have correct custom type for date with type object and no settings", () => {
+			const functions = new Schema({"id": {"type": {"value": Date}}}).getAttributeTypeDetails("id").customType.functions;
+			expect(functions.toDynamo).to.exist;
+			expect(functions.fromDynamo).to.exist;
+			expect(functions.toDynamo(new Date(1582246653000))).to.eql(1582246653000);
+			expect(functions.fromDynamo(1582246653000)).to.eql(new Date(1582246653000));
+		});
+
+		it("Should have correct custom type for date with custom storage settings as miliseconds", () => {
+			const functions = new Schema({"id": {"type": {"value": Date, "settings": {"storage": "miliseconds"}}}}).getAttributeTypeDetails("id").customType.functions;
+			expect(functions.toDynamo).to.exist;
+			expect(functions.fromDynamo).to.exist;
+			expect(functions.toDynamo(new Date(1582246653000))).to.eql(1582246653000);
+			expect(functions.fromDynamo(1582246653000)).to.eql(new Date(1582246653000));
+		});
+
+		it("Should have correct custom type for date with custom storage settings as seconds", () => {
+			const functions = new Schema({"id": {"type": {"value": Date, "settings": {"storage": "seconds"}}}}).getAttributeTypeDetails("id").customType.functions;
+			expect(functions.toDynamo).to.exist;
+			expect(functions.fromDynamo).to.exist;
+			expect(functions.toDynamo(new Date(1582246653000))).to.eql(1582246653);
+			expect(functions.fromDynamo(1582246653)).to.eql(new Date(1582246653000));
+		});
+	});
+
 	describe("getAttributeSettingValue", () => {
 		const tests = [
 			// Defaults