Sunday, October 29, 2017

typescript, metadata, Xrm => need a real, non-Xrm metadata cache layer

v9.0 of dynamics Xrm provided some metadata support e.g. Xrm.Utility.getEntitySetName. It is useful given a singlular entity name to provide the plural name e.g. contact => contacts.
But this is has not been enough for my applications and besides, I like to run tests on non-browser related components or use non-browser applications to support my dynamics applications.
With this type of requirement, we needa real metadata class that can provide a diverse set of metadata for any application.
I tend to use a modified version of CRMWebAPI to help with this. You can find some of the content I describe below in my dynamics-client-ui project.

Metadata? What do we need?

I typically need:
  • Connection Roles and Categories lookup by name and value.
  • Plural names as dsecribe above.
  • OptionSet labels and values for a given lcsid.
  • Object type codes.
  • A list of all activity entity types.
  • ...the list is nearly endless...
  • Metadata on attributes
  • Metadata on relationships especially 1:M
To support this we can create a metadata class in typescript (usable in javascript) to support metadata capture. It needs to provde caching of course to be efficient.
To do this is actually quite easy. The only real question is how you structure the cache. Generally, a simple cache structure with put/get would be sufficient but below we create some specialized data structures. The below works in both node environments for testing (and can be mocked) as well as browser environments.
The class below will require typescript and some type of module management/bundler. It's not complete but it's a skeleton to show how to create a Metadata class...and its not complete but an early draft.
import {DEBUG} from "BuildSettings"
import { CRMWebAPI, QueryOptions } from "./CRMWebAPI"
import * as R from "ramda"

interface LabelValue {
    Label: string
    Value: number
}

/** From OptionSet. */
interface Option extends LabelValue {
    Description?: string
}

export interface EntityDefinition {
    MetadataId: string
    SchemaName: string
    LogicalName: string
    PrimaryIdAttribute: string
    LogicalCollectionName: string
}

export interface OneToManyRelationship {
    MetadataId: string
    
    ReferencedAttribute: string
    ReferencedEntity: string,
    ReferencedEntityNavigationPropertyName: string
    
    ReferencingAttribute: string
    ReferencingEntity: string
    ReferencingEntityNavigationPropertyName: string
    RelationshipType: string
    
    SchemaName: string
    IsManaged: boolean
    IsHierarchical: boolean
}

export interface Attribute {
    MetadataId: string
    LogicalName: string
}


/** [entity name].[attribute name] => attribute metadata definition. */
let entityToAttribute = {}

/** singular entity name to entity object */
const entityNameToDefinition: Map<string, EntityDefinition> = new Map()
const entityDefinitions: Array<EntityDefinition> = []

/** entity logical name to array of relationships */
const entityNameToOneToMany: Map<string, Array<OneToManyRelationship>> = new Map()

export interface ObjectTypeCodePair {
    LogicalName: string
    ObjectTypeCode: number
}

let objectTypeCodes: Array<ObjectTypeCodePair> = []
const objectTypeCodesByCode: Map<number, ObjectTypeCodePair> = new Map()
const objectTypeCodesByName: Map<string, ObjectTypeCodePair> = new Map()

/** all connection role categories */
export interface ConnectionRoleCategory {
    Label: string
    Value: number
}

let connectionRoleCategories: Array<ConnectionRoleCategory> = []
const connectionRoleCategoriesByName: Map<string, ConnectionRoleCategory> = new Map()
const connectionRoleCategoriesByValue: Map<number, ConnectionRoleCategory> = new Map()

/** Set of all connection roles. */
export interface ConnectionRole {
    connectionroleid: string
    name: string
    category: number
    ["category@OData.Community.Display.V1.FormattedValue"]: string
    description: string
    statecode: number
    statuscode: number
}

let connectionRoles: Array<ConnectionRole> = []
const connectionRolesById: Map<string, ConnectionRole> = new Map()
const connectionRolesByName: Map<string, ConnectionRole> = new Map()

/**
 * Metadata API. Fetched metadata is shared among all instances of this class at the moment.
 */
export class Metadata {
    constructor(dynclient: CRMWebAPI, lcsid: number = 1033) {
        this.dynclient = dynclient
        this.lcsId = lcsid
    }

    private lcsId: number
    private dynclient: CRMWebAPI

    /** Get all attributes for a logical entity name or return [] */
    getAttributes = async (entityName) => {
        const entry = entityToAttribute[entityName]
        if (entry) { return entry.values() }
        try {
            const m = await this.getMetadata(entityName)
            // Navigate to attributes
            const attrs = await this.dynclient.Get<any>("EntityDefinitions", m!.MetadataId, {
                Select: ["LogicalName"],
                Expand: [{ Property: "Attributes" }]
            }).
                then((r: any) => r.Attributes)
            if (attrs && attrs.length > 0) {
                // place in cache, by logical name!
                const mergeMe = attrs.reduce((accum, a) => {
                    accum[a.LogicalName] = a
                    return accum
                }, {})
                entityToAttribute = R.mergeDeepRight(entityToAttribute, {
                    [entityName]: mergeMe
                })
                return attrs
            }
        } catch (e) {
            console.log(`Error obtaining entity attributes for ${entityName}`, e)
        }
        // no attributes returned? probably an error somewhere
        return []
    }

    /** Find a specific entity-attribute metadata. Return null if not found. */
    lookupAttribute = async (entityName, attributeName) => {
        await this.getAttributes(entityName)
        // we should have the data now if it exists
        const entity = entityToAttribute[entityName]
        if (entity) {
            const attribute = entity[attributeName]
            if (attribute) return attribute
        }
        return null
    }

    /** Returns all entity {LogicalName, ObjectTypeCode} pairs. */
    getObjectTypeCodes = async () => {
        if (objectTypeCodes.length > 0) return objectTypeCodes
        const qopts = {
            Select: ["LogicalName", "ObjectTypeCode"]
        }
        const r = await this.dynclient.GetList<ObjectTypeCodePair>("EntityDefinitions", qopts)
        objectTypeCodes = r.List
        objectTypeCodes.forEach(c => objectTypeCodesByCode.set(c.ObjectTypeCode, c))
        objectTypeCodes.forEach(c => objectTypeCodesByName.set(c.LogicalName, c))
        return objectTypeCodes
    }

    /** Given a numerical code, return the (LogicalName, ObjectTypeCode) pair. */
    lookupObjectTypeCodeByCode = async (code: number) => {
        await this.getObjectTypeCodes()
        return objectTypeCodesByCode.get(code)
    }

    /** Given a name, return the (LogicalName, ObjectTypeCode) pair. */
    lookupObjectTypeCodeByName = async (name: string) => {
        await this.getObjectTypeCodes()
        return objectTypeCodesByName.get(name)
    }

    /** Pass in the entity singular logical name. Returns null if not found. */
    getMetadata = async (entityName: string): Promise<EntityDefinition|null> => {
        const cacheCheck = entityNameToDefinition.get(entityName)
        if (cacheCheck) return cacheCheck

        const qopts = {
            Filter: `LogicalName eq '${entityName}'`
        }

        // We can do this with a EntityDefinitions(LogicalName='..name...') but CRMWebAPI
        // does not have that.
        return this.dynclient.GetList<EntityDefinition>("EntityDefinitions", qopts).
            then(r => {
                if (!r.List) return null
                // add to cache
                const edef: EntityDefinition = r.List[0]
                entityDefinitions.push(edef)
                entityNameToDefinition[entityName] = edef
                return edef
            })
    }

    /** Get the entity set name given the entity logical name e.g. contact => contacts. */
    getEntitySetName = async (logicalName: string) => {
        return this.getMetadata(logicalName).
            then(md => {
                if (md) return md.LogicalCollectionName
                return null
            })
    }

    /** Get the schema name given the entity logical name. */
    getSchemaName = async (logicalName: string) => {
        return this.getMetadata(logicalName).
            then(md => {
                if (md) return md.SchemaName
                return null
            })
    }

    /** Return all connection roles. */
    getConnectionRoles = async () => {
        if (connectionRoles.length > 0) return connectionRoles
        const qopts = {
            FormattedValues: true,
            Filter: "statecode eq 0"
        }
        const r = await this.dynclient.GetList<ConnectionRole>("connectionroles", qopts).then(r => r.List)
        connectionRoles = r
        connectionRoles.forEach(cr => connectionRolesById.set(cr.connectionroleid, cr))
        connectionRoles.forEach(cr => connectionRolesByName.set(cr.name, cr))
        return r
    }

    // Use public, handles this binding automatically... */
    /** Return an array of connection roles for a given connection category name. */
    public async getConnectionRolesForCategoryNamed(categoryName: string) {
        const roles = await this.getConnectionRoles()
        const cat = await this.getConnectionRoleCategoryByName(categoryName)
        return roles.filter(cr => cr!.category === cat!.Value)
    }

    /** Return a connection role by its id. */
    getConnectionRoleById = async (id: string) => {
        await this.getConnectionRoles()
        return connectionRolesById.get(id)
    }

    /** Return a connection role by its name. */
    getConnectionRoleByName = async (name: string) => {
        await this.getConnectionRoles()
        return connectionRolesByName.get(name)
    }

    /** Return an array of all of these. */
    getConnectionRoleCategories = async () => {
        if (connectionRoleCategories.length > 0)
            return connectionRoleCategories

        const r = await this.dynclient.GetOptionSetUserLabels("connectionrole_category")
        connectionRoleCategories = connectionRoleCategories.concat(r)
        connectionRoleCategories.forEach(crc => connectionRoleCategoriesByName.set(crc.Label, crc))
        connectionRoleCategories.forEach(crc => connectionRoleCategoriesByValue.set(crc.Value, crc))
        return r
    }

    /** Return a connecton role category by value (Category = OptionSet). */
    getConnectionRoleCategoryByValue = async (value: number) => {
        await this.getConnectionRoleCategories()
        return connectionRoleCategoriesByValue.get(value)
    }

    /* Return a connection role category its name. */
    getConnectionRoleCategoryByName = async (name: string) => {
        await this.getConnectionRoleCategories()
        return connectionRoleCategoriesByName.get(name)
    }

    /** 
     * Get Option pairs back, Label and Value or an empty list..
     * Hackey implementation. Only looks at Attribute.OptionSet not Attribute.GlobalOptionSet.
     * Not cached yet!!!
     */
    public async getOptionSet(entityLogicalName: string,
                              attributeLogicalName: string): Promise<Array<Option>>
    {
        const emeta = await this.getMetadata(entityLogicalName)
        const ameta = await this.lookupAttribute(entityLogicalName, attributeLogicalName)
        if(!emeta) return []
        const qopts: QueryOptions = {
            Select: ["Options"],
            Path:[{
                Property: `Attributes(${ameta.MetadataId})`,
                Type: "Microsoft.Dynamics.CRM.PicklistAttributeMetadata"
            },
                  {
                      Property: "OptionSet"
                  }]
        }
        const attr: any = await this.dynclient.Get("EntityDefinitions", emeta.MetadataId, qopts)
        const pairs = attr.Options.map(opt => ({
            Label: opt.Label.LocalizedLabels[0].Label,
            Value: opt.Value
        }))
        //console.log("attr", attr, pairs)
        return pairs
    }

    /** 
     * Return all activity types. How do we filter on non-published kinds? 
     * This may return a surprising number of activities that are used only 
     * in a specialized context so you may need to filter them.
     */
    public async getAllActivityTypes() {
        const qopts: QueryOptions = {
            Select:["LogicalName", "ObjectTypeCode","Description", "DisplayName",
            "IconSmallName", "IconLargeName", "IconMediumName"],
            Filter: "IsActivity eq true"
        }
        const l = await this.dynclient.GetList("EntityDefinitions", qopts).then(r => {
            return r.List.map((entry:any) => ({
                ...entry,
                Description: entry.Description.LocalizedLabels[0].Label,
                DisplayName: entry.DisplayName.LocalizedLabels[0].Label
            }))
        })
        return l
    }

    
    /** Retur the primary PK logical attribute name for a given entity. */
    public async getPk(entityLogicalName: string): Promise<string|null> {
        return this.getMetadata(entityLogicalName).
                    then(md => {
                        if (md) return md.PrimaryIdAttribute
                        return null
                    })        
    }

    /** Relationships. Returns empty array if not found. */
    public async getOneToManyRelationships(entityLogicalName: string): Promise<Array<OneToManyRelationship>> {
        const rels = entityNameToOneToMany.get(entityLogicalName)
        if(rels) return rels
        
        const m = await this.getMetadata(entityLogicalName)
        if(!m) return []
        return this.dynclient.Get<any>(
            "EntityDefinitions", m!.MetadataId,
            {
                Select: ["LogicalName"],
                Expand: [{Property: "OneToManyRelationships"}]
            }).
              then((r:any) => {
                  entityNameToOneToMany.set(entityLogicalName, r.OneToManyRelationships)
                  return r.OneToManyRelationships
              })
    }

    /** Get a 1:M relationship to a specific name. Could be multiple, so choose wisely. */
    public async getOneToManyRelationshipsTo(entityLogicalName: string, toEntityLogicalName: string) {
        const rels = await this.getOneToManyRelationships(entityLogicalName)
        return rels.filter(r => r.ReferencingEntityNavigationPropertyName === toEntityLogicalName)
    }

    /** Should be only one. null if not found. */
    public async getOneToManyRelationshipBySchemaName(entityLogicalName: string, schemaName: string) {
        const rels = await this.getOneToManyRelationships(entityLogicalName)
        const x = rels.filter(r => r.SchemaName === schemaName)
        if(x.length === 1) return x[0]
        return null
    }
                                                 

    
}

export default Metadata

No comments:

Post a Comment