UNPKG

16.4 kBJavaScriptView Raw
1const aws = require("./aws");
2const ddb = require("./aws/ddb/internal");
3const utils = require("./utils");
4const Error = require("./Error");
5const {internalProperties} = require("./Internal").General;
6
7const staticMethods = {
8 "toDynamo": (object, settings = {"type": "object"}) => (settings.type === "value" ? aws.converter().input : aws.converter().marshall)(object),
9 "fromDynamo": (object) => aws.converter().unmarshall(object),
10 // 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.
11 "isDynamoObject": function (object, recurrsive = false) {
12 // This function will check to see if a nested object is valid by calling Document.isDynamoObject recursively
13 const isValid = (value) => {
14 if (typeof value === "undefined" || value === null) {
15 return false;
16 }
17 const keys = Object.keys(value);
18 const key = keys[0];
19 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);
20 const Schema = require("./Schema");
21 const attributeType = Schema.attributeTypes.findDynamoDBType(key);
22 return typeof value === "object" && keys.length === 1 && attributeType && (nestedResult || Object.keys(value[key]).length === 0 || attributeType.isSet);
23 };
24
25 const keys = Object.keys(object);
26 const values = Object.values(object);
27 if (keys.length === 0) {
28 return null;
29 } else {
30 return recurrsive ? isValid(object) : values.every((value) => isValid(value));
31 }
32 }
33};
34function applyStaticMethods(item) {
35 Object.entries(staticMethods).forEach((entry) => {
36 const [key, value] = entry;
37 item[key] = value;
38 });
39}
40
41// DocumentCarrier represents an internal concept in Dynamoose used to expose the model to documents that are created.
42// Without this things like `Document.save` wouldn't be able to access things like the TableName needed to save documents.
43// DocumentCarrier is not a concept that needs to be exposed to users of this library.
44function DocumentCarrier(model) {
45 // Document represents an item in a Model that is either pending (not saved) or saved
46 class Document {
47 constructor(object = {}, settings = {}) {
48 const documentObject = Document.isDynamoObject(object) ? aws.converter().unmarshall(object) : object;
49 Object.keys(documentObject).forEach((key) => this[key] = documentObject[key]);
50 Object.defineProperty(this, internalProperties, {
51 "configurable": false,
52 "value": {}
53 });
54 this[internalProperties].originalObject = {...object};
55 this[internalProperties].originalSettings = {...settings};
56
57 if (settings.fromDynamo) {
58 this[internalProperties].storedInDynamo = true;
59 }
60 }
61 }
62
63 applyStaticMethods(Document);
64 // 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.)
65 Document.prepareForObjectFromSchema = function(object, settings) {
66 if (settings.updateTimestamps) {
67 if (model.schema.settings.timestamps && settings.type === "toDynamo") {
68 const date = new Date();
69 // TODO: The last condition of the following is commented out until we can add automated tests for it
70 if (model.schema.settings.timestamps.createdAt && (object[internalProperties] && !object[internalProperties].storedInDynamo)/* && (typeof settings.updateTimestamps === "boolean" || settings.updateTimestamps.createdAt)*/) {
71 object[model.schema.settings.timestamps.createdAt] = date;
72 }
73 if (model.schema.settings.timestamps.updatedAt && (typeof settings.updateTimestamps === "boolean" || settings.updateTimestamps.updatedAt)) {
74 object[model.schema.settings.timestamps.updatedAt] = date;
75 }
76 }
77 }
78 return object;
79 };
80 // This function will return a list of attributes combining both the schema attributes with the document attributes. This also takes into account all attributes that could exist (ex. properties in sets that don't exist in document), adding the indexes for each item in the document set.
81 // https://stackoverflow.com/a/59928314/894067
82 const attributesWithSchemaCache = {};
83 Document.attributesWithSchema = function(document) {
84 const attributes = model.schema.attributes();
85 if (attributesWithSchemaCache[document] && attributesWithSchemaCache[document][attributes]) {
86 return attributesWithSchemaCache[document][attributes];
87 }
88 // build a tree out of schema attributes
89 const root = {};
90 attributes.forEach((attribute) => {
91 let node = root;
92 attribute.split(".").forEach((part) => {
93 node[part] = node[part] || {};
94 node = node[part];
95 });
96 });
97 // explore the tree
98 function traverse (node, treeNode, outPath, callback) {
99 callback(outPath);
100 if (Object.keys(treeNode).length === 0) { // a leaf
101 return;
102 }
103
104 Object.keys(treeNode).forEach((attr) => {
105 if (attr === "0") {
106 if (!node || node.length == 0) {
107 node = [{}]; // fake the path for arrays
108 }
109 node.forEach((a, index) => {
110 outPath.push(index);
111 traverse(node[index], treeNode[attr], outPath, callback);
112 outPath.pop();
113 });
114 } else {
115 if (!node) {
116 node = {}; // fake the path for properties
117 }
118 outPath.push(attr);
119 traverse(node[attr], treeNode[attr], outPath, callback);
120 outPath.pop();
121 }
122 });
123 }
124 const out = [];
125 traverse(document, root, [], (val) => out.push(val.join(".")));
126 const result = out.slice(1);
127 attributesWithSchemaCache[document] = {[attributes]: result};
128 return result;
129 };
130 // 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.
131 Document.objectFromSchema = async function(object, settings = {"type": "toDynamo"}) {
132 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()) {
133 return undefined;
134 }
135
136 const returnObject = {...object};
137 const schemaAttributes = model.schema.attributes();
138 const dynamooseUndefined = require("./index").UNDEFINED;
139
140 // Type check
141 const validParents = []; // This array is used to allow for set contents to not be type checked
142 const keysToDelete = [];
143 const getValueTypeCheckResult = (value, key, options = {}) => {
144 const typeDetails = model.schema.getAttributeTypeDetails(key, options);
145 const isValidType = [((typeDetails.customType || {}).functions || {}).isOfType, typeDetails.isOfType].filter((a) => Boolean(a)).some((func) => func(value, settings.type));
146 return {typeDetails, isValidType};
147 };
148 const checkTypeFunction = (item) => {
149 const [key, value] = item;
150 if (validParents.find((parent) => key.startsWith(parent.key) && (parent.infinite || key.split(".").length === parent.key.split(".").length + 1))) {
151 return;
152 }
153 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
154 const existsInSchema = schemaAttributes.includes(genericKey);
155 if (existsInSchema) {
156 const {isValidType, typeDetails} = getValueTypeCheckResult(value, genericKey, {"standardKey": true});
157 if (!isValidType) {
158 throw new Error.TypeMismatch(`Expected ${key} to be of type ${typeDetails.name.toLowerCase()}, instead found type ${typeof value}.`);
159 } else if (typeDetails.isSet) {
160 validParents.push({key, "infinite": true});
161 } else if (/*typeDetails.dynamodbType === "M" || */typeDetails.dynamodbType === "L") {
162 // The code below is an optimization for large array types to speed up the process of not having to check the type for every element but only the ones that are different
163 value.forEach((subValue, index, array) => {
164 if (index === 0 || typeof subValue !== typeof array[0]) {
165 checkTypeFunction([`${key}.${index}`, subValue]);
166 }
167 });
168 validParents.push({key});
169 }
170 } else {
171 // Check saveUnknown
172 if (!settings.saveUnknown || !utils.dynamoose.wildcard_allowed_check(model.schema.getSettingValue("saveUnknown"), key)) {
173 keysToDelete.push(key);
174 }
175 }
176 };
177 utils.object.entries(returnObject).filter((item) => item[1] !== undefined && item[1] !== dynamooseUndefined).map(checkTypeFunction);
178 keysToDelete.reverse().forEach((key) => utils.object.delete(returnObject, key));
179
180 if (settings.defaults || settings.forceDefault) {
181 await Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
182 const value = utils.object.get(returnObject, key);
183 if (value === dynamooseUndefined) {
184 utils.object.set(returnObject, key, undefined);
185 } else {
186 const defaultValue = await model.schema.defaultCheck(key, value, settings);
187 const isDefaultValueUndefined = typeof defaultValue === "undefined" || defaultValue === null;
188 if (!isDefaultValueUndefined) {
189 const {isValidType, typeDetails} = getValueTypeCheckResult(defaultValue, key);
190 if (!isValidType) {
191 throw new Error.TypeMismatch(`Expected ${key} to be of type ${typeDetails.name.toLowerCase()}, instead found type ${typeof defaultValue}.`);
192 } else {
193 utils.object.set(returnObject, key, defaultValue);
194 }
195 }
196 }
197 }));
198 }
199 // Custom Types
200 if (settings.customTypesDynamo) {
201 Document.attributesWithSchema(returnObject).map((key) => {
202 const value = utils.object.get(returnObject, key);
203 const isValueUndefined = typeof value === "undefined" || value === null;
204 if (!isValueUndefined) {
205 const typeDetails = model.schema.getAttributeTypeDetails(key);
206 const {customType} = typeDetails;
207 const {type: typeInfo} = typeDetails.isOfType(value);
208 const isCorrectTypeAlready = typeInfo === (settings.type === "toDynamo" ? "underlying" : "main");
209 if (customType && !isCorrectTypeAlready) {
210 const customValue = customType.functions[settings.type](value);
211 utils.object.set(returnObject, key, customValue);
212 }
213 }
214 });
215 }
216 // DynamoDB Type Handler (ex. converting sets to correct value for toDynamo & fromDynamo)
217 utils.object.entries(returnObject).filter((item) => typeof item[1] === "object").forEach((item) => {
218 const [key, value] = item;
219 let typeDetails;
220 try {
221 typeDetails = model.schema.getAttributeTypeDetails(key);
222 } catch (e) {
223 const Schema = require("./Schema");
224 typeDetails = Schema.attributeTypes.findTypeForValue(value, settings.type, settings);
225 }
226
227 if (typeDetails && typeDetails[settings.type]) {
228 utils.object.set(returnObject, key, typeDetails[settings.type](value));
229 }
230 });
231 if (settings.modifiers) {
232 await Promise.all(settings.modifiers.map((modifier) => {
233 return Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
234 const value = utils.object.get(returnObject, key);
235 const modifierFunction = await model.schema.getAttributeSettingValue(modifier, key, {"returnFunction": true});
236 const isValueUndefined = typeof value === "undefined" || value === null;
237 if (modifierFunction && !isValueUndefined) {
238 utils.object.set(returnObject, key, await modifierFunction(value));
239 }
240 }));
241 }));
242 }
243 if (settings.validate) {
244 await Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
245 const value = utils.object.get(returnObject, key);
246 const isValueUndefined = typeof value === "undefined" || value === null;
247 if (!isValueUndefined) {
248 const validator = await model.schema.getAttributeSettingValue("validate", key, {"returnFunction": true});
249 if (validator) {
250 let result;
251 if (validator instanceof RegExp) {
252 result = validator.test(value);
253 } else {
254 result = typeof validator === "function" ? await validator(value) : validator === value;
255 }
256
257 if (!result) {
258 throw new Error.ValidationError(`${key} with a value of ${value} had a validation error when trying to save the document`);
259 }
260 }
261 }
262 }));
263 }
264 if (settings.required) {
265 let attributesToCheck = Document.attributesWithSchema(returnObject);
266 if (settings.required === "nested") {
267 attributesToCheck = attributesToCheck.filter((attribute) => utils.object.keys(returnObject).find((key) => attribute.startsWith(key)));
268 }
269 await Promise.all(attributesToCheck.map(async (key) => {
270 async function check() {
271 const value = utils.object.get(returnObject, key);
272 await model.schema.requiredCheck(key, value);
273 }
274
275 const keyParts = key.split(".");
276 const parentKey = keyParts.slice(0, -1).join(".");
277 if (parentKey) {
278 const parentValue = utils.object.get(returnObject, parentKey);
279 const isParentValueUndefined = typeof parentValue === "undefined" || parentValue === null;
280 if (!isParentValueUndefined) {
281 await check();
282 }
283 } else {
284 await check();
285 }
286 }));
287 }
288 if (settings.enum) {
289 await Promise.all(Document.attributesWithSchema(returnObject).map(async (key) => {
290 const value = utils.object.get(returnObject, key);
291 const isValueUndefined = typeof value === "undefined" || value === null;
292 if (!isValueUndefined) {
293 const enumArray = await model.schema.getAttributeSettingValue("enum", key);
294 if (enumArray && !enumArray.includes(value)) {
295 throw new Error.ValidationError(`${key} must equal ${JSON.stringify(enumArray)}, but is set to ${value}`);
296 }
297 }
298 }));
299 }
300
301 return returnObject;
302 };
303 Document.prototype.toDynamo = async function(settings = {}) {
304 settings.type = "toDynamo";
305 Document.prepareForObjectFromSchema(this, settings);
306 const object = await Document.objectFromSchema(this, settings);
307 return Document.toDynamo(object);
308 };
309 Document.prototype.save = function(settings = {}, callback) {
310 if (typeof settings === "function" && !callback) {
311 callback = settings;
312 settings = {};
313 }
314
315 const paramsPromise = this.toDynamo({"defaults": true, "validate": true, "required": true, "enum": true, "forceDefault": true, "saveUnknown": true, "customTypesDynamo": true, "updateTimestamps": true, "modifiers": ["set"]}).then((item) => {
316 const putItemObj = {
317 "Item": item,
318 "TableName": Document.Model.name
319 };
320
321 if (settings.overwrite === false) {
322 putItemObj.ConditionExpression = "attribute_not_exists(#__hash_key)";
323 putItemObj.ExpressionAttributeNames = {"#__hash_key": model.schema.getHashKey()};
324 }
325
326 return putItemObj;
327 });
328 if (settings.return === "request") {
329 if (callback) {
330 paramsPromise.then((result) => callback(null, result));
331 return;
332 } else {
333 return paramsPromise;
334 }
335 }
336 const promise = Promise.all([paramsPromise, Document.Model.pendingTaskPromise()]).then((promises) => {
337 const [putItemObj] = promises;
338 return ddb("putItem", putItemObj);
339 });
340
341 if (callback) {
342 promise.then(() => {this[internalProperties].storedInDynamo = true; return callback(null, this);}).catch((error) => callback(error));
343 } else {
344 return (async () => {
345 await promise;
346 this[internalProperties].storedInDynamo = true;
347 return this;
348 })();
349 }
350 };
351 Document.prototype.delete = function(callback) {
352 return model.delete({
353 [model.schema.getHashKey()]: this[model.schema.getHashKey()]
354 }, callback);
355 };
356 // This function will modify the document to conform to the Schema
357 Document.prototype.conformToSchema = async function(settings = {"type": "fromDynamo"}) {
358 Document.prepareForObjectFromSchema(this, settings);
359 const expectedObject = await Document.objectFromSchema(this, settings);
360 if (!expectedObject) {
361 return expectedObject;
362 }
363 const expectedKeys = Object.keys(expectedObject);
364 Object.keys(this).forEach((key) => {
365 if (!expectedKeys.includes(key)) {
366 delete this[key];
367 } else if (this[key] !== expectedObject[key]) {
368 this[key] = expectedObject[key];
369 }
370 });
371
372 return this;
373 };
374
375 Document.prototype.original = function() {
376 return this[internalProperties].originalSettings.type === "fromDynamo" ? this[internalProperties].originalObject : null;
377 };
378
379 Document.Model = model;
380
381 return Document;
382}
383
384applyStaticMethods(DocumentCarrier);
385
386module.exports = DocumentCarrier;