1 | const Error = require("./Error");
|
2 | const utils = require("./utils");
|
3 | const Internal = require("./Internal");
|
4 | const Document = require("./Document");
|
5 | const internalCache = Internal.Schema.internalCache;
|
6 |
|
7 | class Schema {
|
8 | constructor(object, settings = {}) {
|
9 | if (!object || typeof object !== "object" || Array.isArray(object)) {
|
10 | throw new Error.InvalidParameterType("Schema initalization parameter must be an object.");
|
11 | }
|
12 | if (Object.keys(object).length === 0) {
|
13 | throw new Error.InvalidParameter("Schema initalization parameter must not be an empty object.");
|
14 | }
|
15 |
|
16 | if (settings.timestamps === true) {
|
17 | settings.timestamps = {
|
18 | "createdAt": "createdAt",
|
19 | "updatedAt": "updatedAt"
|
20 | };
|
21 | }
|
22 | if (settings.timestamps) {
|
23 | if (object[settings.timestamps.createdAt] || object[settings.timestamps.updatedAt]) {
|
24 | throw new Error.InvalidParameter("Timestamp attributes must not be defined in schema.");
|
25 | }
|
26 |
|
27 | object[settings.timestamps.createdAt] = Date;
|
28 | object[settings.timestamps.updatedAt] = Date;
|
29 | }
|
30 |
|
31 |
|
32 | this.schemaObject = object;
|
33 | this.settings = settings;
|
34 | Object.defineProperty(this, internalCache, {
|
35 | "configurable": false,
|
36 | "value": {
|
37 | "getAttributeTypeDetails": {}
|
38 | }
|
39 | });
|
40 |
|
41 | const checkAttributeNameDots = (object/*, existingKey = ""*/) => {
|
42 | return Object.keys(object).forEach((key) => {
|
43 | if (key.includes(".")) {
|
44 | throw new Error.InvalidParameter("Attributes must not contain dots.");
|
45 | }
|
46 |
|
47 | if (typeof object[key] === "object" && object[key].schema) {
|
48 | checkAttributeNameDots(object[key].schema, key);
|
49 | }
|
50 | });
|
51 | };
|
52 | checkAttributeNameDots(this.schemaObject);
|
53 |
|
54 | const checkMultipleArraySchemaElements = (key) => {
|
55 | let attributeType;
|
56 | try {
|
57 | attributeType = this.getAttributeType(key);
|
58 | } catch (e) {}
|
59 |
|
60 | if (attributeType === "L" && (this.getAttributeValue(key).schema || []).length > 1) {
|
61 | throw new Error.InvalidParameter("You must only pass one element into schema array.");
|
62 | }
|
63 | };
|
64 | this.attributes().forEach((key) => checkMultipleArraySchemaElements(key));
|
65 | }
|
66 | }
|
67 |
|
68 | Schema.prototype.getHashKey = function() {
|
69 | return Object.keys(this.schemaObject).find((key) => this.schemaObject[key].hashKey) || Object.keys(this.schemaObject)[0];
|
70 | };
|
71 | Schema.prototype.getRangeKey = function() {
|
72 | return Object.keys(this.schemaObject).find((key) => this.schemaObject[key].rangeKey);
|
73 | };
|
74 |
|
75 | Schema.prototype.getAttributeSettingValue = function(setting, key, settings = {}) {
|
76 | const defaultPropertyValue = (this.getAttributeValue(key) || {})[setting];
|
77 | return typeof defaultPropertyValue === "function" && !settings.returnFunction ? defaultPropertyValue() : defaultPropertyValue;
|
78 | };
|
79 |
|
80 |
|
81 | Schema.prototype.requiredCheck = async function(key, value) {
|
82 | const isRequired = await this.getAttributeSettingValue("required", key);
|
83 | if ((typeof value === "undefined" || value === null) && isRequired) {
|
84 | throw new Error.ValidationError(`${key} is a required property but has no value when trying to save document`);
|
85 | }
|
86 | };
|
87 |
|
88 | Schema.prototype.defaultCheck = async function(key, value, settings) {
|
89 | const isValueUndefined = typeof value === "undefined" || value === null;
|
90 | if ((settings.defaults && isValueUndefined) || (settings.forceDefault && await this.getAttributeSettingValue("forceDefault", key))) {
|
91 | const defaultValue = await this.getAttributeSettingValue("default", key);
|
92 | const isDefaultValueUndefined = typeof defaultValue === "undefined" || defaultValue === null;
|
93 | if (!isDefaultValueUndefined) {
|
94 | return defaultValue;
|
95 | }
|
96 | }
|
97 | };
|
98 |
|
99 | Schema.prototype.getIndexAttributes = async function() {
|
100 | return (await Promise.all(this.attributes().map(async (attribute) => ({"index": await this.getAttributeSettingValue("index", attribute), attribute})))).filter((obj) => obj.index);
|
101 | };
|
102 | Schema.prototype.getIndexRangeKeyAttributes = async function() {
|
103 | const indexes = await this.getIndexAttributes();
|
104 | return indexes.map((index) => index.index.rangeKey).filter((a) => Boolean(a)).map((a) => ({"attribute": a}));
|
105 | };
|
106 | Schema.prototype.getIndexes = async function(model) {
|
107 | return (await this.getIndexAttributes()).reduce((accumulator, currentValue) => {
|
108 | let indexValue = currentValue.index;
|
109 | const attributeValue = currentValue.attribute;
|
110 |
|
111 | const dynamoIndexObject = {
|
112 | "IndexName": indexValue.name || `${attributeValue}${indexValue.global ? "GlobalIndex" : "LocalIndex"}`,
|
113 | "KeySchema": [{"AttributeName": attributeValue, "KeyType": "HASH"}],
|
114 | "Projection": {"ProjectionType": "KEYS_ONLY"}
|
115 | };
|
116 | if (indexValue.project || typeof indexValue.project === "undefined" || indexValue.project === null) {
|
117 | dynamoIndexObject.Projection = Array.isArray(indexValue.project) ? ({"ProjectionType": "INCLUDE", "NonKeyAttributes": indexValue.project}) : ({"ProjectionType": "ALL"});
|
118 | }
|
119 | if (indexValue.rangeKey) {
|
120 | dynamoIndexObject.KeySchema.push({"AttributeName": indexValue.rangeKey, "KeyType": "RANGE"});
|
121 | }
|
122 | if (indexValue.global) {
|
123 | const throughputObject = utils.dynamoose.get_provisioned_throughput(indexValue.throughput ? indexValue : model.options);
|
124 | if (throughputObject.ProvisionedThroughput) {
|
125 | dynamoIndexObject.ProvisionedThroughput = throughputObject.ProvisionedThroughput;
|
126 | }
|
127 | }
|
128 | if (!accumulator[(indexValue.global ? "GlobalSecondaryIndexes" : "LocalSecondaryIndexes")]) {
|
129 | accumulator[(indexValue.global ? "GlobalSecondaryIndexes" : "LocalSecondaryIndexes")] = [];
|
130 | }
|
131 | accumulator[(indexValue.global ? "GlobalSecondaryIndexes" : "LocalSecondaryIndexes")].push(dynamoIndexObject);
|
132 |
|
133 | return accumulator;
|
134 | }, {});
|
135 | };
|
136 |
|
137 | Schema.prototype.getSettingValue = function(setting) {
|
138 | return this.settings[setting];
|
139 | };
|
140 |
|
141 | function attributes() {
|
142 | const main = (object, existingKey = "") => {
|
143 | return Object.keys(object).reduce((accumulator, key) => {
|
144 | const keyWithExisting = `${existingKey ? `${existingKey}.` : ""}${key}`;
|
145 | accumulator.push(keyWithExisting);
|
146 |
|
147 | let attributeType;
|
148 | try {
|
149 | attributeType = this.getAttributeType(keyWithExisting);
|
150 | } catch (e) {}
|
151 |
|
152 | if ((attributeType === "M" || attributeType === "L") && object[key].schema) {
|
153 | accumulator.push(...main(object[key].schema, keyWithExisting));
|
154 | }
|
155 |
|
156 | return accumulator;
|
157 | }, []);
|
158 | };
|
159 |
|
160 | return main(this.schemaObject);
|
161 | }
|
162 | Schema.prototype.attributes = function() {
|
163 | if (!this[internalCache].attributes) {
|
164 | this[internalCache].attributes = attributes.call(this);
|
165 | }
|
166 |
|
167 | return this[internalCache].attributes;
|
168 | };
|
169 |
|
170 | Schema.prototype.getAttributeValue = function(key, settings = {}) {
|
171 | return (settings.standardKey ? key : key.replace(/\.\d+/gu, ".0")).split(".").reduce((result, part) => utils.object.get(result.schema, part), {"schema": this.schemaObject});
|
172 | };
|
173 |
|
174 | class DynamoDBType {
|
175 | constructor(obj) {
|
176 | Object.keys(obj).forEach((key) => {
|
177 | this[key] = obj[key];
|
178 | });
|
179 | }
|
180 |
|
181 | result(typeSettings) {
|
182 | const isSubType = this.dynamodbType instanceof DynamoDBType;
|
183 | const type = isSubType ? this.dynamodbType : this;
|
184 | const result = {
|
185 | "name": this.name,
|
186 | "dynamodbType": isSubType ? this.dynamodbType.dynamodbType : this.dynamodbType,
|
187 | "nestedType": this.nestedType
|
188 | };
|
189 | result.isOfType = this.jsType.func ? this.jsType.func : ((val) => {
|
190 | return [{"value": this.jsType, "type": "main"}, {"value": (isSubType ? type.jsType : null), "type": "underlying"}].filter((a) => Boolean(a.value)).find((jsType) => typeof jsType.value === "string" ? typeof val === jsType.value : val instanceof jsType.value);
|
191 | });
|
192 | if (type.set) {
|
193 | const typeName = type.customDynamoName || type.name;
|
194 | result.set = {
|
195 | "name": `${this.name} Set`,
|
196 | "isSet": true,
|
197 | "dynamodbType": `${type.dynamodbType}S`,
|
198 | "isOfType": (val, type, settings = {}) => {
|
199 | if (type === "toDynamo") {
|
200 | return (!settings.saveUnknown && Array.isArray(val) && val.every((subValue) => result.isOfType(subValue))) || (val instanceof Set && [...val].every((subValue) => result.isOfType(subValue)));
|
201 | } else {
|
202 | return val.wrapperName === "Set" && val.type === typeName && Array.isArray(val.values);
|
203 | }
|
204 | },
|
205 | "toDynamo": (val) => ({"wrapperName": "Set", "type": typeName, "values": [...val]}),
|
206 | "fromDynamo": (val) => new Set(val.values)
|
207 | };
|
208 | if (this.customType) {
|
209 | const functions = this.customType.functions(typeSettings);
|
210 | result.customType = {
|
211 | ...this.customType,
|
212 | functions
|
213 | };
|
214 | result.set.customType = {
|
215 | "functions": {
|
216 | "toDynamo": (val) => val.map(functions.toDynamo),
|
217 | "fromDynamo": (val) => ({"values": val.values.map(functions.fromDynamo)}),
|
218 | "isOfType": (val, type) => {
|
219 | if (type === "toDynamo") {
|
220 | return Array.isArray(val) && val.every(functions.isOfType);
|
221 | } else {
|
222 | return val.wrapperName === "Set" && val.type === typeName && Array.isArray(val.values);
|
223 | }
|
224 | }
|
225 | }
|
226 | };
|
227 | }
|
228 | }
|
229 |
|
230 | return result;
|
231 | }
|
232 | }
|
233 |
|
234 | const attributeTypesMain = (() => {
|
235 | const numberType = new DynamoDBType({"name": "Number", "dynamodbType": "N", "set": true, "jsType": "number"});
|
236 | return [
|
237 | new DynamoDBType({"name": "Buffer", "dynamodbType": "B", "set": true, "jsType": Buffer, "customDynamoName": "Binary"}),
|
238 | new DynamoDBType({"name": "Boolean", "dynamodbType": "BOOL", "jsType": "boolean"}),
|
239 | new DynamoDBType({"name": "Array", "dynamodbType": "L", "jsType": {"func": Array.isArray}, "nestedType": true}),
|
240 | new DynamoDBType({"name": "Object", "dynamodbType": "M", "jsType": {"func": (val) => Boolean(val) && val.constructor === Object && (val.wrapperName !== "Set" || Object.keys(val).length !== 3 || !val.type || !val.values)}, "nestedType": true}),
|
241 | numberType,
|
242 | new DynamoDBType({"name": "String", "dynamodbType": "S", "set": true, "jsType": "string"}),
|
243 | new DynamoDBType({"name": "Date", "dynamodbType": numberType, "customType": {
|
244 | "functions": (typeSettings) => ({
|
245 | "toDynamo": (val) => {
|
246 | if (typeSettings.storage === "seconds") {
|
247 | return Math.round(val.getTime() / 1000);
|
248 | } else {
|
249 | return val.getTime();
|
250 | }
|
251 | },
|
252 | "fromDynamo": (val) => {
|
253 | if (typeSettings.storage === "seconds") {
|
254 | return new Date(val * 1000);
|
255 | } else {
|
256 | return new Date(val);
|
257 | }
|
258 | },
|
259 | "isOfType": (val, type) => {
|
260 | return type === "toDynamo" ? val instanceof Date : typeof val === "number";
|
261 | }
|
262 | })
|
263 | }, "jsType": Date})
|
264 | ];
|
265 | })();
|
266 | const attributeTypes = utils.array_flatten(attributeTypesMain.filter((checkType) => !checkType.customType).map((checkType) => checkType.result()).map((a) => [a, a.set])).filter((a) => Boolean(a));
|
267 | function retrieveTypeInfo(type, isSet, key, typeSettings) {
|
268 | const foundType = attributeTypesMain.find((checkType) => checkType.name.toLowerCase() === type.toLowerCase());
|
269 | if (!foundType) {
|
270 | throw new Error.InvalidType(`${key} contains an invalid type: ${type}`);
|
271 | }
|
272 | const parentType = foundType.result(typeSettings);
|
273 | if (!parentType.set && isSet) {
|
274 | throw new Error.InvalidType(`${key} with type: ${type} is not allowed to be a set`);
|
275 | }
|
276 | return isSet ? parentType.set : parentType;
|
277 | }
|
278 | Schema.prototype.getAttributeTypeDetails = function(key, settings = {}) {
|
279 | const standardKey = (settings.standardKey ? key : key.replace(/\.\d+/gu, ".0"));
|
280 | if (this[internalCache].getAttributeTypeDetails[standardKey]) {
|
281 | return this[internalCache].getAttributeTypeDetails[standardKey];
|
282 | }
|
283 | const val = this.getAttributeValue(standardKey, {"standardKey": true});
|
284 | if (!val) {
|
285 | throw new Error.UnknownAttribute(`Invalid Attribute: ${key}`);
|
286 | }
|
287 | let typeVal = typeof val === "object" && !Array.isArray(val) ? val.type : val;
|
288 | let typeSettings = {};
|
289 | if (typeof typeVal === "object" && !Array.isArray(typeVal)) {
|
290 | typeSettings = typeVal.settings || {};
|
291 | typeVal = typeVal.value;
|
292 | }
|
293 |
|
294 | const getType = (typeVal) => {
|
295 | let type;
|
296 | if (typeof typeVal === "function") {
|
297 | const regexFuncName = /^Function ([^(]+)\(/iu;
|
298 | [, type] = typeVal.toString().match(regexFuncName);
|
299 | } else {
|
300 | type = typeVal;
|
301 | }
|
302 | return type;
|
303 | };
|
304 | let type = getType(typeVal);
|
305 | const isSet = type.toLowerCase() === "set";
|
306 | if (isSet) {
|
307 | type = getType(this.getAttributeSettingValue("schema", key)[0]);
|
308 | }
|
309 |
|
310 | const returnObject = retrieveTypeInfo(type, isSet, key, typeSettings);
|
311 | this[internalCache].getAttributeTypeDetails[standardKey] = returnObject;
|
312 | return returnObject;
|
313 | };
|
314 | Schema.prototype.getAttributeType = function(key, value, settings = {}) {
|
315 | try {
|
316 | return this.getAttributeTypeDetails(key).dynamodbType;
|
317 | } catch (e) {
|
318 | if (settings.unknownAttributeAllowed && e.message === `Invalid Attribute: ${key}` && value) {
|
319 | return Object.keys(Document.toDynamo(value, {"type": "value"}))[0];
|
320 | } else {
|
321 | throw e;
|
322 | }
|
323 | }
|
324 | };
|
325 |
|
326 | Schema.prototype.getCreateTableAttributeParams = async function(model) {
|
327 | const hashKey = this.getHashKey();
|
328 | const AttributeDefinitions = [
|
329 | {
|
330 | "AttributeName": hashKey,
|
331 | "AttributeType": this.getAttributeType(hashKey)
|
332 | }
|
333 | ];
|
334 | const AttributeDefinitionsNames = [hashKey];
|
335 | const KeySchema = [
|
336 | {
|
337 | "AttributeName": hashKey,
|
338 | "KeyType": "HASH"
|
339 | }
|
340 | ];
|
341 |
|
342 | const rangeKey = this.getRangeKey();
|
343 | if (rangeKey) {
|
344 | AttributeDefinitions.push({
|
345 | "AttributeName": rangeKey,
|
346 | "AttributeType": this.getAttributeType(rangeKey)
|
347 | });
|
348 | AttributeDefinitionsNames.push(rangeKey);
|
349 | KeySchema.push({
|
350 | "AttributeName": rangeKey,
|
351 | "KeyType": "RANGE"
|
352 | });
|
353 | }
|
354 |
|
355 | utils.array_flatten(await Promise.all([this.getIndexAttributes(), this.getIndexRangeKeyAttributes()])).map((obj) => obj.attribute).forEach((index) => {
|
356 | if (AttributeDefinitionsNames.includes(index)) {
|
357 | return;
|
358 | }
|
359 |
|
360 | AttributeDefinitionsNames.push(index);
|
361 | AttributeDefinitions.push({
|
362 | "AttributeName": index,
|
363 | "AttributeType": this.getAttributeType(index)
|
364 | });
|
365 | });
|
366 |
|
367 | return {
|
368 | AttributeDefinitions,
|
369 | KeySchema,
|
370 | ...await this.getIndexes(model)
|
371 | };
|
372 | };
|
373 |
|
374 |
|
375 | module.exports = Schema;
|
376 | module.exports.attributeTypes = {
|
377 | "findDynamoDBType": (type) => attributeTypes.find((checkType) => checkType.dynamodbType === type),
|
378 | "findTypeForValue": (...args) => attributeTypes.find((checkType) => checkType.isOfType(...args))
|
379 | };
|