/** @namespace decorators */
import {
GQLBase, MODEL_KEY, META_KEY, GETTERS, SETTERS, PROPS, AUTO_PROPS
} from '../GQLBase'
import { isArray, extendsFrom } from 'ne-types'
import { inspect } from 'util'
import { GraphQLEnumType, parse } from 'graphql'
import { SyntaxTree } from '../SyntaxTree'
/**
* For each of the decorators, Getters, Setters, and Properties, we take a
* list of property names used to create the appropriate accessor types. In
* some cases, however, the instance of GQLBase's data model may have a
* different name. Finally if the return type for the getter should be wrapped
* in a another GQLBase class type, we will need a way to specify those things
* too.
*
* The `extractBits()` takes a single argument value from the decorator as it
* parses them and converts it into an object, properly sorted, into values that
* allow the above described behavior.
*
* Examples:
*
* ```
* // Create a class with a name and age property that map directly to the
* // underlying data model
* @Getters('name', 'age')
* class MyType extends GQLBase {...}
*
* // Create a class with a name property that maps to a different property
* // name in the underlying data model
* @Getters(['name', '_fake_name'])
* class MyMockType extends GQLBase {...}
*
* // Create a class with an employee property that returns an Employee
* @Getters(['employee', Employee])
* class MyRoleType extends GQLBase {...}
*
* // Finally create a class with an employe property that returns an Employee
* // with data under a different name in the underlying data model.
* @Getters(['employee', '_worker', Employee])
* class MyMockRoleType extends GQLBase {...}
* ```
*
* @memberof decorators
* @method ⌾⠀extractBits
* @since 2.5
*
* @param {String|Array<String|Function>} property name of a property, or list
* of property names and a Class.
* @return {Object} an object with the following format ```
* {
* fieldName: name of root instance property to create
* modelName: name of its associated internal model property
* typeClass: an optional class to wrap around the results in a getter
* }
* ```
*/
function extractBits(property) {
let array = isArray(property) ? property : [property, property, null]
let reply;
if (!property) {
let error = new Error(
'Invalid property. Given\n %o',
inspect(property, {depth: 2})
);
return {
fieldName: 'anErrorOccurred',
modelName: 'anErrorOccurred',
typeClass: null,
getterMaker: function() { return () => error },
setterMaker: function() { return (v) => undefined }
}
}
//
if (array.length === 3) {
reply = {
fieldName: array[0],
modelName: array[1],
typeClass: typeof array[2] === 'function' && array[2] || null
}
}
//
else if (array.length === 2) {
reply = {
fieldName: array[0],
modelName: typeof array[1] === 'string'
? array[1]
: array[0],
typeClass: typeof array[1] === 'function' && array[1] || null
}
}
//
else {
reply = {
fieldName: array[0],
modelName: array[0],
typeClass: array[0]
}
}
reply.getterMaker = function() {
let { modelName, fieldName, typeClass } = reply;
return function() {
const thisClass = this.constructor
const model = this[MODEL_KEY] || null
let val
if (!extendsFrom(thisClass, GQLBase)) {
console.error(`${thisClass.name} is not derived from GQLBase`);
return undefined
}
if (!thisClass.SCHEMA) {
throw new Error(`
All GQLBase extended classes should have a defined SCHEMA. Please
manually define a static get SCHEMA() in your class or use the
@Schema() decorator to do so.
`)
}
if (typeClass) {
// If the value of the model is already the type of class we expect
// we do not need to do any processing and we can just grab it and
// go.
if (model[modelName] && extendsFrom(model[modelName], typeClass)) {
val = model[modelName]
}
// Otherwise we need to return an instance of the determined typeClass
// and pass that back instead; as requested.
else {
const results = SyntaxTree.findField(
parse(this.constructor.SCHEMA),
this.constructor.name,
modelName
)
const { meta } = results || { meta: null };
let args = [model[modelName], this.requestData];
if (meta && !meta.nullable && !model) {
throw new Error(`
Using @Getters or @Properties decorators with a null or
undefined model when the schema states that this field
cannot be null.
Type : ${typeClass.name}
Field (AST data)
name : ${meta.name}
type : ${meta.type}
nullable: ${meta.nullable}
[getter] : ${fieldName}
[maps to] : ${modelName}
[model ] : ${model}
`)
}
// If the following is true, it means that despite allowing nulls
// for this field in the schema, we do have a valid model and should
// proceed.
if (model) {
if (extractBits.DIRECT_TYPES.includes(typeClass.name)) {
val = typeClass(...args)
}
else {
val = new typeClass(...args)
}
if (typeClass.GQL_TYPE === GraphQLEnumType) { return val.value; }
}
}
}
else {
val = model[modelName];
}
if (val === 'undefined' || val === undefined) {
val = null;
}
return val;
}
}
reply.setterMaker = function() {
let { modelName } = reply;
return function (value) {
this[MODEL_KEY][modelName] = value;
}
}
return reply;
}
/**
* An array of proper class names that are used to test for cases where the
* proper usage of instantiating an instance should preclude the use of `new`
*
* @memberof decorators
* @type {Array<String>}
*/
extractBits.DIRECT_TYPES = [
String.name
];
/**
* A small suite of functions a getter that allows easy manipulation of the
* the DIRECT_TYPES workaround needed for some types of complex class
* wrapping allowed by the @Getters and @Properties decorators. Namely the
* ability to do something like @Getters('name', String) which would wrap the
* contents of whatever is in the objects model in a String call.
*
* Direct types are those that need to be called without `new` in order for the
* desired behavior to present itself.
*
* @memberof decorators
* @type {Object}
* @since 2.7.0
*/
export const DirectTypeManager = {
/**
* A getter that retrieves the array of direct types
*
* @method DirectTypeManager#types
* @member {Array<String>} types
*
* @return {Array<String>} an array of class name strings.
*/
get types(): Array<String> {
return extractBits.DIRECT_TYPES
},
/**
* Appends the supplied class name to the list of registered direct types. If
* a class or function is passed, rather than a String,
*
* @method DirectTypeManager#types
*
* @param {Function|string|RegExp} className the name of the class to append.
* Typically it is best to pass the name property of the class in question
* such as `RegExp.name` or `MyClass.name`.
*/
add(className: string | RegExp | Function): void {
if (typeof className === 'function') {
className = className.name
}
extractBits.DIRECT_TYPES.push(className);
},
/**
* Foricbly empties the contents of the extractBits.DIRECT_TYPES array. This
* is not recommended as it can have unintended consequences. It is
* recommended to use `reset` instead
*
* @method DirectTypeManager#clear
*
* @return {Array<string>} an array of class name Strings that were removed
* when cleared.
*/
clear(): Array<string> {
return extractBits.DIRECT_TYPES.splice(0, extractBits.DIRECT_TYPES.length)
},
/**
* The recommended way to reset the DIRECT_TYPES list. This removes all
* changed values, returns the removed bits, and adds back in the defaults.
*
* @method DirectTypeManager#reset
*
* @return {Array<string>} an array of class name Strings that were removed
* during the reset process.
*/
reset(): Array<string> {
return extractBits.DIRECT_TYPES.splice(
0,
extractBits.DIRECT_TYPES.length,
String.name
)
}
}
/**
* This decorator allows you to add a Class method to the DirectTypeManager
* as a function that should not be invoked with the `new` keyword. For all
* intents and purposes the function should be declared `static`.
*
* @method DirectTypeAdd
* @param {Function} target [description]
* @constructor
*/
export function DirectTypeAdd(target) {
DirectTypeManager.add(target);
return target;
}
/**
* When applying multiple property getters and setters, knowing some info
* about what was applied elsewhere can be important. "Tags" can be applied
* that store the fieldName and descriptor applied via one of these decorators.
*
* Multiple "tags" are supported to allow for detecting the difference between
* decorators applied by the developer using lattice and something auto
* generated such as auto-props.
*
* @param {GQLBase} Class an instance of GQLBase to apply the tags tp
* @param {Array<string|Symbol>} addTags an array of Symbols or strings to be
* wrapped in Symbols that will be used as tag keys
* @param {string} fieldName the name of the field being decorated
* @param {Object} descriptor the JavaScript descriptor object to associate
* with this tagged field.
*/
export function applyTags(
Class:GQLBase,
addTags: Array<string|Symbol>,
fieldName: string,
descriptor: Object
) {
let tags = (Array.isArray(addTags) && addTags || [])
.map(tag => typeof tag === 'string' && Symbol.for(tag) || tag)
.filter(tag => typeof tag === 'symbol')
tags.forEach(tag => {
Class[META_KEY][tag] = Class[META_KEY][tag] || {}
Class[META_KEY][tag][fieldName] = descriptor
})
}
/**
* When working with `GQLBase` instances that expose properties
* that have a 1:1 mapping to their own model property of the
* same name, adding the getters manually can be annoying. This
* takes an indeterminate amount of strings representing the
* properties for which getters should be injected.
*
* @function 🏷⠀Getters
* @memberof! decorators
*
* @param {Array<String|Array<String>>} propertyNames if the model has 'name'
* and 'age' as properties, then passing those two strings will result
* in getters that surface those properties as GraphQL fields.
* @return {Function} a class decorator method.s
*/
export function Getters(
...propertyNames: Array<String|Array<String|Function>>
): Function {
return function(target: mixed, addTags: Array<string|Symbol> = []): mixed {
for (let property of propertyNames) {
let { fieldName, getterMaker } = extractBits(property);
let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
let hasImpl = desc && (desc.get || typeof desc.value === 'function')
let tags = [GETTERS].concat(Array.isArray(addTags) && addTags || [])
if (!hasImpl) {
let descriptor = {
get: getterMaker()
}
applyTags(target, tags, fieldName, descriptor)
Object.defineProperty(target.prototype, fieldName, descriptor);
}
else {
console.warn(
`Skipping getter for ${target.name}.${fieldName}; already exists`
)
}
}
return target;
}
}
/**
* When working with `GQLBase` instances that expose properties
* that have a 1:1 mapping to their own model property of the
* same name, adding the setters manually can be annoying. This
* takes an indeterminate amount of strings representing the
* properties for which setters should be injected.
*
* @function 🏷⠀Setters
* @memberof! decorators
* @since 2.1.0
*
* @param {Array<String|Array<String>>} propertyNames if the model has
* 'name' and 'age' as properties, then passing those two strings will
* result in setters that surface those properties as GraphQL fields.
* @return {Function} a class decorator method
*/
export function Setters(
...propertyNames: Array<String|Array<String|Function>>
): Function {
return function(target: mixed, addTags: Array<String|Symbol> = []): mixed {
for (let property of propertyNames) {
let { fieldName, setterMaker } = extractBits(property);
let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
let hasImpl = desc && (desc.get || typeof desc.value === 'function')
let tags = [SETTERS].concat(Array.isArray(addTags) && addTags || [])
if (!hasImpl) {
let descriptor = {
set: setterMaker()
}
applyTags(target, tags, fieldName, descriptor)
Object.defineProperty(target.prototype, fieldName, descriptor);
}
else {
console.warn(
`Skipping setter for ${target.name}.${fieldName}; already exists`
)
}
}
return target;
}
}
/**
* When working with `GQLBase` instances that expose properties
* that have a 1:1 mapping to their own model property of the
* same name, adding the getters manually can be annoying. This
* takes an indeterminate amount of strings representing the
* properties for which getters should be injected.
*
* This method creates both getters and setters
*
* @function 🏷⠀Properties
* @memberof! decorators
* @since 2.1.0
*
* @param {Array<String|Array<String>>} propertyNames if the model has 'name'
* and 'age' as properties, then passing those two strings will result
* in getters and setters that surface those properties as GraphQL fields.
* @return {Function} a class decorator method
*/
export function Properties(
...propertyNames: Array<String|Array<String|Function>>
): Function {
return function(target: mixed, addTags: Array<String|Symbol> = []): mixed {
for (let property of propertyNames) {
let {fieldName, getterMaker, setterMaker } = extractBits(property);
let desc = Object.getOwnPropertyDescriptor(target.prototype, fieldName)
let hasImpl = desc && (desc.get || typeof desc.value === 'function')
let tags = [PROPS].concat(Array.isArray(addTags) && addTags || [])
if (!hasImpl) {
let descriptor = {
set: setterMaker(),
get: getterMaker()
}
applyTags(target, tags, fieldName, descriptor)
Object.defineProperty(target.prototype, fieldName, descriptor);
}
else {
console.warn(
`Skipping properties for ${target.name}.${fieldName}; already exists`
)
}
}
return target;
}
}
export default Properties;