/**
Copyright 2021 Forestry.io Holdings, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
    http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/ import _ from 'lodash' ; import path from 'path' ; import { TinaSchema } from '../schema' ; import { assertShape , sequential , lastItem } from '../util' ; import { NAMER } from '../ast-builder' ; import { Database , CollectionDocumentListLookup } from '../database' ; import isValid from 'date-fns/isValid' ; import { parseMDX , stringifyMDX } from '../mdx' ; import flat from 'flat' ; import type { Templateable , TinaFieldEnriched , Collectable , TinaCloudCollection , } from '../types' ; import { TinaError } from './error' ; interface ResolverConfig { database : Database ; tinaSchema : TinaSchema ; } export const createResolver = ( args ) => { return new Resolver ( args ) ; } ; /**
 * The resolver provides functions for all possible types of lookup
 * values and retrieves them from the database
 */ export class Resolver { public database : Database ; public tinaSchema : TinaSchema ; constructor ( public init ) { this . database = init . database ; this . tinaSchema = init . tinaSchema ; } public resolveCollection = async ( collectionName ) => { const collection = this . tinaSchema . getCollection ( collectionName ) ; const extraFields = { } ; const documents = await this . getDocumentsForCollection ( collectionName ) ; return { documents , ... collection , ... extraFields , } ; } ; public getDocument = async ( fullPath ) => { if ( typeof fullPath !== 'string' ) { throw new Error ( `fullPath must be of type string for getDocument request` ) ; } const rawData = await this . database . get < { _collection : string ; _template : string ; } > ( fullPath ) ; const collection = this . tinaSchema . getCollection ( rawData . _collection ) ; try { const template = await this . tinaSchema . getTemplateForData ( { data : rawData , collection , } ) ; const basename = path . basename ( fullPath ) ; const extension = path . extname ( fullPath ) ; const filename = basename . replace ( extension , '' ) ; const relativePath = fullPath . replace ( '\\' , '/' ) . replace ( collection . path , '' ) . replace ( /^\/|\/$/g , '' ) ; const breadcrumbs = filename . split ( '/' ) ; const form = { label : collection . label , name : basename , fields : await sequential ( template . fields , async ( field ) => { return this . resolveField ( field ) ; } ) , } ; const data = { _collection : rawData . _collection , _template : rawData . _template , } ; await sequential ( template . fields , async ( field ) => this . resolveFieldData ( field , rawData , data ) ) ; return { __typename : NAMER . documentTypeName ( [ rawData . _collection ] ) , id : fullPath , sys : { basename , filename , extension , path : fullPath , relativePath , breadcrumbs , collection , template : lastItem ( template . namespace ) , } , data , values : data , dataJSON : data , form : form , } ; } catch ( e ) { if ( e instanceof TinaError ) { throw new TinaError ( e . message , { requestedDocument : fullPath , ... e . extensions , } ) ; } throw e ; } } ; public getDocumentFields = async ( ) => { try { const response = { } ; const collections = await this . tinaSchema . getCollections ( ) ; /**
             * Iterate through collections...
             */ await sequential ( collections , async ( collection ) => { const collectable = this . tinaSchema . getTemplatesForCollectable ( collection ) ; switch ( collectable . type ) { case 'object' : response [ collection . name ] = { collection , fields : await sequential ( collectable . template . fields , async ( field ) => { return this . resolveField ( field ) ; } ) , mutationInfo : { includeCollection : true , includeTemplate : false , } , } ; break ; case 'union' : const templates = { } ; /**
                         * Iterate through templates...
                         */ await sequential ( collectable . templates , async ( template ) => { templates [ lastItem ( template . namespace ) ] = { template , fields : await sequential ( template . fields , async ( field ) => { return this . resolveField ( field ) ; } ) , } ; } ) ; response [ collection . name ] = { collection , templates , mutationInfo : { includeCollection : true , includeTemplate : true } , } ; break ; } } ) ; return response ; } catch ( e ) { throw e ; } } ; public buildObjectMutations = ( fieldValue , field ) => { if ( field . fields ) { const objectTemplate = typeof field . fields === 'string' ? this . tinaSchema . getGlobalTemplate ( field . fields ) : field ; if ( Array . isArray ( fieldValue ) ) { return fieldValue . map ( ( item ) => this . buildFieldMutations ( item , objectTemplate ) ) ; } else { return this . buildFieldMutations ( fieldValue , objectTemplate ) ; } } if ( field . templates ) { if ( Array . isArray ( fieldValue ) ) { return fieldValue . map ( ( item ) => { if ( typeof item === 'string' ) { throw new Error ( `Expected object for template value for field ${ field . name }` ) ; } const templates = field . templates . map ( ( templateOrTemplateName ) => { if ( typeof templateOrTemplateName === 'string' ) { return this . tinaSchema . getGlobalTemplate ( templateOrTemplateName ) ; } return templateOrTemplateName ; } ) ; const [ templateName ] = Object . entries ( item ) [ 0 ] ; const template = templates . find ( ( template ) => template . name === templateName ) ; if ( ! template ) { throw new Error ( `Expected to find template ${ templateName }` ) ; } return { ... this . buildFieldMutations ( item [ template . name ] , template ) , _template : template . name , } ; } ) ; } else { if ( typeof fieldValue === 'string' ) { throw new Error ( `Expected object for template value for field ${ field . name }` ) ; } const templates = field . templates . map ( ( templateOrTemplateName ) => { if ( typeof templateOrTemplateName === 'string' ) { return this . tinaSchema . getGlobalTemplate ( templateOrTemplateName ) ; } return templateOrTemplateName ; } ) ; const [ templateName ] = Object . entries ( fieldValue ) [ 0 ] ; const template = templates . find ( ( template ) => template . name === templateName ) ; if ( ! template ) { throw new Error ( `Expected to find template ${ templateName }` ) ; } return { ... this . buildFieldMutations ( fieldValue [ template . name ] , template ) , _template : template . name , } ; } } } ; public createResolveDocument = async ( { collection , realPath , args , isAddPendingDocument , } ) => { /**
         * TODO: Remove when `addPendingDocument` is no longer needed.
         */ if ( isAddPendingDocument === true ) { const templateInfo = this . tinaSchema . getTemplatesForCollectable ( collection ) ; switch ( templateInfo . type ) { case 'object' : await this . database . addPendingDocument ( realPath , { } ) ; break ; case 'union' : const templateString = args . template ; const template = templateInfo . templates . find ( ( template ) => lastItem ( template . namespace ) === templateString ) ; if ( ! args . template ) { throw new Error ( `Must specify a template when creating content for a collection with multiple templates. Possible templates are: ${ templateInfo . templates . map ( ( t ) => lastItem ( t . namespace ) ) . join ( ' ' ) }` ) ; } if ( ! template ) { throw new Error ( `Expected to find template named ${ templateString } in collection "${ collection . name }" but none was found. Possible templates are: ${ templateInfo . templates . map ( ( t ) => lastItem ( t . namespace ) ) . join ( ' ' ) }` ) ; } await this . database . addPendingDocument ( realPath , { _template : lastItem ( template . namespace ) , } ) ; } return this . getDocument ( realPath ) ; } const params = this . buildObjectMutations ( args . params [ collection . name ] , collection ) ; await this . database . put ( realPath , params ) ; return this . getDocument ( realPath ) ; } ; public updateResolveDocument = async ( { collection , realPath , args , isAddPendingDocument , isCollectionSpecific , } ) => { /**
         * TODO: Remove when `addPendingDocument` is no longer needed.
         */ if ( isAddPendingDocument === true ) { const templateInfo = this . tinaSchema . getTemplatesForCollectable ( collection ) ; const params = this . buildParams ( args ) ; switch ( templateInfo . type ) { case 'object' : if ( params ) { const values = this . buildFieldMutations ( params , templateInfo . template ) ; await this . database . put ( realPath , values ) ; } break ; case 'union' : await sequential ( templateInfo . templates , async ( template ) => { const templateParams = params [ lastItem ( template . namespace ) ] ; if ( templateParams ) { if ( typeof templateParams === 'string' ) { throw new Error ( `Expected to find an object for template params, but got string` ) ; } const values = { ... this . buildFieldMutations ( templateParams , template ) , _template : lastItem ( template . namespace ) , } ; await this . database . put ( realPath , values ) ; } } ) ; } return this . getDocument ( realPath ) ; } const params = this . buildObjectMutations ( isCollectionSpecific ? args . params : args . params [ collection . name ] , collection ) ; await this . database . put ( realPath , params ) ; return this . getDocument ( realPath ) ; } ; public resolveDocument = async ( { args , collection : collectionName , isMutation , isCreation , isAddPendingDocument , isCollectionSpecific , } ) => { /**
         * `collectionName` is passed in:
         *    * `addPendingDocument()` has `collection` on `args`
         *    * `getDocument()` provides a `collection` on `args`
         *    * `get<Collection>Document()` has `collection` on `lookup`
         */ let collectionLookup = collectionName || undefined ; /**
         * For generic functions (like `createDocument()` and `updateDocument()`), `collection` is the top key of the `params`
         */ if ( ! collectionLookup && isCollectionSpecific === false ) { collectionLookup = Object . keys ( args . params ) [ 0 ] ; } const collectionNames = this . tinaSchema . getCollections ( ) . map ( ( item ) => item . name ) ; assertShape < string > ( collectionLookup , ( yup ) => { return yup . mixed ( ) . oneOf ( collectionNames ) ; } , `"collection" must be one of: [${ collectionNames . join ( ', ' ) }] but got ${ collectionLookup }` ) ; assertShape < { relativePath : string ; } > ( args , ( yup ) => yup . object ( { relativePath : yup . string ( ) . required ( ) } ) ) ; const collection = await this . tinaSchema . getCollection ( collectionLookup ) ; const realPath = path . join ( collection ?. path , args . relativePath ) ; const alreadyExists = await this . database . documentExists ( realPath ) ; if ( isMutation ) { if ( isCreation ) { /**
                 * createDocument, create<Collection>Document
                 */ if ( alreadyExists === true ) { throw new Error ( `Unable to add document, ${ realPath } already exists` ) ; } return this . createResolveDocument ( { collection , realPath , args , isAddPendingDocument , } ) ; } /**
             * updateDocument, update<Collection>Document
             */ if ( alreadyExists === false ) { throw new Error ( `Unable to update document, ${ realPath } does not exist` ) ; } return this . updateResolveDocument ( { collection , realPath , args , isAddPendingDocument , isCollectionSpecific , } ) ; } else { /**
             * getDocument, get<Collection>Document
             */ return this . getDocument ( realPath ) ; } } ; public resolveCollectionConnections = async ( { ids } ) => { return { totalCount : ids . length , edges : await sequential ( ids , async ( filepath ) => { const document = await this . getDocument ( filepath ) ; return { node : document , } ; } ) , } ; } ; public getDocumentsForCollection = async ( collectionName ) => { const collection = this . tinaSchema . getCollection ( collectionName ) ; return this . database . store . glob ( collection . path , this . getDocument ) ; } ; public resolveCollectionConnection = async ( { args , lookup , } ) => { let documents ; if ( args . filter ) { const queries = [ ] ; const flattenedArgs = flat ( args . filter , { delimiter : '#' } ) ; Object . entries ( flattenedArgs ) . map ( ( [ key , value ] ) => { const keys = key . split ( '#' ) ; const maybeTemplateName = keys [ 0 ] ; const realKey = keys . slice ( 0 , keys . length - 1 ) . join ( '#' ) ; const collection = this . tinaSchema . getCollection ( lookup . collection ) ; let templateName = collection . name ; if ( collection . templates ) { const template = collection . templates . find ( ( template ) => { if ( typeof template === 'string' ) { throw new Error ( 'Global templates not yet supported for queries' ) ; } return template . name === maybeTemplateName ; } ) ; if ( typeof template === 'string' ) { throw new Error ( 'Global templates not yet supported for queries' ) ; } if ( template ) { templateName = template . name ; } } queries . push ( `__attribute__${ lookup . collection }#${ templateName }#${ realKey }#${ value }` ) ; } ) ; documents = await this . database . query ( queries , this . getDocument ) ; } else { const collection = await this . tinaSchema . getCollection ( lookup . collection ) ; documents = await this . database . store . glob ( collection . path , this . getDocument ) ; } return { totalCount : documents . length , edges : documents . map ( ( document ) => { return { node : document } ; } ) , } ; } ; private buildFieldMutations = ( fieldParams , template ) => { const accum = { } ; Object . entries ( fieldParams ) . forEach ( ( [ fieldName , fieldValue ] ) => { if ( Array . isArray ( fieldValue ) ) { if ( fieldValue . length === 0 ) { return ; } } const field = template . fields . find ( ( field ) => field . name === fieldName ) ; if ( ! field ) { throw new Error ( `Expected to find field by name ${ fieldName }` ) ; } switch ( field . type ) { case 'datetime' : accum [ fieldName ] = resolveDateInput ( fieldValue , field ) ; break ; case 'string' : case 'boolean' : case 'number' : case 'image' : accum [ fieldName ] = fieldValue ; break ; case 'object' : accum [ fieldName ] = this . buildObjectMutations ( fieldValue , field ) ; break ; case 'rich-text' : field ; accum [ fieldName ] = stringifyMDX ( fieldValue , field ) ; break ; case 'reference' : accum [ fieldName ] = fieldValue ; break ; default : throw new Error ( `No mutation builder for field type ${ field . type }` ) ; } } ) ; return accum ; } ; private resolveFieldData = async ( { namespace , ... field } , rawData , accumulator ) => { if ( ! rawData ) { return undefined ; } assertShape < { [ key ] : unknown ; } > ( rawData , ( yup ) => yup . object ( ) ) ; const value = rawData [ field . name ] ; switch ( field . type ) { case 'datetime' : if ( value instanceof Date ) { accumulator [ field . name ] = value . toISOString ( ) ; } else { accumulator [ field . name ] = value ; } break ; case 'string' : case 'boolean' : case 'number' : case 'reference' : case 'image' : accumulator [ field . name ] = value ; break ; case 'rich-text' : const tree = parseMDX ( value , field ) ; accumulator [ field . name ] = tree ; break ; case 'object' : if ( field . list ) { if ( ! value ) { return ; } assertShape < { [ key ] : unknown ; } [ ] > ( value , ( yup ) => yup . array ( ) . of ( yup . object ( ) . required ( ) ) ) ; accumulator [ field . name ] = await sequential ( value , async ( item ) => { const template = await this . tinaSchema . getTemplateForData ( { data : item , collection : { namespace , ... field , } , } ) ; const payload = { } ; await sequential ( template . fields , async ( field ) => { await this . resolveFieldData ( field , item , payload ) ; } ) ; const isUnion = ! ! field . templates ; return isUnion ? { _template : lastItem ( template . namespace ) , ... payload , } : payload ; } ) ; } else { if ( ! value ) { return ; } const template = await this . tinaSchema . getTemplateForData ( { data : value , collection : { namespace , ... field , } , } ) ; const payload = { } ; await sequential ( template . fields , async ( field ) => { await this . resolveFieldData ( field , value , payload ) ; } ) ; const isUnion = ! ! field . templates ; accumulator [ field . name ] = isUnion ? { _template : lastItem ( template . namespace ) , ... payload , } : payload ; } break ; default : return field ; } return accumulator ; } ; /**
     * A mutation looks nearly identical between updateDocument:
     * ```graphql
     * updateDocument(collection: $collection,relativePath: $path, params: {
     *   post: {
     *     title: "Hello, World"
     *   }
     * })`
     * ```
     * and `updatePostDocument`:
     * ```graphql
     * updatePostDocument(relativePath: $path, params: {
     *   title: "Hello, World"
     * })
     * ```
     * The problem here is that we don't know whether the payload came from `updateDocument`
     * or `updatePostDocument` (we could, but for now it's easier not to pipe those details through),
     * But we do know that when given a `args.collection` value, we can assume that
     * this was a `updateDocument` request, and thus - should grab the data
     * from the corresponding field name in the key
     */ private buildParams = ( args ) => { try { assertShape < { collection : string ; params : { [ collectionName ] : FieldParams ; } ; } > ( args , ( yup ) => yup . object ( { collection : yup . string ( ) . required ( ) , params : yup . object ( ) . required ( ) , } ) ) ; return args . params [ args . collection ] ; } catch ( e ) { } assertShape < { params : FieldParams ; } > ( args , ( yup ) => yup . object ( { params : yup . object ( ) . required ( ) , } ) ) ; return args . params ; } ; private resolveField = async ( { namespace , ... field } ) : Promise < unknown > => { const extraFields = field . ui || { } ; switch ( field . type ) { case 'number' : return { component : 'number' , ... field , ... extraFields , } ; case 'datetime' : return { component : 'date' , ... field , ... extraFields , } ; case 'boolean' : return { component : 'toggle' , ... field , ... extraFields , } ; case 'image' : return { component : 'image' , clearable : true , ... field , ... extraFields , } ; case 'string' : if ( field . options ) { if ( field . list ) { return { component : 'checkbox-group' , ... field , ... extraFields , options : field . options , } ; } return { component : 'select' , ... field , ... extraFields , options : field . required ? field . options : [ { label : `Choose an option` , value : '' } , ... field . options ] , } ; } if ( field . list ) { return { component : 'list' , field : { component : 'text' , } , ... field , ... extraFields , } ; } return { component : 'text' , ... field , ... extraFields , } ; case 'object' : const templateInfo = this . tinaSchema . getTemplatesForCollectable ( { ... field , namespace , } ) ; if ( templateInfo . type === 'object' ) { return { ... field , component : field . list ? 'group-list' : 'group' , fields : await sequential ( templateInfo . template . fields , async ( field ) => await this . resolveField ( field ) ) , ... extraFields , } ; } else if ( templateInfo . type === 'union' ) { const templates = { } ; const typeMap = { } ; await sequential ( templateInfo . templates , async ( template ) => { const extraFields = template . ui || { } ; const templateName = lastItem ( template . namespace ) ; typeMap [ templateName ] = NAMER . dataTypeName ( template . namespace ) ; templates [ lastItem ( template . namespace ) ] = { label : template . label || templateName , key : templateName , fields : await sequential ( template . fields , async ( field ) => await this . resolveField ( field ) ) , ... extraFields , } ; return true ; } ) ; return { ... field , typeMap , component : field . list ? 'blocks' : 'not-implemented' , templates , ... extraFields , } ; } else { throw new Error ( `Unknown object for resolveField function` ) ; } case 'rich-text' : const templates = { } ; const typeMap = { } ; await sequential ( field . templates , async ( template ) => { if ( typeof template === 'string' ) { throw new Error ( `Global templates not yet supported for rich-text` ) ; } else { const extraFields = template . ui || { } ; const templateName = lastItem ( template . namespace ) ; typeMap [ templateName ] = NAMER . dataTypeName ( template . namespace ) ; templates [ lastItem ( template . namespace ) ] = { label : template . label || templateName , key : templateName , inline : template . inline , name : templateName , fields : await sequential ( template . fields , async ( field ) => await this . resolveField ( field ) ) , ... extraFields , } ; return true ; } } ) ; return { ... field , templates : Object . values ( templates ) , component : 'rich-text' , ... extraFields , } ; case 'reference' : const documents = _ . flatten ( await sequential ( field . collections , async ( collectionName ) => { const collection = this . tinaSchema . getCollection ( collectionName ) ; return this . database . store . glob ( collection . path ) ; } ) ) ; return { ... field , component : 'reference' , options : [ { label : 'Choose an option' , value : '' } , ... documents . map ( ( document ) => { return { value : document , label : document , } ; } ) , ] , ... extraFields , } ; default : throw new Error ( `Unknown field type ${ field . type }` ) ; } } ; } const resolveDateInput = ( value ) => { /**
     * Convert string to `new Date()`
     */ const date = new Date ( value ) ; if ( ! isValid ( date ) ) { throw 'Invalid Date' ; } /**
     * toISOString() converts to UTC
     */ return date . toISOString ( ) ; } ; type FieldParams = { [ fieldName ] : string | { [ key ] : unknown ; } | FieldParams [ ] ; } ;