type-arango
Version:
ArangoDB Foxx decorators and utilities for TypeScript
460 lines (391 loc) • 15.9 kB
text/typescript
import {Joi} from '../joi'
import {
AttributeObject,
DecoratorId,
DecoratorStorage,
DocumentAttribute,
DocumentData,
DocumentOptions,
EventMethod,
EventType,
QueryFilter,
QueryOpt,
RelationStructure,
RelationType,
RoleAttributes,
RoleObject,
Roles,
SchemaStructure
} from '../types'
import {config, documents, Entities, Entity, logger, version} from '../index'
import {argumentResolve, concatUnique, enjoi, removeValues, toArray, toJoi, isObject} from '../utils'
import {MissingTypeError, RelationNotFoundError} from '../errors'
import {Collection} from './Collection.model'
const edgeAttributes = ['_from','_to']
/**
* Creates a new Document for a decorated class
*/
function createDocumentFromContainer<T=typeof Entity>(someClass: new() => T): Document<T> {
let c = new Document(someClass)
documents.push(c)
return c
}
/**
* Finds a document instance for a decorated class
*/
export function findDocumentForContainer<T=typeof Entity>(someClass: new() => T): Document<T> | undefined {
return documents.find(c => someClass === c.Class)
}
/**
* Returns the respective document instance for a decorated class
*/
export function getDocumentForContainer<T=typeof Entity>(someClass: new() => T): Document<T> {
let doc = findDocumentForContainer(someClass)
if(doc) return doc
return createDocumentFromContainer(someClass)
}
function reduceMap(arg: any, doc: DocumentData, map: DocumentMap){
if(doc[map[0]] === undefined) return doc
doc[map[0]] = map[1](doc[map[0]], arg)
return doc
}
type DocumentMap = [string, (val: any, arg: any) => any]
const _id = Joi.string()
const _key = Joi.string()
const _from = Joi.string()
const _to = Joi.string()
const eventTypes: EventType[] = [
'After.document', 'Before.document',
'After.insert', 'Before.insert',
'After.update', 'Before.update',
'After.modify', 'Before.modify',
'After.write', 'Before.write',
'After.replace', 'Before.replace',
'After.remove', 'Before.remove'
]
const eventNames: string[] = [
'afterDocument', 'beforeDocument',
'afterInsert', 'beforeInsert',
'afterUpdate', 'beforeUpdate',
'afterModify', 'beforeModify',
'afterWrite', 'beforeWrite',
'afterReplace', 'beforeReplace',
'afterRemove', 'beforeRemove'
]
export class Document<T=any> {
public col?: Collection
public name: string
public isEdge: boolean = false
public options: DocumentOptions = {}
public attribute: DocumentAttribute = {
_id: {attribute: '_id', metadata: String, schema: _id},
_key: {attribute: '_key', metadata: String, schema: _key}
}
public schema: SchemaStructure = {_id, _key}
public relation: RelationStructure<Document> = {}
public roles: string[] = ['guest']
public roleStripAttributes: RoleObject = {}
private decorator: DecoratorStorage = {Index:[]}
private forClientMap: DocumentMap[] = []
private fromClientMap: DocumentMap[] = []
constructor(public Class: new() => T){
this.name = Class.name
}
public makeEdge(){
this.isEdge = true
Object.assign(this.attribute, {_from,_to})
Object.assign(this.schema, {_from,_to})
}
public decorate(decorator: DecoratorId, data: any){
return this.decorator[decorator] = [...(this.decorator[decorator]||[]), {...data,decorator}]
}
get attributeIdentifierObject(){
let o: DocumentData = {}
Object.keys(this.attribute).forEach(a => o[a] = a)
return o
}
get indexes(){
return this.decorator.Index
}
get joi(){
return Joi.object(this.schema)
}
public forClient(doc: DocumentData, arg: any){
const { forClient } = this.options
doc = this.forClientMap.reduce(reduceMap.bind(null, arg), doc)
return forClient ? forClient(doc, arg) : doc
}
public fromClient(doc: DocumentData, arg: any){
const { fromClient } = this.options
doc = this.fromClientMap.reduce(reduceMap.bind(null, arg), doc)
return fromClient ? fromClient(doc, arg) : doc
}
/**
* Emit before event
*/
public emitBefore(method: EventMethod, ...args: any[]){
// PropertyDecorator
toArray(this.decorator['Before.'+method+'.prop' as DecoratorId])
.forEach(({attribute,resolver}) => {
switch(method){
case 'document': resolver(args[0], {_key:args[0], attribute, method}); break
case 'insert': args[0][attribute] = resolver(args[0][attribute], {json:args[0], attribute, method}); break
case 'update': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], json:args[0], attribute, method}); break
case 'replace': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], json:args[0], attribute, method}); break
case 'remove': resolver(args[0], {_key:args[0], attribute, method}); break
}
})
// ClassDecorator
toArray(this.decorator['Before.'+method+'.class' as DecoratorId])
.forEach(({resolver}) => {
let v: false | true | undefined | ArangoDB.HttpStatus
switch(method){
case 'document': v = resolver(args[0], {_key:args[0], method}); break
case 'insert': v = resolver(args[0], {json:args[0], method}); break
case 'update': v = resolver(args[0], {_key:args[1], json:args[0], method}); break
case 'replace': v = resolver(args[0], {_key:args[1], json:args[0], method}); break
case 'remove': v = resolver(args[0], {_key:args[0], method}); break
}
// Cancel
if(v === false || typeof v === 'string'){
throw new Error(v || 'bad-request')
}
// Passive
else if(v === true || v === undefined){ }
// DocumentData
else if(typeof args[0] === 'object') {
args[0] = v
}
})
return args[0]
}
/**
* Emit after event
*/
public emitAfter(method: EventMethod, ...args: any[]){
// PropertyDecorator
toArray(this.decorator['After.'+method+'.prop' as DecoratorId])
.forEach(({attribute,resolver}) => {
switch(method){
case 'document': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], document:args[0], attribute, method}); break
case 'insert': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], document:args[0], attribute, method}); break
case 'update': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], document:args[0], attribute, method}); break
case 'replace': args[0][attribute] = resolver(args[0][attribute], {_key:args[1], document:args[0], attribute, method}); break
case 'remove': resolver(args[0], {_key:args[0], attribute, method}); break
}
})
// ClassDecorator
toArray(this.decorator['After.'+method+'.class' as DecoratorId])
.forEach(({resolver}) => {
let v: false | true | undefined | ArangoDB.HttpStatus
switch(method){
case 'document': v = resolver(args[0], {_key:args[0], method}); break
case 'insert': v = resolver(args[0], {_key:args[0]._key, document:args[0], method}); break
case 'update': v = resolver(args[1], {_key:args[1], document:args[0], method}); break
case 'replace': v = resolver(args[1], {_key:args[1], document:args[0], method}); break
case 'remove': v = resolver(args[0], {_key:args[0], method}); break
}
// Cancel
if(v === false || typeof v === 'string'){
throw new Error(v || 'bad-request')
}
// Passive
else if(v === true || v === undefined){ }
// DocumentData
else if(typeof args[0] === 'object') {
args[0] = v
}
})
return args[0]
}
/**
* Resolves one or more related entities
*/
resolveRelation(data: DocumentData | Entity, attribute: string, arg?: string[]){
const rel = this.relation[attribute]
let filter: QueryFilter = {}
let merge: any = null
// related document is an edge, use CollectionName/ID
if(rel.document.isEdge && edgeAttributes.includes(rel.attribute)){
filter[rel.attribute] = this.col!.name + '/' + data._key
} else {
if(data._key)
filter[rel.attribute] = ['HAS', data._key]
}
// relation key/s stored in document
let ref = data[attribute]
if(ref){
if(isObject(ref))
throw new Error(`Invalid relation value of "${rel.document.name}.${rel.attribute}": ${JSON.stringify(ref)}`)
// remove CollectionName/ from relation id
if(this.isEdge){
if(Array.isArray(ref))
ref = ref.map(r => r.replace(rel.document.col!.name+'/', ''))
else
ref = ref.replace(rel.document.col!.name+'/', '')
}
// support tuples with key and value [[KEY, VALUE],...]
if(Array.isArray(ref[0])){
merge = [...ref]
ref = ref.map((r: any) => r[0])
}
filter = {_key:ref}
}
if(!Object.keys(filter).length)
throw new Error(`Cannot resolve relation of "${rel.document.name}.${rel.attribute}": empty filter`)
const entities = rel.document.col!.Class as typeof Entities
if(arg === null)
return data[attribute]
let opt: QueryOpt = {filter}
// attributes
if(Array.isArray(arg)){
opt.keep = arg
}
let res: any
// retrieve document/s
switch(rel.type){
default: return null
case 'OneToOne': res = entities.find(opt); break
case 'OneToMany': res = entities.filter(opt); break
}
// merge values back into result
if(merge)
res = res.map((res: any) => ({...res, _value: merge.find((r: any) => r[0] === res._key)[1] }))
return res
}
/**
* Creates an array of attributes that can't be read or written for every role used in the collection
* this.roleStripAttributes = {user:{read:[],write:['readOnlyAttribute']}
*/
buildAttributeRoles(){
logger.debug('Building %s attribute roles cache', this.name)
const attributes = Object.values(this.attribute)
// every role of the entity
for(let role of this.roles){
let roles: RoleAttributes = {read:[],write:[]}
// every attribute of the entity
for(let attr of attributes){
if(!attr.roles) continue
const { roles: {readers,writers} } = attr
if(readers && !readers.includes(role)) roles.read = concatUnique(roles.read, attr.attribute)
if(writers && !writers.includes(role)) roles.write = concatUnique(roles.write, attr.attribute)
}
this.roleStripAttributes[role] = roles
}
}
/**
* Creates arrays of document attributes that can be read or written
*/
stripAttributeList(providedRoles: Roles, method: 'read' | 'write'){
// collect attributes into temp object {key:count}
let attributes: any = {}, sum: number = 0
for(let role of providedRoles){
if(this.roleStripAttributes[role]){
sum++
for(let r of this.roleStripAttributes[role][method]){
attributes[r] = (attributes[r] || 0) + 1
}
}
}
let stripAttributes: string[] = []
Object.keys(attributes).forEach(k => attributes[k] === sum && stripAttributes.push(k))
return stripAttributes
}
/**
* Setup attribute names for doc.attributeIdentifierObject before finalizing
*/
complete(){
const { Authorized, Attribute } = this.decorator
if(Attribute) for(let {attribute} of [...Authorized||[], ...Attribute]){
if(attribute) this.attribute[attribute] = {attribute}
}
}
finalize(){
const { Authorized, OneToOne, OneToMany } = this.decorator
let metadata: any
// @OneToOne | @OneToMany
if(OneToOne || OneToMany) for(let {
decorator, prototype, attribute, type, relation
} of (OneToOne||[]).concat(OneToMany||[])) {
metadata = attribute && Reflect.getMetadata('design:type', prototype, attribute)
if(!type && ((metadata && ['Array','Function'].includes(metadata.name)) || (!type && !metadata)))
throw new MissingTypeError(this.name, attribute!)
type = type ? argumentResolve(type) : metadata
const docName = this.name.toLowerCase()
const relationDoc = getDocumentForContainer(type as any)
const attrObj = relationDoc.attributeIdentifierObject
let relationAttr = argumentResolve(relation, attrObj)
if(relation && !relationAttr)
throw new RelationNotFoundError(decorator as any, this.name, relation)
else if(!relationAttr)
relationAttr = attrObj[docName] ? docName : '_key'
this.relation[attribute!] = {type:decorator as RelationType, document:relationDoc, attribute:relationAttr}
}
// @Authorized
if(Authorized) for(let o of Authorized){
const A = this.decorator.Attribute || []
let a = (A || []).find(a => a.attribute === o.attribute)
if(!a) a = this.decorate('Attribute', o).find(attr => attr.attribute === o.attribute)
else {
a.readersArray = o.readersArray
a.writersArray = o.writersArray
}
}
const { Attribute } = this.decorator
// @Attribute
if(Attribute) for(let {
prototype, attribute, typeOrRequiredOrSchemaOrReaders, readersArray, writersArray
} of Attribute){
metadata = Reflect.getMetadata('design:type', prototype, attribute!)
const rel = this.relation[attribute!]
let joi
if(!rel) joi = toJoi(metadata)
else switch(rel.type){
case 'OneToOne': joi = rel.document.attribute[rel.attribute].schema || Joi.string(); break
case 'OneToMany': joi = Joi.array().items(rel.document.attribute[rel.attribute!].schema || Joi.string()); break
default: joi = toJoi(metadata); break
}
// if(!metadata)
// throw new Error('Invalid design:type for "'+this.name+'.'+attribute+'"')
let schema = argumentResolve(typeOrRequiredOrSchemaOrReaders, joi, enjoi) || joi
let readers = argumentResolve(readersArray)
let writers = argumentResolve(writersArray)
let roles
if(Array.isArray(schema)){
writers = readers
readers = schema
schema = joi
}
if(readers)
roles = removeValues({readers:readers,writers:writers||config.requiredWriterRolesFallback}, undefined)
if(roles) {
if(config.addAttributeWritersToReaders && roles.writers){
roles.readers = concatUnique(roles.readers, writers||[])
}
this.roles = concatUnique(this.roles, roles.readers, roles.writers)
}
if(schema){
this.schema[attribute!] = schema === true ? joi.required() : schema
}
const data: AttributeObject = removeValues({attribute,roles,schema,metadata}, undefined)
if(metadata && metadata._typeArango){
if(version < metadata._typeArango){
logger.error(`Type.${metadata.name} requires type-arango v${metadata._typeArango}`)
} else {
if(metadata.forClient) this.forClientMap.push([attribute!, metadata.forClient])
if(metadata.fromClient) this.fromClientMap.push([attribute!, metadata.fromClient])
eventTypes.forEach(name => {
const m = metadata[eventNames[eventTypes.indexOf(name)]]
if(!m) return
this.decorate(name+'.prop' as DecoratorId, {prototype,attribute,resolver:m})
})
}
}
this.attribute[attribute!] = data
logger.debug('Created attribute `%s.%s`', this.name, attribute)
}
// create attribute roles
this.buildAttributeRoles()
logger.info('Completed document "%s"', this.name)
}
}