Wednesday, December 20, 2017

msbuild and merging assemblies.md

msbuild and merging assemblies.md

You probably use ilmerge to merge assemblies for uploadding to dynamics.

The easiest way to enable this in visual studio is by adding the msbuild task ilmerge via nuget. nuget is essentially a fancy “macro” that does something to your project. It often just adds assemblies to your references list or adds a config file to manage some tool that also gets installed into your solution.

Once you do, nuget adds the ilmerge.dll and ilmerge.exe to the “packages” directory in your project. Do not forget to exclude these when you check them into github or another version control system. You can find instructions for adding the ilmerge task here: https://github.com/emerbrito/ILMerge-MSBuild-Task/wiki or through nuget directly https://www.nuget.org/packages/MSBuild.ILMerge.Task/.

An issue arises when you host your project files on a network drive. ilmerge will not work in the msbuild task because it is trying to merge assemblies that are on a network drive and .net does not allow this by default. .net has alot of security hassles in general that make life difficult, but this one is easy to overcome.

To overcome this, you need to realize that msbuild loads assemblies to execute tasks. Hence you need to adjust the permissions that msbuild runs with. msbuild is provided in visual studio’s installation directories. Assuming you have the appropriate permission, find the file: C:\Program Files (x86)\Microsoft Visual Studio\2017\Enterprise\MSBuild\15.0\Bin (use the proper VS version number for you, mine was 15.0) and adjust the MSBuild.exe.config file:

<?xml version="1.0" encoding="utf-8"?>
  <configuration>
    <runtime>
	    <!-- add this line anywhere in the runtime section -->
		 <loadFromRemoteSources enabled="true"/> 
...

ilmerge will now merge assemblies located at remote resources, such as a network drive, correctly. Maybe everyone knows this already, has another workaround, or maybe no one runs their projects on network shares or its already setup for them. I needed to make a modification for my development environment. I did not find a way to modify this permission globally using .net 4.5.

P.S. Don’t forget you still need to add the dynamics 365 SDK via nuget: https://www.nuget.org/packages/Microsoft.CrmSdk.CoreAssemblies/

Monday, December 18, 2017

dynamics customeraddress loading.md

dynamics customeraddress loading.md

Addresses in dynamics are handled as a special entity with different semantics than most other entities in CRM. You are unable to customize an address by creating new relationships and you are unable to use new form control features as the form is still the old one, much like the connections form.

Addresses (the logical name is customeraddress/customeraddresses) are always allocated along with the primary entities they were designed to serve. For example, 2 address records are created for each contact and account with address numbers 1 and 2. Any additional attributes are numbered 3+ when they are created via an autonumbering scheme. Specific attributes on contact and account are automatically mapped to these two backing addresses. The mapping is two way, so updating the customeraddress record directly also updates the entity. The addresses are only available for listing/managing via an entity’s form navigation menu. The address management screens are not present in the July 2017 release of the UCI client.

One question though, aside from whether you should use the builtin address or not, is how to load them, if you decide to use them. Because the first two addresses are already created, any “insert” operation will leave these blank. If your data source has a primary address identify, you may want to load this into account.address1 (addressnumber = 1). Hence, you need to have a set of addresses, identify the top “1” or “2” addresses, use update, then the rest should be inserted.

Using dynamics-client we can write a small bit of code to do this. dynamics-crm runs under node. You could also do this in any ETL of course.

First we need some helper functions:

 def updateAddress(parentId: String, addressNumber: Int, payload: String) = {
    val q = QuerySpec(
      filter = Option(s"addressnumber eq $addressNumber and _parentid_value eq $parentId"),
      select = Seq("customeraddressid", "addressnumber")
    )
    lift {
      val addresses = unlift(dynclient.getList[CustomerAddress](q.url("customeraddresses")))
      if(addresses.length != 1) throw new Exception(s"Invalid exising customeraddress entity found for ${parentId}-${addressNumber}")
      unlift(dynclient.update("customeraddresses", addresses(0).customeraddressid, payload))
    }
  }

  def insertAddress(payload: String) = dynclient.createReturnId("/customeraddresses", payload)

The update first retrieves the customeraddress via the customeraddress’s parent id then updates the customeraddress record appropriately. Insert is straight forward.

The logic for identifying the primary address needs to come from a SQL command, for example, from the database you are pulling the data from:

 /**
    * Load source side account "location" correctly. First 2 addresses should map to
    * pre-existing addressnumber 1 and 2 and hence should be updates and
    * not inserts. Data should be sorted by crm accountid then sorted by whatever
    * makes the address you want for 1 and 2 appear at the start of the group.
    * 
    * objecttypecode is a string! not a number for this entity: account|contact|...
    */
  val loadAddresses = Action { config =>
    val src: Stream[IO, js.Object] = cats.Applicative[Option].map2(
      config.etl.query orElse config.etl.queryFile.map(Utils.slurp(_)),
      config.etl.connectionFile)(dbStream _)
      .getOrElse(Stream.empty)
    val counter  = new java.util.concurrent.atomic.AtomicInteger(0)
    val xf = xform(config.etl.cliParameters)
    val toPayload = (o: js.Object) => clean(xf(o).asJson)
    val program = src
      .take(config.etl.take.getOrElse(Long.MaxValue))
      .drop(config.etl.drop.getOrElse(0))
      .groupBy(jobj => jobj.asDict[String]("parentid"))
      .map{jobj =>
        val id = jobj._1
        val records = jobj._2
        if(config.etl.verbosity > 0)
          println(s"parentid: ${id}, records: ${records.map(r => Utils.render(r))}")
        counter.getAndAdd(records.length)
        // First two already exist and are updates else inserts
        val updates = records.take(2).zipWithIndex.map{ addr =>
          val parentId = addr._1.asDict[String]("parentid")
          val payload = toPayload(addr._1)
          if(config.etl.verbosity > 2) println(s"Update: parentid=$parentId: ${payload}")
          updateAddress(parentId, 1 + addr._2, payload)
        }
        val inserts = records.drop(2).map{addr =>
          val payload = toPayload(addr)
          if(config.etl.verbosity > 2) println(s"Insert: ${payload}")
          insertAddress(payload)
        }
        (updates ++ inserts).toList.sequence
      }
      .map(Stream.eval(_))

    IO(println("Loading company addresses"))
      .flatMap{_ => program.join(config.common.concurrency).run }
      .flatMap{_ => IO(println(s"# records loaded: ${counter.get()}"))}
  }

The routine method is all you need and took about 30 min to write. There is a standard xf that interprets command line parameters to add, drop or rename attributes. The core logic is in the val program = ... part. Here we just group the input (the input must be sorted on the parentid and address order), take the first two and perform updates while the remaining are inserted. Since the database input probably has some fields that should not be inserted into dynamics, we can specify that those be dropped via the standard xf CLI --drop ‘mysortfield1|mysortfield2’.

That’s it!

Thursday, November 23, 2017

Dynamics CRM, form context without using Xrm.Page, can use with React

If you program in javascript/typescript for forms programming, you know that to access content on the page, you need to use the Xrm.Page object. However, in v9+, Xrm.Page is deprecated. The advice is to obtain the form context off the execution context. The execution context is what is provided when you add a callback handler to an onSave or onChange type event.

If you program with web resources, you know that there is not a supported way to obtain the form context. At best, you typically access it via window.parent.Xrm.Page. That works, but its deprecated. What’s a safer way to obtain the form context?

One way is to setup a simple system of access that relies only on the Form.onLoad handler and publishing the value to a well-know location. A onLoad handler is added in the form editor. Since onLoad is called at a different time then when your web resource may be loaded, you need to setup a simple promise to obtain the form context from any web resource by publishing the form context a well known location that is accesible to all web resources. The best well-known location is on the toplevel window, but for the description below, we add the form context one level up from where the form onLoad handlder is called. This location is also accessible from web resources. Client form scripts and web resources are loaded at the same child level with a common parent iframe.

Here’s how you do it:

  • Setup a function to be called with Form.onLoad.
  • In that function, setup a promise that can be accessed on the onLoad script’s window.parent.
  • In the web resource, access widow.parent. and use a then clause on the promise.

Here’s some code that you would load and attach to the onLoad handler:

/**
 * Captures the form context and stores it in
 * a well known location for other components,
 * especially Web Resources.
 *
 * Since form scripts and web resources live
 * in a hierarchy of iframes, ensure that
 * we embed that knowledge here, once.
 *
 * Note that MS documents are incomplete about
 * the context and its validness after a callback
 * function exits.
 *
 * Usage: arrange to have onLoad called
 * as a form's onload handler.
 */

/** Attachment point for the callback. Object has "Deferred" type. */
function Deferred() {
    return defer(Object.create(Deferred.prototype))
}

/** Add resolve, reject, promise to an object. */
function defer(deferred) {
    deferred.promise = new Promise(function(resolve, reject) {
        deferred.resolve = resolve
        deferred.reject = reject
    })
    return deferred
}

const p = Deferred()

/**
 * Arrange to have this function called
 * with the form's OnLoad event. This is 
 * the only way to guarantee that we obtain
 * a valid form context without going through
 * the deprecated Xrm.Page.
 * 
 * This form assumes that form scripts load
 * into a frame hierarchy that is one below
 * a parent that webresources can also access.
 */
export function onLoad(ctx: any): void {
    p.resolve(ctx.getFormContext())
}

/** 
 * Attach our promise one level up so other frames can find it.
 * To reach the promised land, call FormContextP() to obtain
 * the promise. Use Promise.race (or equivalent) to timeout
 * waiting.
 */
// @ts-ignore
window.parent.FormContextP = p.promise

A web resource would then access window.parent.FormCotextP from its code. There is no other way to pass objects between frame levels in an HTML document. The only thing you have used in the above is that the form script and web resouces are loaded as siblings, however, you can remove that assumption by posting the promise object to the topmost window, if you want.

Assuming you are using react, you could do:

class MyComponent extends React.Component<..,...> {
...
    public componentDidMount(): void {
        const p = (window.parent as any).FormContextP
        if (p)
            p.then(fctx => {
                this.setState({ formContext: (fctx as Xrm.PageContext) })
            })
    }
...
}

I’ve created a highly re-usable EntityForm that captures this and other Dynamics form related information and passes it to the child component. It makes it easy to access key “context” and other information that is needed. Note that in the above, we capture the form context and stick it into state, we could also provide this as “context” to child components by setting up some context methods in the class.

Monday, November 20, 2017

React, Redux, Typescript + Dynamics (Xrm, Crm) Client Programming

I've been assembling notes on react, dynamics and front end programming.

You may find them useful. They are in a state of continuous edit.

gitbook link

Thursday, November 9, 2017

Dynamics, Web API, FetchXml, generating your missing paging cookie

If you use fetchxml with the latest web api, you may be surprised that sometimes you do not get the paging cookie back when your results are > 5000 records. There are alot of articles on the web about using the paging cookie once you do have it.

How do you make sure you get your cookie?

Some people suggest just sticking the paging number into the fetchxml is the answer, but that causes thrash on the server (even if its in the cloud) if you have alot of results to page through.

<fetch page="20" ...>
...
</fetch>

That’s not great as its possible that the server may need to keep running the same query and tossing aside results–maybe a cache will save the day, but maybe not.

The real answer to always generate a paging cookie is contained on MSDN.

You need to add the right odata annotation to have the paging cookie generated.

...code to generate the request
content += 'Prefer: odata.include-annotations="Microsoft.Dynamics.CRM.*"\n'
...

I have not seen this mentioned anywhere so far, so I thought I would write this up. Note that you can use the fully specified annotation Microsoft.Dynamics.CRM.fetchxmlpagingcookie but the .* version picks any other CRM specific annotation that may be out there so I use the .* version vs the specific one. The OData spec has alot of notes on annotations and how to add and remove them. It’s worth a read of course. Don’t forget to add your other annotations e.g. FormattedValues.

There are many API libraries out there that are quite poor in that they do not allow you to easily batch request your fetchxml if your fetchxml string length is too large for the URL variety. Be aware of you what tools you use and their limitations. There is still a URL length limitation in batch requests but it is much larger than the URL limitation. You’ll still need to chunk your fetchxml somehow if you are retrieving, for example, something that requires a large list of values in a condition clause.

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