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