Oracle: How to pass empty associative array to Stored Procedure

August 19th, 2014 1 comment

    One of the possible ways to pass a collection of values to a stored procedure is to use Associative Array (also known as Index-By Tables). For example, the declaration of the stored procedure accepting array of strings may resemble the following:

TYPE str_table_type IS TABLE OF VARCHAR2(255) INDEX BY PLS_INTEGER;

PROCEDURE Save_Something (
    p_str_array IN str_table_type,
    ...
    p_MSG_OUT OUT    VARCHAR2
);

To call such stored procedure we need to create and properly fill out the p_str_array parameter. The code doing that may look like shown below:

// create the parameter. 
// In case of Associative Array the passed OracleDbType.Varchar2 is a type of the elements
OracleParameter array = new OracleParameter("p_str_array", 
                                      OracleDbType.Varchar2, ParameterDirection.Input);

// Specify it as PL/SQL Associative Array
array.CollectionType  = OracleCollectionType.PLSQLAssociativeArray;

string[] values = new[] { "Value One", "Value Two", "Value Three" };

// Set the values for PL/SQL Associative Array
array.Value = values;

// Set the maximum number of elements in the PL/SQL Associative Array,
// in case of Input direction, Size usually equals to the number of passed values
array.Size = values.Length;

// Pass to the command and call one
using (var cmd = new OracleCommand("DNF.SOME_PKG.Save_Something")
                                   { CommandType = CommandType.StoredProcedure })
{
	cmd.Parameters.Add(array);
	cmd.Parameters.Add(new OracleParameter("p_MSG_OUT", OracleDbType.Varchar2, 
                                           ParameterDirection.Output) { Size = 2000 });
	
	cmd.ExecuteNonQuery();
	
	// process p_MSG_OUT somehow
	...
}

The use of the Associative Array is pretty straightforward unless we are trying to pass an empty array. I tried a few ideas coming to mind first (listed below), none of them doesn’t work though:

array.Value = null;
array.Size  = 0;
...
array.Value = new string[0];
array.Size  = 0;
...
array.Value = new string[1] { null };
array.Size  = 1;

The “OracleParameter.Value is invalid” was the most popular exception I was getting at that moment. Ultimately, however, I found a working variant:

// for array of strings
array.Value = new OracleString[1] { OracleString.Null };
array.Size  = 1;
...
// for array of Int32s, decimals and so on
array.Value = new OracleDecimal[1] { OracleDecimal.Null };
array.Size  = 1;

As the result, the following common method and a few accompanying ones have been born to create Associative Arrays of different types:

// Creates an Associative Array parameter, knows how to treat empty collections
// Note: specify the maxNumberOfElementsInArray as an expected number of returned elements
public OracleParameter CreateAssociativeArray<ValueType, OracleType>(
                      string name, List<ValueType> values, 
                      ParameterDirection direction, OracleDbType oracleDbType, 
                      OracleType nullValue, int? maxNumberOfElementsInArray)
{
	bool isArrayEmpty     = values == null || values.Count == 0;
	OracleParameter array = new OracleParameter(name, oracleDbType, direction);
	array.CollectionType  = OracleCollectionType.PLSQLAssociativeArray;
	array.Value           = !isArrayEmpty ? values.ToArray() :
                                            (object)new OracleType[1] { nullValue };
	array.Size            = !isArrayEmpty ? values.Count     : 1;

	// if it's Output/InputOutput parameter, set the maximum possible number of elements.            
	if (maxNumberOfElementsInArray != null && 
       (direction == ParameterDirection.Output || direction == ParameterDirection.InputOutput))
		array.Size = Math.Max(array.Size, maxNumberOfElementsInArray.Value);

	return array;
}

public OracleParameter CreateInt32AssociativeArray(string name, 
          List<int> values, ParameterDirection direction = ParameterDirection.Input, 
          int? maxNumberOfElementsInArray = null)
{
	return CreateAssociativeArray<int, OracleDecimal>(name, values, 
             direction, OracleDbType.Int32, OracleDecimal.Null, maxNumberOfElementsInArray);
}

public OracleParameter CreateDecimalAssociativeArray(string name, 
           List<decimal> values, ParameterDirection direction = ParameterDirection.Input, 
           int? maxNumberOfElementsInArray = null)
{
	return CreateAssociativeArray<decimal, OracleDecimal>(name, values, direction, 
             OracleDbType.Decimal, OracleDecimal.Null, maxNumberOfElementsInArray);
}

public OracleParameter CreateStringAssociativeArray(string name, 
            List<string> values, ParameterDirection direction = ParameterDirection.Input, 
            int? maxNumberOfElementsInArray = null, int maxLength = 255)
{
	var res = CreateAssociativeArray<string, OracleString>(name, values, direction, 
               OracleDbType.Varchar2, OracleString.Null, maxNumberOfElementsInArray);

	if(direction == ParameterDirection.Output || direction == ParameterDirection.InputOutput)
	{
		int curMaxLen = maxLength;
		if(values != null)
			values.ForEach(s => { if (curMaxLen < s.Length) curMaxLen = s.Length; });

		res.ArrayBindSize = new int[res.Size];
		for (int i = 0; i < res.Size; i++)
			res.ArrayBindSize[i] = curMaxLen;
	}

	return res;
}

There are two important things to note here. The first one, in case of Output or InputOutput direction, the Size has to be set to the maximum number of elements you expect to get from the stored procedure (see the CreateAssociativeArray method). Specify this maximum number in the maxNumberOfElementsInArray. For example, you want to pass 3 elements to the stored procedure and get 10 back (InputOutput direction). Set the maxNumberOfElementsInArray to 10, otherwise CreateAssociativeArray will set Size to 3.

The second thing is the special processing of array of strings (see the CreateStringAssociativeArray method). String is variable-length element type (Varchar2 and so on). So, for strings we need to define the ArrayBindSize property. ArrayBindSize is the collection each element of which specifies the length of the corresponding element in the Value property. Being longer than specified, the element will be truncated. Pass the maximum allowed length of elements in maxLength, otherwise it will be set to 255 (default) or the maximum length found in the Value property.

Use the following code to create arrays of strings, Int32s and decimals in case of Input direction (or create your own “accompanying” methods for other types):

OracleParameter intArray = CreateInt32AssociativeArray("p_int_array", 
               new List<int> { 1, 2, 3 });

// pass empty array to the stored procedure
OracleParameter decimalArray = CreateDecimalAssociativeArray("p_dec_array", null);

OracleParameter strArray = CreateStringAssociativeArray("p_str_array",  
              new List<string> { "Value One", "Value Two", "Value Three" });

If Associative Array should be an Output or InputOutput parameter, the code may look like:

// Output parameter
// we expect no more than 10 elements to be returned
OracleParameter intArray = CreateInt32AssociativeArray("p_int_array", null, 
     ParameterDirection.Output, 10);
...

// InputOutput parameter
// declare input values
string[] tmpAtrArray = new string[3] { "Value One", "Value Two", "Value Three" };
// we expect no more than 20 strings to be returned
// returned strings suppose to be no longer than 100
OracleParameter strArray = CreateStringAssociativeArray("p_str_array", 
     new List<string>(tmpAtrArray), ParameterDirection.InputOutput, 20, 100);

To read, for example, number values (presented as OracleDecimal) from the output Associative Array I use the following two utility methods:

public Nullable<T> GetValue<T>(OracleDecimal oracleDecimal, 
      Func<OracleDecimal, T> convert) where T : struct
{
	if (oracleDecimal == null || oracleDecimal.IsNull)
		return null;

	return convert(oracleDecimal);            
}

public List<T> GetValues<T>(OracleParameter parameter, 
     Func<OracleDecimal, T> convert) where T : struct
{
	List<T> res = new List<T>();

	if(parameter.Value != null)
	{
		OracleDecimal[] values = parameter.Value as OracleDecimal[];
		if(values != null)
			foreach(OracleDecimal oDecimal in values)
			{
				Nullable<T> val = GetValue(oDecimal, convert);
				if (val != null)
					res.Add(val.Value);
			}
	}

	return res;
}

So, the reading of integers looks as follows:

OracleParameter intArray = CreateInt32AssociativeArray("p_int_array", null, 
            ParameterDirection.Output, 10);
...
// read returned values
List<int> res = GetValues<int>(intArray, od => { return od.ToInt32(); });

All code has been written and tested with the ODAC 12c Release 2.

SharePoint: Resolve user through the particular Membership Provider

April 28th, 2014 No comments

    We developed a few SharePoint-based applications comprised of two parts: internal and public. The internal one is accessible for Domain users only, while the public one points to the Internet and virtually available for everyone. Each of the applications uses the Claims Based Authentication and is extended to have two zones: the Default zone represents the internal part, while the Internet zone is for the public one. The Claims Based Authentication of the Default and Internet zones operates over the NTLM Integrated Windows Authentication and the Forms Based Authentication (FBA), respectively. For FBA we used our custom Membership provider derived from the SqlMembershipProvider and Role manager derived from the SqlRoleProvider, while the users’ email addresses served as the logins to sign in to the system. The problem came out when users having a Domain account had used their Domain emails to register and sign in to the system through the public part.

Let’s say there is a Domain user SOMEDOMAIN\firstname.lastname with the email lastname@somedomain. The user decides to test our application and registers in the public part, entering his email lastname@somedomain. During the registration the code similar to the listed below is being performed:

string userEmail    = "lastname@somedomain";
string userPassword = "1234567";
string providerName = "SomeCustomProvider";
...
SqlMembershipProvider customProvider = 
                           Membership.Providers[providerName] as SqlMembershipProvider;
...
MembershipCreateStatus membershipCreateStatus;
MembershipUser membershipUser = customProvider.CreateUser(userEmail, 
									userPassword, userEmail, 
									null, null, true, Guid.NewGuid(), 
									out membershipCreateStatus);
...
if (membershipUser != null && membershipCreateStatus == MembershipCreateStatus.Success)
{
	SPWeb spWeb = ...;
	...
	SPUser spUser = spWeb.EnsureUser(membershipUser.UserName);
	...
	// add user to a SharePoint group
	SPGroup publicUsersGroup = ...;
	publicUsersGroup.AddUser(spUser);
	...
	// give spUser unique permissions and so on
}

So, right after the customProvider.CreateUser has been called, we still have the Domain account SOMEDOMAIN\firstname.lastname with the email lastname@somedomain plus we have a new user with the login lastname@somedomain and the identical email. The latter one is stored in database and managed by the custom membership provider.

To get the proper instance of SPUser, we call EnsureUser (it else could be SafeUserEnsure) and pass user’s login which, in the given case, is actually the user’s email. Behind the scenes SharePoint attempts to sequentially resolve the user through all of available membership providers. Besides our custom membership provider, there is another one provided by SharePoint itself, namely, SPClaimsAuthMembershipProvider (Microsoft.SharePoint.Administration.Claims.SPClaimsAuthMembershipProvider defined in Microsoft.SharePoint) which is the default provider (when you add your custom membership provider to the web.config files it’s strongly recommended to keep the SPClaimsAuthMembershipProvider as a default provider to avoid unexpected behaviour). Apparently the default provider is the first one resorted to resolve the user. The pitfall here is that the SPClaimsAuthMembershipProvider finds and returns the Domain user (evidently in this case the user is resolved by email). So, following our example, the received SPUser will reference the SOMEDOMAIN\firstname.lastname with the email lastname@somedomain. Having gotten the wrong SPUser, we add it to groups and grant some permissions. Of course, once the registered user tries to sign in to the public part of the application, he stumbles upon Access Denied error as he doesn’t have any permissions since all required ones were provided to the Domain user. So, below is how to resolve this ambiguity.

If we take a look at how the user registered through the custom membership provider looks like in the SharePoint we’ll see something like the following:

i:0#.f|somecustomprovider|lastname@somedomain

More information regarding this format of Claims you’ll find here. The most valuable fact for us right now is that such encoded user name contains the name of the membership provider which manages the user (following the example above, it’s somecustomprovider). Obviously, if we pass such encoded user name into EnsureUser, the right membership provider will be applied and, therefore, the right SPUser will be returned. So, let’s find a way to turn the usual user name (lastname@somedomain) into the encoded one (i:0#.f|somecustomprovider|lastname@somedomain).

Such transformation might be easily done by means of SPClaimProviderManager that allows managing available claims providers and supplies various utility methods to encode and decode claims. So, the method below accepts usual user name and name of membership provider and returns the encoded user name:

public static string GetFbaEncodedUserName(string userName, string membershipProviderName)
{
	if (!SPClaimProviderManager.IsEncodedClaim(userName))
	{
		SPClaim claim = SPClaimProviderManager.CreateUserClaim(userName, 
											SPOriginalIssuerType.Forms, 
											membershipProviderName);
		return claim.ToEncodedString();
	}
	return userName;
}

The method creates a claim and returns its encoded representation. The Fba in the method’s name implies that we deal with the Form Based Authentication hence the SPOriginalIssuerType.Forms type is passed to the SPClaimProviderManager.CreateUserClaim.

The code below illustrates how to use the method:

string userName     = "lastname@somedomain";
string providerName = "SomeCustomProvider";

// get the user name resembling i:0#.f|somecustomprovider|lastname@somedomain
string encodedUserName = GetFbaEncodedUserName(userName, providerName);

SPWeb spWeb;

// get the right SPUser instance
SPUser spUser = spWeb.EnsureUser(encodedUserName);

SharePoint: How to add a new SPGroup through the code

March 9th, 2014 No comments

    The below listed CreateSpGroup methods along with a few subsidiary ones could be used to add a new SPGroup to a SPWeb

public static SPGroup CreateSpGroup(SPWeb spWeb, string groupName, 
                                    string description, bool isAssociated)
{
  SPGroup res = GetGroupByName(spWeb, groupName);
  if (res == null)
  {
    spWeb.SiteGroups.Add(groupName, spWeb.Site.SystemAccount, null, description);
    res = spWeb.SiteGroups[groupName];

    if (isAssociated)
    {
      spWeb.AssociatedGroups.Add(res);

      const string createdGroupsProperty = "vti_createdassociategroups";
      var createdAssociatedGroupsStr = spWeb.AllProperties[createdGroupsProperty] as string;
      if (!string.IsNullOrEmpty(createdAssociatedGroupsStr))
      {
        var createdAssociatedGroups = ParseAssociateGroupsFromString(createdAssociatedGroupsStr);
        if (!createdAssociatedGroups.Contains(res.ID))
        {
          createdAssociatedGroups.Add(res.ID);
          spWeb.AllProperties[createdGroupsProperty] = 
                               ConvertAssociateGroupsToString(createdAssociatedGroups);
        }
      }
      spWeb.Update();
    }
  }
  return res;
}

public static SPGroup CreateSpGroup(SPWeb spWeb, string groupName)
{
	return CreateSpGroup(spWeb, groupName, string.Empty, false);
}

public static List<int> ParseAssociateGroupsFromString(string str)
{
  var list = new List<int>();
  if (!string.IsNullOrEmpty(str))
    foreach (string str2 in str.Split(new[] { ';' }))
    {
        int num2;
        if ((!string.IsNullOrEmpty(str2) && 
            int.TryParse(str2, NumberStyles.Integer, CultureInfo.InvariantCulture, out num2)) && 
                    ((num2 > 0) && !list.Contains(num2)))
          list.Add(num2);
    }
  return list;
}

public static string ConvertAssociateGroupsToString(List<int> groupIds)
{
	var builder = new StringBuilder();
	for (int i = 0; i < groupIds.Count; i++)
	{
		if (builder.Length > 0)
			builder.Append(';');
		builder.Append(groupIds[i].ToString(CultureInfo.InvariantCulture));
	}
	return builder.ToString();
}

public static SPGroup GetGroupByName(SPWeb spWeb, string groupName)
{
	foreach (SPGroup group in spWeb.SiteGroups)
		if (groupName.Equals(group.Name, StringComparison.OrdinalIgnoreCase))
			return group;
	return null;
}

At first the CreateSpGroup method makes sure whether the target group doesn’t exist by calling the GetGroupByName. If such group has been found, it’s returned, otherwise a new empty group is being created. The SPWeb.Site.SystemAccount aka Site Collection administrator will be the owner of the new group.

The isAssociated flag indicates if the group has to be associated with the site. Let’s linger on associated groups a bit longer.

When a site (or sub-site with unique permissions) is created, three default groups associated with the site become available. Their names usually are “YourSiteName Owners”, “YourSiteName Members” and “YourSiteName Visitors”, where the YourSiteName is the name you gave to your site. You can see them in the Groups section at Quick Launch area when going to the “People and Groups” page (Site Actions -> Site Settings -> People and groups). Through the code those groups are accessible as SPWeb.AssociatedOwnerGroup, SPWeb.AssociatedMemberGroup and SPWeb.AssociatedVisitorGroup respectively. We also can get their IDs from the AllProperties collection of a SPWeb instance like SPWeb.AllProperties[“vti_associateownergroup”], SPWeb.AllProperties[“vti_associatemembergroup”] and SPWeb.AllProperties[“vti_associatevisitorgroup”] respectively. We are able to associate our own custom groups with the site by adding them to the SPWeb.AssociatedGroups collection, so that our groups would appear in the Quick Launch at the “People and Groups” page. So, that’s the first thing the CreateSpGroup does in case the isAssociated is set to true.

One more often-mentioned feature of associated groups is that they allegedly can be deleted automatically when the site/sub-site they are associated with is being deleted. Here, however, there is a pitfall: all groups are SiteCollection-level objects, therefore removing the site/sub-site, the groups (including associated ones) are still available as they are linked to the site collection. SharePoint, however, treats the associated default groups in a special way, namely the groups specified in the SPWeb.AssociatedOwnerGroup, SPWeb.AssociatedMemberGroup and SPWeb.AssociatedVisitorGroup will be actually deleted with the site/sub-site jointly. As regards custom associated groups, in the general case, they will never be automatically deleted with the site. So, don’t count on it. If, however, you are still eager to make your custom group deletable by default, you don’t have any choice but to override one of the associated default groups with the custom one. That’s not a complete solution though, you also have to add the id of the custom group to the spWeb.AllProperties[“vti_createdassociategroups”]. I came to this, having reviewed such methods as Microsoft.SharePoint.Utilities.SPUtilityInternal.CreateDefaultSharePointGroups and Microsoft.SharePoint.ApplicationPages.DeleteWebPage.OnLoad by means of .Net Reflector. So, in case the isAssociated is set to true, the second thing the CreateSpGroup does is adding group’s id to the “vti_createdassociategroups” property. After that the only step you have to do to make your custom group deletable is something like this

spWeb.AssociatedVisitorGroup = CreateSpGroup(spWeb, "my own custom group", string.Empty, true);
//spWeb.AssociatedMemberGroup  = CreateSpGroup(spWeb, "my own custom group", string.Empty, true);
//spWeb.AssociatedOwnerGroup   = CreateSpGroup(spWeb, "my own custom group", string.Empty, true);

Note that, doing that, the overridden default group still remains and is linked to the Site Collection. In case it is not needed any more, remove it. So, before overriding the default group, you’d better save the reference to it and then, after overriding, remove it by calling SPWeb.SiteGroups.Remove.

Nevertheless I suggest you not override associated default groups as you are always able to customize them the way you need instead: change name and description, add users you want and so on.

One more important thing to be aware of is, deleting a site/sub-site, you likely just move it to the Site Collection Recycle Bin (unless the Recycle Bin is off). That means the associated default groups will not be deleted right away, they will be deleted only at that moment when the site is being erased from the Recycle Bin. Unlike the site they are associated with, the groups will not be moved to the Recycle Bin. Moreover SharePoint doesn’t even mark them as “being deleted”. Thus during the Recycle Bin retention period (30 days by default) the associated default groups, being the SiteCollection-level objects, remain available and can be used by someone else. So, somebody who rashly uses the default groups of the “deleted” site may be unpleasantly surprised some day as the groups suddenly disappear. To avoid this, I personally (of course if I have no doubts that the site/sub-site has to vanish completely) erase it from Recycle Bin right away or ask Site Collection administrators to do this as soon as possible.

Let’s go back to the rest of the methods listed above. The ParseAssociateGroupsFromString method parses the value of vti_createdassociategroups into an array of groups’ IDs. The ConvertAssociateGroupsToString, in turn, makes inverse operation and turns the array into a string to save it in the vti_createdassociategroups property. The format of the vti_createdassociategroups is very simple, it’s a set of groups’ IDs separated with semicolons like 1;2;3. Nothing is easier than to implement parse of such string. I, however, decided to use the methods found in SharePoint‘s interior. So, ParseAssociateGroupsFromString and ConvertAssociateGroupsToString are internal methods defined in the Microsoft.SharePoint.ApplicationPages.PeopleGroupPageBase and captured by .Net Reflector.

The GetGroupByName is intended to look for a group with a specified name and used to check if the group about to be created exists.

As I said before, the groups are SiteCollection-level objects, so groups have to be created under privileged account at least under Site Collection administrator. So, the CreateSpGroup could be called as shown below:

// use of system account
string siteUrl = "some site url";
SPUserToken spUserToken = GetSystemToken(siteUrl);
using (SPSite spSite = new SPSite(siteUrl, spUserToken))
{
	using (SPWeb spWeb = spSite.OpenWeb())
	{
		SPGroup newGroup = CreateSpGroup(spWeb, "new group", string.Empty, true);
		// do something with the new group
	}
}

*Note: the GetSystemToken method is described here – SharePoint: How to get SystemAccount token.

or

// use of application pool identity
SPSecurity.RunWithElevatedPrivileges(delegate
{
    using (SPSite spSite = new SPSite("some site url"))
        using (SPWeb spWeb = spSite.OpenWeb())
        {
            SPGroup newGroup = CreateSpGroup(spWeb, "new group", string.Empty, true);
            // do something with the new group
        }
});

Dynamics CRM: Query Builder Error

November 20th, 2013 1 comment

    I was experimenting with the etc (Entity Type Code) parameter in urls placed within the SiteMap.xml of my solution and deployed the solution with a non-existent etc to Dynamics CRM 2013 at Office 365. Unlike Dynamics CRM 2011 which just ignores this parameter, Dynamics CRM 2013 responded to me with a Query Builder Error, namely

The specified record type does not exist in Microsoft Dynamics CRM


Query Builder Error

The main pitfall here is that you don’t have a chance to go to any other pages and fix the problem because everything you get are the error message and the Try Again button which gives you a unique opportunity to see the error again 🙂 But don’t rush to call support, try the following direct url to the Customization page:

https://yourOrganizationName.crm.dynamics.com/tools/systemcustomization/systemCustomization.aspx

* replace yourOrganizationName with your Organization name

Or for on-premise instances

http://yourServerName/yourOrganizationName/tools/systemcustomization/systemcustomization.aspx

* replace yourServerName and yourOrganizationName with your Server and Organization names respectively

System Customization Page

The Customization page itself doesn’t contain the navigation tiles that cause the Query Builder Error. So, through the page you’ll be able to manage installed packages and, like in my case, to remove the problem solution.

Dynamics CRM: How to add a map to an Entity form

November 13th, 2013 No comments

    Let’s say we have an Entity with Latitude and Longitude attributes and we want to display a pushpin at this point on a map when opening the Entity‘s form. Briefly, I offer to add a section to the form (the section is a placeholder for the map) and bind the form’s OnLoad event to a custom JavaScript, which finds the section, adds Bing Map Control to it, gets Latitude and Longitude attributes of a current record and displays the appropriate point on the map.

1. Add JavaScript to Web Resources

Let’s assume we have the custom unmanaged solution named Test Solution that provides with the Bus Stop Entity containing Latitude and Longitude attributes. Open the solution and create a JavaScript (JScript) Web Resource with the code listed below. Read the comments within the code as they explain what happens inside.

//If the dotNetFollower namespace object is not defined, create it
if (typeof (dotNetFollower) == "undefined") {
    dotNetFollower = {};
}

dotNetFollower.BingMapLib = (function () {
    var self = this; // save reference to itself
	
	self.bingKey = "put here your key";
    self.map     = null; // map control   
    self.mapDiv  = null; // div, a placeholder for Map Control   

	// dynamically adds a script to the page
    self.addScript = function(url, callback) {
		var script   = document.createElement('script');
		script.src   = url;
		script.type  = 'text/javascript';
		script.defer = false;
	 
		if (typeof callback != "undefined" && callback != null) {
	 
			// IE only, connect to event, which fires when JavaScript is loaded
			script.onreadystatechange = function() {
				if (this.readyState == 'complete' || this.readyState == 'loaded') {
					this.onreadystatechange = this.onload = null; // prevent duplicate calls
					callback();
				}
			}
	 
			// FireFox and others, connect to event, which fires when JavaScript is loaded
			script.onload = function() {
				this.onreadystatechange = this.onload = null; // prevent duplicate calls
				callback();
			};
		}
	 
		var head = document.getElementsByTagName('head').item(0);
		head.appendChild(script);
	};
	
	// checks if a passed string is Null or Empty
	self.isNullOrEmpty = function (str) {
        return !str || $.trim(str) === ""; // the trim method is provided by jQuery
    };
	
	// checks if Microsoft.Maps.Map is available 
	self.scriptsReady = function () {
        return typeof (Microsoft)          != 'undefined' &&
        	   typeof (Microsoft.Maps)     != 'undefined' && 
        	   typeof (Microsoft.Maps.Map) != 'undefined' && 
        	   typeof (Xrm)                != 'undefined';
    };
	
	// adds a div to the section within the form, the div is supposed to host a Bing Map Control
	self.prepareMapPlaceholder = function() {
		var mapWidth  = '100%';
		var mapHeight = '400px';
		
		// get the section by label Map
		var section = $('table[label="Map"]');
		
		// if section isn't found, that could mean that we deal with 
        // the CRM 2013. So, let's try to find the section using another selector
        if(section.length == 0)
        	section = $(":header:contains('Map')").parents("table");
		
        var td = section.find('td[colspan="2"]:last');		
		
		// add a div
		td.html('<div id="mapPlaceholder" style="width:' + mapWidth + 
                '; height: ' + mapHeight + ';" />');
		self.mapDiv = td.find('#mapPlaceholder')[0];
	};
    
    // creates a Bing Map Control hosted in the passed args.div
    self.loadMap = function (args) {
    	// check if Microsoft.Maps.Map is available 
        if (self.scriptsReady()) {
        
            if (self.map == null) {
                var div = $(args.div);
                
                // create a Bing Map Control
                self.map = new Microsoft.Maps.Map(args.div, {
                    credentials: self.bingKey,
                    showDashboard: false,
                    showMapTypeSelector: false,
                    showScalebar: false,
                    mapTypeId: Microsoft.Maps.MapTypeId.auto,
                    zoom: args.zoom,
                    width: div.parent().width(),
                    height: div.height(),
                    center: new Microsoft.Maps.Location(args.lat, args.lon),
                    enableSearchLogo: false,
                    enableClickableLogo: false
                });
                
                // hook up to resize event to adjust the map's size in time
                $(window).resize(function () {
                    self.map.setOptions({ width: div.parent().width(), height: div.height() });                    
                });                
            }
            
            // call the callback
            args.callback();
        }
        else
        	// in spite of calling the loadMap function right away after 
        	// the Bing Map script is loaded, 
        	// the Microsoft.Maps.Map still may be undefined, 
        	// so wait for a while in this case
            setTimeout(self.loadMap, 1000, args);
    };
    
    // displays a pushpin on the map
    self.showBusStop = function() {
		
	  self.prepareMapPlaceholder();
		
	  // dynamically add the Bing Map script
	  self.addScript("https://ecn.dev.virtualearth.net/mapcontrol/mapcontrol.ashx?v=7.0&s=1", 
       function() {
		// this code is executed once the Bing Map script has been loaded
		
		// get lat/lon of the current record
		var lat  = Xrm.Page.getAttribute("testpub_latitude").getValue();
		var lon  = Xrm.Page.getAttribute("testpub_longitude").getValue();        
        var zoom = 15;
        
        // if lat or/and lon are null or empty, 
        // use the default location namely the center of the USA
        if(self.isNullOrEmpty(lat) || self.isNullOrEmpty(lon)) {
        	lat  = 39.8282;
        	lon  = -98.5795;
        	zoom = 1;
        }
        
        // create Map Control hosted in the self.mapDiv
		self.loadMap({lat: lat, lon: lon, zoom: zoom, div: self.mapDiv, callback: function() {
			
			// this code is executed once the Microsoft.Maps.Map gets defined
			
			// create a pushpin
			var location = new Microsoft.Maps.Location(lat, lon);
         	var pushpin  = new Microsoft.Maps.Pushpin(location);
            	
         	// add the pushpin to the map
            self.map.entities.push(pushpin);
		}});
	  });
	};
    
    return self;
})();

The showBusStop function is an entry point that will be called on the Entity form’s OnLoad event (I’ll show later how to bind the event to the showBusStop). The showBusStop prepares the section for holding the map (how to add the section to the form will be described later as well) and dynamically includes the Bing Map script into the page by means of the addScript function described in details here – How to add a script-tag dynamically. Once the Bing Map script has been loaded, we get the Latitude and Longitude and initiate Map Control creation by calling the loadMap function. Note that the Bing Map script dynamically includes other scripts, and one of those is responsible for Microsoft.Maps.Map definition. So, the loadMap waits until the Microsoft.Maps.Map gets available by recalling itself through the setTimeout. Once the Map Control has been created, we create a pushpin and add it to the map.

The Map Control will adjust its width to fill the available space whenever the window’s size is changed.

The above script largely uses jQuery, so the jQuery has to be added to Web Resources as well. In my case the Web Resources have been named testpub_insertMap and testpub_jQuery respectively:

Crm Web Resources

2. Add a section to Entity form.

Open the Bus Stop Entity‘s main form for editing and add a One Column section, for example, to the General tab. Open the section’s properties (double click on the section) and set the Label property to Map (the word “Map” is used by the testpub_insertMap script to locate the section in the DOM tree). The picture below illustrates these steps:

Add Section To Entity Form

3. Bind Entity Form’s OnLoad event to the custom JavaScript function

Keep the Bus Stop Entity‘s main form open for editing and click on Form Properties. There first of all we need to make the custom scripts available on the form. Technically it means that our JavaScript Web Resources will be dynamically included into the page being loaded. So, in the Form Libraries section (at the Event tab), add testpub_jQuery and testpub_insertMap to the list. In our case, the order of the scripts is important: the testpub_jQuery must come first, i.e. be above the testpub_insertMap.

The second step is to specify what function from one of the added JavaScript Web Resources should be called on form’s OnLoad event. In the Event Handlers section, add a new event handler by choosing the testpub_insertMap as a Library and setting the dotNetFollower.BingMapLib.showBusStop as a Function. Note that no parameters are required to be passed into the dotNetFollower.BingMapLib.showBusStop. After these two steps you should have something like this:

Add Scripts To Entity Form OnLoad

If you have done everything right, the result view of the Bus Stop Entity‘s form should look like the following:

Map On Entity Form

The sample unmanaged solution you can download here – testsolution_1_0.zip