dynamoose
Version:
Dynamoose is a modeling tool for Amazon's DynamoDB (inspired by Mongoose)
722 lines (603 loc) • 17.5 kB
JavaScript
'use strict';
const debug = require('debug')('dynamoose:attribute');
const util = require('util');
const errors = require('./errors');
function Attribute(schema, name, value) {
this.options = {};
debug('Creating attribute %s %o', name, value);
if (value.type){
this.options = value;
}
this.schema = schema;
this.name = name;
if (this.schema.options.saveUnknown) {
this.setTypeFromRawValue(value);
} else {
this.setType(value);
}
if(!schema.useDocumentTypes) {
if(this.type.name === 'map') {
debug('Overwriting attribute %s type to object', name);
this.type = this.types.object;
} else if (this.type.name === 'list') {
debug('Overwriting attribute %s type to array', name);
this.type = this.types.array;
}
}
if (schema.useNativeBooleans) {
if(this.type.name === 'boolean') {
debug('Overwriting attribute %s type to be a native boolean', name);
this.type = this.types.nativeBoolean;
}
}
this.attributes = {};
if (this.type.name === 'map'){
if(value.type) {
value = value.map;
}
for (const subattrName in value){
if(this.attributes[subattrName]) {
throw new errors.SchemaError(`Duplicate attribute: ${subattrName} in ${this.name}`);
}
this.attributes[subattrName] = module.exports.create(schema, subattrName, value[subattrName]);
}
}
else if (this.type.name === 'list'){
if(value.type) {
value = value.list;
}
if (value === undefined && value[0] === undefined){
throw new errors.SchemaError('No object given for attribute:' + this.name );
}
// stang: Don't know what this guard is for - had to remove because when parsing unknown attributes, this is legal?
// if (value.length > 1){
// throw new errors.SchemaError('Only one object can be defined as a list type in ' + this.name );
// }
for (let i = 0; i < value.length; i++) {
this.attributes[i] = module.exports.create(schema, 0, value[i]);
}
}
if (this.options){
this.applyDefault(this.options.default);
this.required = this.options.required;
this.set = this.options.set;
this.parseDynamoCustom = this.options.fromDynamo;
this.toDynamoCustom = this.options.toDynamo;
this.get = this.options.get;
this.applyValidation(this.options.validate);
this.applyIndexes(this.options.index);
}
}
function datify(v) {
if(!v.getTime) {
v = new Date(v);
}
return JSON.stringify(v.getTime());
}
Attribute.prototype.types = {
string: {
name: 'string',
dynamo: 'S'
},
number: {
name: 'number',
dynamo: 'N'
},
boolean: {
name: 'boolean',
dynamo: 'S',
dynamofy: JSON.stringify
},
nativeBoolean: {
name: 'boolean',
dynamo: 'BOOL'
},
date: {
name: 'date',
dynamo: 'N',
dynamofy: datify
},
object: {
name: 'object',
dynamo: 'S',
dynamofy: JSON.stringify
},
array: {
name: 'array',
dynamo: 'S',
dynamofy: JSON.stringify
},
map: {
name: 'map',
dynamo: 'M',
dynamofy: JSON.stringify
},
list: {
name: 'list',
dynamo: 'L',
dynamofy: JSON.stringify
},
buffer: {
name: 'buffer',
dynamo: 'B'
}
};
Attribute.prototype.setTypeFromRawValue = function(value) {
//no type defined - assume this is not a type definition and we must grab type directly from value
let type;
let typeVal = value;
if (value.type){
typeVal = value.type;
}
if (util.isArray(typeVal) || typeVal === 'list'){
type = 'List';
} else if ( (util.isArray(typeVal) && typeVal.length === 1) || typeof typeVal === 'function') {
this.isSet = util.isArray(typeVal);
let regexFuncName = /^Function ([^(]+)\(/i;
let found = typeVal.toString().match(regexFuncName);
type = found[1];
if (type === 'Object') {
type = 'Map';
}
} else if (typeof typeVal === 'object' || typeVal === 'map'){
type = 'Map';
} else {
type = typeof typeVal;
}
if(!type) {
throw new errors.SchemaError('Invalid attribute type: ' + type);
}
type = type.toLowerCase();
this.type = this.types[type];
if(!this.type) {
throw new errors.SchemaError('Invalid attribute type: ' + type);
}
};
Attribute.prototype.setType = function(value) {
if(!value) {
throw new errors.SchemaError('Invalid attribute value: ' + value);
}
let type;
let typeVal = value;
if (value.type){
typeVal = value.type;
}
if (util.isArray(typeVal) && typeVal.length === 1 && typeof typeVal[0] === 'object'){
type = 'List';
} else if ( (util.isArray(typeVal) && typeVal.length === 1) || typeof typeVal === 'function') {
this.isSet = util.isArray(typeVal);
let regexFuncName = /^Function ([^(]+)\(/i;
let found = typeVal.toString().match(regexFuncName);
type = found[1];
} else if (typeof typeVal === 'object'){
type = 'Map';
} else if (typeof typeVal === 'string') {
type = typeVal;
}
if(!type) {
throw new errors.SchemaError('Invalid attribute type: ' + type);
}
type = type.toLowerCase();
this.type = this.types[type];
if(!this.type) {
throw new errors.SchemaError('Invalid attribute type: ' + type);
}
};
Attribute.prototype.applyDefault = function(dflt) {
if(dflt === null || dflt === undefined){
delete this.default;
} else if(typeof dflt === 'function') {
this.default = dflt;
} else {
this.default = function() {
return dflt;
};
}
};
Attribute.prototype.applyValidation = function(validator) {
if(validator === null || validator === undefined) {
delete this.validator;
} else if(typeof validator === 'function') {
this.validator = validator;
} else if(validator.constructor.name === 'RegExp') {
this.validator = function (val) {
return validator.test(val);
};
} else {
this.validator = function (val) {
return validator === val;
};
}
};
Attribute.prototype.applyIndexes = function(indexes) {
if(indexes === null || indexes === undefined) {
delete this.indexes;
return;
}
let attr = this;
attr.indexes = {};
function applyIndex(i) {
if(typeof i !== 'object') {
i = {};
}
let index = {};
if(i.global) {
index.global = true;
if(i.rangeKey) {
index.rangeKey = i.rangeKey;
}
if(i.throughput) {
let throughput = i.throughput;
if(typeof throughput === 'number') {
throughput = {read: throughput, write: throughput};
}
index.throughput = throughput;
if((!index.throughput.read || !index.throughput.write) && index.throughput.read >= 1 && index.throughput.write >= 1) {
throw new errors.SchemaError('Invalid Index throughput: '+ index.throughput);
}
} else {
index.throughput = attr.schema.throughput;
}
}
if(i.name) {
index.name = i.name;
} else {
index.name = attr.name + (i.global ? 'GlobalIndex' : 'LocalIndex');
}
if(i.project !== null && i.project !== undefined) {
index.project = i.project;
} else {
index.project = true;
}
if(attr.indexes[index.name]) {
throw new errors.SchemaError('Duplicate index names: ' + index.name);
}
attr.indexes[index.name] = index;
}
if(util.isArray(indexes)) {
indexes.map(applyIndex);
} else {
applyIndex(indexes);
}
};
Attribute.prototype.setDefault = function(model) {
if (model === undefined || model === null){ return;}
let val = model[this.name];
if((val === null || val === undefined || val === '' || this.options.forceDefault) && this.default) {
model[this.name] = this.default(model);
debug('Defaulted %s to %s', this.name, model[this.name]);
}
};
Attribute.prototype.toDynamo = function(val, noSet, model, options) {
if (this.toDynamoCustom) {
return this.toDynamoCustom(val, noSet, model, options);
}
if(val === null || val === undefined || val === '') {
if(this.required) {
throw new errors.ValidationError('Required value missing: ' + this.name);
}
return null;
}
if(!noSet && this.isSet){
if(!util.isArray(val)) {
throw new errors.ValidationError('Values must be array: ' + this.name);
}
if(val.length === 0) {
return null;
}
}
if(this.validator && !this.validator(val, model)) {
throw new errors.ValidationError('Validation failed: ' + this.name);
}
// Check to see if attribute is a timestamp
let runSet = true;
let isExpires = false;
let isTimestamp = false;
if (model && model.$__ && model.$__.schema && model.$__.schema.timestamps && (model.$__.schema.timestamps.createdAt === this.name || model.$__.schema.timestamps.updatedAt === this.name)) {
isTimestamp = true;
}
if (model && model.$__ && model.$__.schema && model.$__.schema.expires && (model.$__.schema.expires.attribute === this.name)) {
isExpires = true;
}
if (isTimestamp && options.updateTimestamps === false) {
runSet = false;
}
if (isExpires && options.updateExpires === true) {
val = this.default();
runSet = true;
}
if (this.set && runSet) {
val = this.set(val);
}
let type = this.type;
const isSet = this.isSet && !noSet;
let dynamoObj = {};
if(isSet) {
dynamoObj[type.dynamo + 'S'] = val.map(function(v) {
if(type.dynamofy) {
return type.dynamofy(v);
}
v = v.toString();
if(type.dynamo === 'S') {
if(this.options.trim) {
v = v.trim();
}
if(this.options.lowercase) {
v = v.toLowerCase();
}
if(this.options.uppercase) {
v = v.toUpperCase();
}
}
return v;
}.bind(this));
} else if (type.name === 'map') {
let dynamoMapObj = {};
for(const name in this.attributes) {
const attr = this.attributes[name];
attr.setDefault(model);
const dynamoAttr = attr.toDynamo(val[name], undefined, model);
if(dynamoAttr) {
dynamoMapObj[attr.name] = dynamoAttr;
}
}
dynamoObj.M = dynamoMapObj;
} else if (type.name === 'list') {
if(!util.isArray(val)) {
throw new errors.ValidationError('Values must be array in a `list`: ' + this.name);
}
let dynamoList = [];
for (let i = 0; i < val.length; i++) {
const item = val[i];
// TODO currently only supports one attribute type
const objAttr = this.attributes[0];
if (objAttr){
objAttr.setDefault(model);
dynamoList.push(objAttr.toDynamo(item, undefined, model));
}
}
dynamoObj.L = dynamoList;
} else {
if(type.dynamofy) {
val = type.dynamofy(val);
}
if(type.dynamo !== 'BOOL' && (type.dynamo !== 'B' || !(val instanceof Buffer))) {
val = val.toString();
}
if(type.dynamo === 'S') {
if (this.options.enum) {
if (this.options.enum.indexOf(val) === -1) {
throw new errors.ValidationError('Value must be one of : ' + JSON.stringify(this.options.enum));
}
}
if(this.options.trim) {
val = val.trim();
}
if(this.options.lowercase) {
val = val.toLowerCase();
}
if(this.options.uppercase) {
val = val.toUpperCase();
}
}
dynamoObj[type.dynamo] = val;
}
debug('toDynamo %j', dynamoObj);
return dynamoObj;
};
Attribute.prototype.parseDynamo = function(json) {
if (this.parseDynamoCustom) {
return this.parseDynamoCustom(json);
}
function dedynamofy(type, isSet, json, transform, attr) {
try {
if(!json){
return;
}
if(isSet) {
const set = json[type + 'S'];
return set.map(function (v) {
if(transform) {
return transform(v);
}
return v;
});
}
const val = json[type];
if(transform) {
return transform((val !== undefined) ? val : json, attr);
}
return val;
} catch (e) {
throw new errors.ParseError(`Attribute "${attr.name}" of type "${type}" has an invalid value of "${json[Object.keys(json)[0]]}"`, e);
}
}
function mapify(v, attr){
if(!v){ return; }
let val = {};
for(const attrName in attr.attributes) {
const attrVal = attr.attributes[attrName].parseDynamo(v[attrName]);
if(attrVal !== undefined && attrVal !== null){
val[attrName] = attrVal;
}
}
return val;
}
function listify(v, attr){
if(!v){ return; }
let val = [];
debug('parsing list');
if (util.isArray(v)){
for (let i = 0; i < v.length ; i++){
// TODO assume only one attribute type allowed for a list
const attrType = attr.attributes[0];
const attrVal = attrType.parseDynamo(v[i]);
if(attrVal !== undefined && attrVal !== null){
val.push(attrVal);
}
}
}
return val;
}
function datify(v) {
debug('parsing date from %s', v);
return new Date(parseInt(v, 10));
}
function bufferify(v) {
return Buffer.from(v);
}
function stringify(v){
if (typeof v !== 'string'){
debug('******', v);
return JSON.stringify(v);
}
return v;
}
let val;
switch(this.type.name) {
case 'string':
val = dedynamofy('S', this.isSet, json, stringify, this);
break;
case 'number':
val = dedynamofy('N', this.isSet, json, JSON.parse, this);
break;
case 'boolean':
// 'S' is backwards compatible however 'BOOL' is a new valid argument
val = dedynamofy(this.type.dynamo, this.isSet, json, JSON.parse, this);
break;
case 'date':
val = dedynamofy('N', this.isSet, json, datify, this);
break;
case 'object':
val = dedynamofy('S', this.isSet, json, JSON.parse, this);
break;
case 'array':
val = dedynamofy('S', this.isSet, json, JSON.parse, this);
break;
case 'map':
val = dedynamofy('M', this.isSet, json, mapify, this);
break;
case 'list':
val = dedynamofy('L', this.isSet, json, listify, this);
break;
case 'buffer':
val = dedynamofy('B', this.isSet, json, bufferify, this);
break;
default:
throw new errors.SchemaError('Invalid attribute type: ' + this.type);
}
if(this.get) {
val = this.get(val);
}
debug('parseDynamo: %s : "%s" : %j', this.name, this.type.name, val);
return val;
};
/*
* Converts DynamoDB document types (Map and List) to dynamoose
* attribute definition map and ist types
*
* For example, DynamoDB value:
* {
* M: {
* subAttr1: { S: '' },
* subAttr2: { N: '' },
* }
* }
*
* to
* {
* type: 'map',
* map: {
* subAttr1: { type: String },
* subAttr1: { type: Number },
* },
* }
*/
function createAttrDefFromDynamo(dynamoAttribute) {
let dynamoType;
let attrDef = {
type: module.exports.lookupType(dynamoAttribute),
};
if (attrDef.type === Object) {
attrDef.type = 'map';
for (dynamoType in dynamoAttribute) {
attrDef.map = {};
for (const subAttrName in dynamoAttribute[dynamoType]) {
attrDef.map[subAttrName] = createAttrDefFromDynamo(dynamoAttribute[dynamoType][subAttrName]);
}
}
} else if (attrDef.type === Array) {
attrDef.type = 'list';
for (dynamoType in dynamoAttribute) {
attrDef.list = dynamoAttribute[dynamoType].map(createAttrDefFromDynamo);
}
}
return attrDef;
}
module.exports.createUnknownAttrbuteFromDynamo = function(schema, name, dynamoAttribute) {
debug('createUnknownAttrbuteFromDynamo: %j : "%s" : %j', schema, name, dynamoAttribute);
const attrDef = createAttrDefFromDynamo(dynamoAttribute);
const attr = new Attribute(schema, name, attrDef);
return attr;
};
module.exports.create = function(schema, name, obj) {
debug('create: %j : "%s" : %j', schema, name, obj);
const value = obj;
let options = {};
if(typeof obj === 'object' && obj.type) {
options = obj;
}
const attr = new Attribute(schema, name, value);
if(options.hashKey && options.rangeKey) {
throw new errors.SchemaError('Cannot be both hashKey and rangeKey: ' + name);
}
if(options.hashKey || (!schema.hashKey && !options.rangeKey)) {
schema.hashKey = attr;
}
if(options.rangeKey) {
schema.rangeKey = attr;
}
// check for global attributes in the tree..
if(attr.indexes) {
for(const indexName in attr.indexes) {
const index = attr.indexes[indexName];
if(schema.indexes.global[indexName] || schema.indexes.local[indexName]) {
throw new errors.SchemaError('Duplicate index name: ' + indexName);
}
if(index.global) {
schema.indexes.global[indexName] = attr;
} else {
schema.indexes.local[indexName] = attr;
}
}
}
return attr;
};
module.exports.lookupType = function (dynamoObj) {
if(dynamoObj.S !== null && dynamoObj.S !== undefined) {
// try {
// JSON.parse(dynamoObj.S);
// return Object;
// } catch (err) {
return String;
// }
}
if(dynamoObj.L !== null && dynamoObj.L !== undefined) {
return Array;
}
if(dynamoObj.M !== null && dynamoObj.M !== undefined) {
return Object;
}
if(dynamoObj.N !== null && dynamoObj.N !== undefined) {
return Number;
}
if(dynamoObj.BOOL !== null && dynamoObj.BOOL !== undefined) {
return Boolean;
}
if(dynamoObj.B !== null && dynamoObj.B !== undefined) {
return Buffer;
}
if(dynamoObj.NS !== null && dynamoObj.NS !== undefined) {
return [Number];
}
};